From 6bc8a9c962564b3ecb4bf9df5623c7dffa7de727 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 2 Oct 2026 16:00:09 -0700 Subject: [PATCH 1/2] fix(audits): apply browser-runtime rules to @sim/utils; align settings checklist with the standalone description rule --- .claude/rules/sim-settings-pages.md | 2 +- .cursor/rules/sim-settings-pages.mdc | 2 +- scripts/check-utils-enforcement.ts | 20 ++++++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 02c8b75661d..09393d6ef3b 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -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`. diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc index e5b83eb9796..98b9a684370 100644 --- a/.cursor/rules/sim-settings-pages.mdc +++ b/.cursor/rules/sim-settings-pages.mdc @@ -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`. diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 52ab18ab7cb..1d620b91ae1 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -63,40 +63,48 @@ 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: @@ -104,6 +112,7 @@ const BANNED_PATTERNS: Array<{ 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: @@ -111,6 +120,7 @@ const BANNED_PATTERNS: Array<{ description: "typeof v === 'object' && v !== null && !Array.isArray(v)", suggestion: 'isRecordLike(v) from @sim/utils/object', prefilter: /!Array\.isArray\(/, + replacesHelper: true, }, { pattern: @@ -118,6 +128,7 @@ const BANNED_PATTERNS: Array<{ description: 'Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))', suggestion: 'filterUndefined(obj) from @sim/utils/object', prefilter: FROM_ENTRIES, + replacesHelper: true, }, { pattern: @@ -125,6 +136,7 @@ const BANNED_PATTERNS: Array<{ 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( @@ -134,6 +146,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( @@ -143,11 +156,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) { @@ -288,7 +303,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 helperSource = rel.startsWith(UTILS_SOURCE) || ALLOWLISTED_FILES.has(rel) const content = await readFile(file, 'utf8') const matches: Array<{ @@ -298,7 +313,8 @@ async function main() { }> = [] const prefilterHits = new Map() - for (const { pattern, description, suggestion, prefilter } of BANNED_PATTERNS) { + for (const { pattern, description, suggestion, prefilter, replacesHelper } of BANNED_PATTERNS) { + if (helperSource && replacesHelper) continue if (prefilter) { let hit = prefilterHits.get(prefilter) if (hit === undefined) { From ff6a9171dc7ecbd676737382dfae06152afc22a9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 2 Oct 2026 16:14:14 -0700 Subject: [PATCH 2/2] test(audits): pin which check:utils rules helper sources are exempt from --- scripts/check-utils-enforcement.test.ts | 28 ++++++++ scripts/check-utils-enforcement.ts | 91 +++++++++++++------------ 2 files changed, 77 insertions(+), 42 deletions(-) create mode 100644 scripts/check-utils-enforcement.test.ts diff --git a/scripts/check-utils-enforcement.test.ts b/scripts/check-utils-enforcement.test.ts new file mode 100644 index 00000000000..f5a65b8d960 --- /dev/null +++ b/scripts/check-utils-enforcement.test.ts @@ -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) + }) +}) diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 1d620b91ae1..8facefb0075 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -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')] @@ -293,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() + 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) { @@ -303,46 +349,7 @@ async function main() { for (const file of allFiles) { const rel = path.relative(ROOT, file) - const helperSource = rel.startsWith(UTILS_SOURCE) || ALLOWLISTED_FILES.has(rel) - - const content = await readFile(file, 'utf8') - const matches: Array<{ - index: number - description: string - suggestion: string - }> = [] - - const prefilterHits = new Map() - 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) 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()) @@ -361,4 +368,4 @@ async function main() { process.exit(1) } -main() +if (import.meta.main) main()