Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ resolves both via `getSettingsSectionMeta(plane, section)` and the
Adding a new settings page:

1. Add the section id to the `UnifiedSettingsSection` union + a `SETTINGS_SECTION_REGISTRY`
entry (with `label` **and** `unified.description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
entry (with `label` **and** its description, as described above) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
~40–55 chars, in the product voice (see `.claude/rules/constitution.md`).
2. Register its module in `SECTION_MODULES` (`settings/section-warmers.ts`) and render it
inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`.
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/sim-settings-pages.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ resolves both via `getSettingsSectionMeta(plane, section)` and the
Adding a new settings page:

1. Add the section id to the `UnifiedSettingsSection` union + a `SETTINGS_SECTION_REGISTRY`
entry (with `label` **and** `unified.description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
entry (with `label` **and** its description, as described above) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
~40–55 chars, in the product voice (see `.claude/rules/constitution.md`).
2. Register its module in `SECTION_MODULES` (`settings/section-warmers.ts`) and render it
inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`.
Expand Down
28 changes: 28 additions & 0 deletions scripts/check-utils-enforcement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { findViolations } from './check-utils-enforcement'

const ES2023 = 'export const sorted = (items: number[]) => items.toSorted()\n'
const INLINE_ERROR_MESSAGE =
"export const message = (e: unknown) => (e instanceof Error ? e.message : 'failed')\n"

function descriptions(file: string, source: string) {
return findViolations(file, source).map(({ description }) => description)
}

describe('helper-source exemptions', () => {
it('still applies browser-runtime rules to @sim/utils, which ships to the browser', () => {
expect(descriptions('packages/utils/src/array.ts', ES2023)).toHaveLength(1)
})

it('still applies browser-runtime rules to allowlisted files', () => {
expect(descriptions('packages/cli/src/index.ts', ES2023)).toHaveLength(1)
})

it('lets @sim/utils use the primitive its helper replaces', () => {
expect(descriptions('packages/utils/src/errors.ts', INLINE_ERROR_MESSAGE)).toEqual([])
})

it('rejects that primitive everywhere else', () => {
expect(descriptions('apps/sim/lib/example.ts', INLINE_ERROR_MESSAGE)).toHaveLength(1)
})
})
105 changes: 64 additions & 41 deletions scripts/check-utils-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
import { readFileSync } from 'node:fs'
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const ROOT = path.resolve(import.meta.dir, '..')
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')

const SCAN_DIRS = [path.join(ROOT, 'apps'), path.join(ROOT, 'packages')]

Expand Down Expand Up @@ -63,68 +64,80 @@ const BANNED_PATTERNS: Array<{
suggestion: string
/** Cheap literal test that skips the pattern on files that cannot match; memoized per file. */
prefilter?: RegExp
/** Bans re-implementing a helper, so `@sim/utils` and the allowlisted files are exempt. */
replacesHelper?: true
}> = [
// Randomness / ID generation — global property access that import bans miss
{
pattern: /\bMath\.random\s*\(/g,
description: 'Math.random()',
suggestion: 'randomInt / randomFloat / randomItem from @sim/utils/random',
replacesHelper: true,
},
{
pattern: /\bcrypto\.randomUUID\s*\(/g,
description: 'crypto.randomUUID()',
suggestion: 'generateId() or generateShortId() from @sim/utils/id',
replacesHelper: true,
},
{
pattern: /\bcrypto\.randomBytes\s*\(/g,
description: 'crypto.randomBytes()',
suggestion: 'generateRandomBytes() or generateRandomHex() from @sim/utils/random',
replacesHelper: true,
},
// Deep clone idiom
{
pattern: /JSON\.parse\s*\(\s*JSON\.stringify\s*\(/g,
description: 'JSON.parse(JSON.stringify(...))',
suggestion: 'structuredClone() — built-in, no import needed',
replacesHelper: true,
},
// Inline error message extraction (excludes null/undefined/false fallbacks — those have different semantics)
{
pattern: /instanceof Error\s*\?\s*\w+\.message\s*:\s*(?!\s*null\b|\s*undefined\b|\s*false\b)./g,
description: 'e instanceof Error ? e.message : fallback',
suggestion: 'getErrorMessage(e, fallback?) from @sim/utils/errors',
replacesHelper: true,
},
// Inline sleep
{
pattern: /new Promise\s*[(<]\s*(?:resolve|\(resolve\))\s*=>\s*setTimeout\s*\(\s*resolve/g,
description: 'new Promise(resolve => setTimeout(resolve, ms))',
suggestion: 'sleep(ms) from @sim/utils/helpers',
replacesHelper: true,
},
{
pattern:
/\b([\w.]+)\s+instanceof\s+Error\s*\?\s*\1\s*:\s*new\s+Error\(\s*String\(\s*\1\s*\)\s*\)/g,
description: 'e instanceof Error ? e : new Error(String(e))',
suggestion: 'toError(e) from @sim/utils/errors',
prefilter: /new\s+Error\(\s*String\(/,
replacesHelper: true,
},
{
pattern:
/typeof\s+([\w.]+)\s*===\s*'object'\s*&&\s*\1\s*!==\s*null\s*&&\s*!Array\.isArray\(\s*\1\s*\)/g,
description: "typeof v === 'object' && v !== null && !Array.isArray(v)",
suggestion: 'isRecordLike(v) from @sim/utils/object',
prefilter: /!Array\.isArray\(/,
replacesHelper: true,
},
{
pattern:
/Object\.fromEntries\(\s*Object\.entries\([^()]*\)\s*\.filter\(\s*\(\[\s*\w*\s*,\s*(\w+)\s*\]\)\s*=>\s*\1\s*!==\s*undefined\s*\)\s*,?\s*\)/g,
description: 'Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))',
suggestion: 'filterUndefined(obj) from @sim/utils/object',
prefilter: FROM_ENTRIES,
replacesHelper: true,
},
{
pattern:
/Object\.fromEntries\(\s*Object\.entries\([^()]*\)\s*\.filter\(\s*\(\[\s*(\w+)\s*\]\)\s*=>\s*\1\s*!==\s*[\w.'"]+\s*\)\s*,?\s*\)/g,
description: 'Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key))',
suggestion: 'omit(obj, [key]) from @sim/utils/object',
prefilter: FROM_ENTRIES,
replacesHelper: true,
},
{
pattern: new RegExp(
Expand All @@ -134,6 +147,7 @@ const BANNED_PATTERNS: Array<{
description: 's.length > n ? s.slice(0, n) + suffix : s',
prefilter: TRUNCATE_PREFILTER,
suggestion: "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')",
replacesHelper: true,
},
{
pattern: new RegExp(
Expand All @@ -143,11 +157,13 @@ const BANNED_PATTERNS: Array<{
description: 's.length <= n ? s : s.slice(0, n) + suffix',
prefilter: TRUNCATE_PREFILTER,
suggestion: "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')",
replacesHelper: true,
},
{
pattern: /\/\[\.\*\+\?\^\$\{\}\(\)\|\[\\\]\\\\\]\/g/g,
description: 'hand-rolled regex-metacharacter escape',
suggestion: 'escapeRegExp(value) from @sim/utils/string',
replacesHelper: true,
},
// Render-path rules (.claude/rules/sim-react-performance.md, sim-styling.md)
{
Expand Down Expand Up @@ -278,6 +294,51 @@ function es2023LibViolations(): Violation[] {
return violations
}

/** Every banned-pattern hit in one file; `file` is repo-relative, which decides its exemptions. */
export function findViolations(file: string, content: string): Violation[] {
const violations: Violation[] = []
const helperSource = file.startsWith(UTILS_SOURCE) || ALLOWLISTED_FILES.has(file)

const matches: Array<{
index: number
description: string
suggestion: string
}> = []

const prefilterHits = new Map<RegExp, boolean>()
for (const { pattern, description, suggestion, prefilter, replacesHelper } of BANNED_PATTERNS) {
if (helperSource && replacesHelper) continue
if (prefilter) {
let hit = prefilterHits.get(prefilter)
if (hit === undefined) {
hit = prefilter.test(content)
prefilterHits.set(prefilter, hit)
}
if (!hit) continue
}
pattern.lastIndex = 0
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
matches.push({ index: match.index, description, suggestion })
}
}
if (matches.length === 0) return []

const lines = content.split('\n')
const lineStarts = buildLineStarts(content)
for (const match of matches) {
const line = lineAt(lineStarts, match.index)
if (hasAllow(lines, line)) continue
violations.push({
file,
line,
description: match.description,
suggestion: match.suggestion,
snippet: (lines[line - 1] ?? '').trim(),
})
}
return violations
}

async function main() {
const allFiles: string[] = []
for (const dir of SCAN_DIRS) {
Expand All @@ -288,45 +349,7 @@ async function main() {

for (const file of allFiles) {
const rel = path.relative(ROOT, file)
if (rel.startsWith(UTILS_SOURCE) || ALLOWLISTED_FILES.has(rel)) continue

const content = await readFile(file, 'utf8')
const matches: Array<{
index: number
description: string
suggestion: string
}> = []

const prefilterHits = new Map<RegExp, boolean>()
for (const { pattern, description, suggestion, prefilter } of BANNED_PATTERNS) {
if (prefilter) {
let hit = prefilterHits.get(prefilter)
if (hit === undefined) {
hit = prefilter.test(content)
prefilterHits.set(prefilter, hit)
}
if (!hit) continue
}
pattern.lastIndex = 0
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
matches.push({ index: match.index, description, suggestion })
}
}
if (matches.length === 0) continue

const lines = content.split('\n')
const lineStarts = buildLineStarts(content)
for (const match of matches) {
const line = lineAt(lineStarts, match.index)
if (hasAllow(lines, line)) continue
violations.push({
file: rel,
line,
description: match.description,
suggestion: match.suggestion,
snippet: (lines[line - 1] ?? '').trim(),
})
}
violations.push(...findViolations(rel, await readFile(file, 'utf8')))
}

violations.push(...es2023LibViolations())
Expand All @@ -345,4 +368,4 @@ async function main() {
process.exit(1)
}

main()
if (import.meta.main) main()
Loading