Skip to content

Commit d3bf864

Browse files
authored
fix(audits): apply browser-runtime rules to @sim/utils; align settings checklist with the standalone description rule (#8591)
* fix(audits): apply browser-runtime rules to @sim/utils; align settings checklist with the standalone description rule * test(audits): pin which check:utils rules helper sources are exempt from
1 parent 405cc6a commit d3bf864

4 files changed

Lines changed: 94 additions & 43 deletions

File tree

‎.claude/rules/sim-settings-pages.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ resolves both via `getSettingsSectionMeta(plane, section)` and the
103103
Adding a new settings page:
104104

105105
1. Add the section id to the `UnifiedSettingsSection` union + a `SETTINGS_SECTION_REGISTRY`
106-
entry (with `label` **and** `unified.description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
106+
entry (with `label` **and** its description, as described above) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
107107
~40–55 chars, in the product voice (see `.claude/rules/constitution.md`).
108108
2. Register its module in `SECTION_MODULES` (`settings/section-warmers.ts`) and render it
109109
inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`.

‎.cursor/rules/sim-settings-pages.mdc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ resolves both via `getSettingsSectionMeta(plane, section)` and the
100100
Adding a new settings page:
101101

102102
1. Add the section id to the `UnifiedSettingsSection` union + a `SETTINGS_SECTION_REGISTRY`
103-
entry (with `label` **and** `unified.description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
103+
entry (with `label` **and** its description, as described above) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
104104
~40–55 chars, in the product voice (see `.claude/rules/constitution.md`).
105105
2. Register its module in `SECTION_MODULES` (`settings/section-warmers.ts`) and render it
106106
inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { findViolations } from './check-utils-enforcement'
3+
4+
const ES2023 = 'export const sorted = (items: number[]) => items.toSorted()\n'
5+
const INLINE_ERROR_MESSAGE =
6+
"export const message = (e: unknown) => (e instanceof Error ? e.message : 'failed')\n"
7+
8+
function descriptions(file: string, source: string) {
9+
return findViolations(file, source).map(({ description }) => description)
10+
}
11+
12+
describe('helper-source exemptions', () => {
13+
it('still applies browser-runtime rules to @sim/utils, which ships to the browser', () => {
14+
expect(descriptions('packages/utils/src/array.ts', ES2023)).toHaveLength(1)
15+
})
16+
17+
it('still applies browser-runtime rules to allowlisted files', () => {
18+
expect(descriptions('packages/cli/src/index.ts', ES2023)).toHaveLength(1)
19+
})
20+
21+
it('lets @sim/utils use the primitive its helper replaces', () => {
22+
expect(descriptions('packages/utils/src/errors.ts', INLINE_ERROR_MESSAGE)).toEqual([])
23+
})
24+
25+
it('rejects that primitive everywhere else', () => {
26+
expect(descriptions('apps/sim/lib/example.ts', INLINE_ERROR_MESSAGE)).toHaveLength(1)
27+
})
28+
})

‎scripts/check-utils-enforcement.ts‎

Lines changed: 64 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
import { readFileSync } from 'node:fs'
2626
import { readdir, readFile } from 'node:fs/promises'
2727
import path from 'node:path'
28+
import { fileURLToPath } from 'node:url'
2829

29-
const ROOT = path.resolve(import.meta.dir, '..')
30+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
3031

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

@@ -63,68 +64,80 @@ const BANNED_PATTERNS: Array<{
6364
suggestion: string
6465
/** Cheap literal test that skips the pattern on files that cannot match; memoized per file. */
6566
prefilter?: RegExp
67+
/** Bans re-implementing a helper, so `@sim/utils` and the allowlisted files are exempt. */
68+
replacesHelper?: true
6669
}> = [
6770
// Randomness / ID generation — global property access that import bans miss
6871
{
6972
pattern: /\bMath\.random\s*\(/g,
7073
description: 'Math.random()',
7174
suggestion: 'randomInt / randomFloat / randomItem from @sim/utils/random',
75+
replacesHelper: true,
7276
},
7377
{
7478
pattern: /\bcrypto\.randomUUID\s*\(/g,
7579
description: 'crypto.randomUUID()',
7680
suggestion: 'generateId() or generateShortId() from @sim/utils/id',
81+
replacesHelper: true,
7782
},
7883
{
7984
pattern: /\bcrypto\.randomBytes\s*\(/g,
8085
description: 'crypto.randomBytes()',
8186
suggestion: 'generateRandomBytes() or generateRandomHex() from @sim/utils/random',
87+
replacesHelper: true,
8288
},
8389
// Deep clone idiom
8490
{
8591
pattern: /JSON\.parse\s*\(\s*JSON\.stringify\s*\(/g,
8692
description: 'JSON.parse(JSON.stringify(...))',
8793
suggestion: 'structuredClone() — built-in, no import needed',
94+
replacesHelper: true,
8895
},
8996
// Inline error message extraction (excludes null/undefined/false fallbacks — those have different semantics)
9097
{
9198
pattern: /instanceof Error\s*\?\s*\w+\.message\s*:\s*(?!\s*null\b|\s*undefined\b|\s*false\b)./g,
9299
description: 'e instanceof Error ? e.message : fallback',
93100
suggestion: 'getErrorMessage(e, fallback?) from @sim/utils/errors',
101+
replacesHelper: true,
94102
},
95103
// Inline sleep
96104
{
97105
pattern: /new Promise\s*[(<]\s*(?:resolve|\(resolve\))\s*=>\s*setTimeout\s*\(\s*resolve/g,
98106
description: 'new Promise(resolve => setTimeout(resolve, ms))',
99107
suggestion: 'sleep(ms) from @sim/utils/helpers',
108+
replacesHelper: true,
100109
},
101110
{
102111
pattern:
103112
/\b([\w.]+)\s+instanceof\s+Error\s*\?\s*\1\s*:\s*new\s+Error\(\s*String\(\s*\1\s*\)\s*\)/g,
104113
description: 'e instanceof Error ? e : new Error(String(e))',
105114
suggestion: 'toError(e) from @sim/utils/errors',
106115
prefilter: /new\s+Error\(\s*String\(/,
116+
replacesHelper: true,
107117
},
108118
{
109119
pattern:
110120
/typeof\s+([\w.]+)\s*===\s*'object'\s*&&\s*\1\s*!==\s*null\s*&&\s*!Array\.isArray\(\s*\1\s*\)/g,
111121
description: "typeof v === 'object' && v !== null && !Array.isArray(v)",
112122
suggestion: 'isRecordLike(v) from @sim/utils/object',
113123
prefilter: /!Array\.isArray\(/,
124+
replacesHelper: true,
114125
},
115126
{
116127
pattern:
117128
/Object\.fromEntries\(\s*Object\.entries\([^()]*\)\s*\.filter\(\s*\(\[\s*\w*\s*,\s*(\w+)\s*\]\)\s*=>\s*\1\s*!==\s*undefined\s*\)\s*,?\s*\)/g,
118129
description: 'Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))',
119130
suggestion: 'filterUndefined(obj) from @sim/utils/object',
120131
prefilter: FROM_ENTRIES,
132+
replacesHelper: true,
121133
},
122134
{
123135
pattern:
124136
/Object\.fromEntries\(\s*Object\.entries\([^()]*\)\s*\.filter\(\s*\(\[\s*(\w+)\s*\]\)\s*=>\s*\1\s*!==\s*[\w.'"]+\s*\)\s*,?\s*\)/g,
125137
description: 'Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key))',
126138
suggestion: 'omit(obj, [key]) from @sim/utils/object',
127139
prefilter: FROM_ENTRIES,
140+
replacesHelper: true,
128141
},
129142
{
130143
pattern: new RegExp(
@@ -134,6 +147,7 @@ const BANNED_PATTERNS: Array<{
134147
description: 's.length > n ? s.slice(0, n) + suffix : s',
135148
prefilter: TRUNCATE_PREFILTER,
136149
suggestion: "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')",
150+
replacesHelper: true,
137151
},
138152
{
139153
pattern: new RegExp(
@@ -143,11 +157,13 @@ const BANNED_PATTERNS: Array<{
143157
description: 's.length <= n ? s : s.slice(0, n) + suffix',
144158
prefilter: TRUNCATE_PREFILTER,
145159
suggestion: "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')",
160+
replacesHelper: true,
146161
},
147162
{
148163
pattern: /\/\[\.\*\+\?\^\$\{\}\(\)\|\[\\\]\\\\\]\/g/g,
149164
description: 'hand-rolled regex-metacharacter escape',
150165
suggestion: 'escapeRegExp(value) from @sim/utils/string',
166+
replacesHelper: true,
151167
},
152168
// Render-path rules (.claude/rules/sim-react-performance.md, sim-styling.md)
153169
{
@@ -278,6 +294,51 @@ function es2023LibViolations(): Violation[] {
278294
return violations
279295
}
280296

297+
/** Every banned-pattern hit in one file; `file` is repo-relative, which decides its exemptions. */
298+
export function findViolations(file: string, content: string): Violation[] {
299+
const violations: Violation[] = []
300+
const helperSource = file.startsWith(UTILS_SOURCE) || ALLOWLISTED_FILES.has(file)
301+
302+
const matches: Array<{
303+
index: number
304+
description: string
305+
suggestion: string
306+
}> = []
307+
308+
const prefilterHits = new Map<RegExp, boolean>()
309+
for (const { pattern, description, suggestion, prefilter, replacesHelper } of BANNED_PATTERNS) {
310+
if (helperSource && replacesHelper) continue
311+
if (prefilter) {
312+
let hit = prefilterHits.get(prefilter)
313+
if (hit === undefined) {
314+
hit = prefilter.test(content)
315+
prefilterHits.set(prefilter, hit)
316+
}
317+
if (!hit) continue
318+
}
319+
pattern.lastIndex = 0
320+
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
321+
matches.push({ index: match.index, description, suggestion })
322+
}
323+
}
324+
if (matches.length === 0) return []
325+
326+
const lines = content.split('\n')
327+
const lineStarts = buildLineStarts(content)
328+
for (const match of matches) {
329+
const line = lineAt(lineStarts, match.index)
330+
if (hasAllow(lines, line)) continue
331+
violations.push({
332+
file,
333+
line,
334+
description: match.description,
335+
suggestion: match.suggestion,
336+
snippet: (lines[line - 1] ?? '').trim(),
337+
})
338+
}
339+
return violations
340+
}
341+
281342
async function main() {
282343
const allFiles: string[] = []
283344
for (const dir of SCAN_DIRS) {
@@ -288,45 +349,7 @@ async function main() {
288349

289350
for (const file of allFiles) {
290351
const rel = path.relative(ROOT, file)
291-
if (rel.startsWith(UTILS_SOURCE) || ALLOWLISTED_FILES.has(rel)) continue
292-
293-
const content = await readFile(file, 'utf8')
294-
const matches: Array<{
295-
index: number
296-
description: string
297-
suggestion: string
298-
}> = []
299-
300-
const prefilterHits = new Map<RegExp, boolean>()
301-
for (const { pattern, description, suggestion, prefilter } of BANNED_PATTERNS) {
302-
if (prefilter) {
303-
let hit = prefilterHits.get(prefilter)
304-
if (hit === undefined) {
305-
hit = prefilter.test(content)
306-
prefilterHits.set(prefilter, hit)
307-
}
308-
if (!hit) continue
309-
}
310-
pattern.lastIndex = 0
311-
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
312-
matches.push({ index: match.index, description, suggestion })
313-
}
314-
}
315-
if (matches.length === 0) continue
316-
317-
const lines = content.split('\n')
318-
const lineStarts = buildLineStarts(content)
319-
for (const match of matches) {
320-
const line = lineAt(lineStarts, match.index)
321-
if (hasAllow(lines, line)) continue
322-
violations.push({
323-
file: rel,
324-
line,
325-
description: match.description,
326-
suggestion: match.suggestion,
327-
snippet: (lines[line - 1] ?? '').trim(),
328-
})
329-
}
352+
violations.push(...findViolations(rel, await readFile(file, 'utf8')))
330353
}
331354

332355
violations.push(...es2023LibViolations())
@@ -345,4 +368,4 @@ async function main() {
345368
process.exit(1)
346369
}
347370

348-
main()
371+
if (import.meta.main) main()

0 commit comments

Comments
 (0)