2525import { readFileSync } from 'node:fs'
2626import { readdir, readFile } from 'node:fs/promises'
2727import 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
3132const 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+
281342async 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