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 : / \b M a t h \. r a n d o m \s * \( / g,
7073 description : 'Math.random()' ,
7174 suggestion : 'randomInt / randomFloat / randomItem from @sim/utils/random' ,
75+ replacesHelper : true ,
7276 } ,
7377 {
7478 pattern : / \b c r y p t o \. r a n d o m U U I D \s * \( / g,
7579 description : 'crypto.randomUUID()' ,
7680 suggestion : 'generateId() or generateShortId() from @sim/utils/id' ,
81+ replacesHelper : true ,
7782 } ,
7883 {
7984 pattern : / \b c r y p t o \. r a n d o m B y t e s \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 : / J S O N \. p a r s e \s * \( \s * J S O N \. s t r i n g i f y \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 : / i n s t a n c e o f E r r o r \s * \? \s * \w + \. m e s s a g e \s * : \s * (? ! \s * n u l l \b | \s * u n d e f i n e d \b | \s * f a l s e \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 : / n e w P r o m i s e \s * [ ( < ] \s * (?: r e s o l v e | \( r e s o l v e \) ) \s * = > \s * s e t T i m e o u t \s * \( \s * r e s o l v e / 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 + i n s t a n c e o f \s + E r r o r \s * \? \s * \1\s * : \s * n e w \s + E r r o r \( \s * S t r i n g \( \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 : / n e w \s + E r r o r \( \s * S t r i n g \( / ,
116+ replacesHelper : true ,
107117 } ,
108118 {
109119 pattern :
110120 / t y p e o f \s + ( [ \w . ] + ) \s * = = = \s * ' o b j e c t ' \s * & & \s * \1\s * ! = = \s * n u l l \s * & & \s * ! A r r a y \. i s A r r a y \( \s * \1\s * \) / g,
111121 description : "typeof v === 'object' && v !== null && !Array.isArray(v)" ,
112122 suggestion : 'isRecordLike(v) from @sim/utils/object' ,
113123 prefilter : / ! A r r a y \. i s A r r a y \( / ,
124+ replacesHelper : true ,
114125 } ,
115126 {
116127 pattern :
117128 / O b j e c t \. f r o m E n t r i e s \( \s * O b j e c t \. e n t r i e s \( [ ^ ( ) ] * \) \s * \. f i l t e r \( \s * \( \[ \s * \w * \s * , \s * ( \w + ) \s * \] \) \s * = > \s * \1\s * ! = = \s * u n d e f i n e d \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 / O b j e c t \. f r o m E n t r i e s \( \s * O b j e c t \. e n t r i e s \( [ ^ ( ) ] * \) \s * \. f i l t e r \( \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