1818 */
1919import { readdir , readFile } from 'node:fs/promises'
2020import path from 'node:path'
21+ import { leadingDirective } from './source-kind'
2122
2223const ROOT = path . resolve ( import . meta. dir , '..' )
2324
2425const SCAN_DIRS = [ path . join ( ROOT , 'apps' ) , path . join ( ROOT , 'packages' ) ]
2526
2627const SKIP_DIRS = new Set ( [ 'node_modules' , 'dist' , '.next' , '.turbo' , 'coverage' , 'bundles' ] )
2728
28- /** Files that implement the utilities themselves — allowed to use the underlying primitives. */
29+ /** `@sim/utils` implements the helpers, so it may use the primitives they replace. */
30+ const UTILS_SOURCE = 'packages/utils/src/'
31+
32+ /** Other files allowed to use the underlying primitives. */
2933const ALLOWLISTED_FILES = new Set ( [
30- 'packages/utils/src/errors.ts' ,
31- 'packages/utils/src/helpers.ts' ,
32- 'packages/utils/src/random.ts' ,
33- 'packages/utils/src/id.ts' ,
34- 'packages/utils/src/object.ts' ,
35- 'packages/utils/src/retry.ts' ,
36- 'packages/utils/src/string.ts' ,
37- 'packages/utils/src/errors.test.ts' ,
38- 'packages/utils/src/helpers.test.ts' ,
39- 'packages/utils/src/random.test.ts' ,
40- 'packages/utils/src/id.test.ts' ,
41- 'packages/utils/src/object.test.ts' ,
42- 'packages/utils/src/retry.test.ts' ,
43- 'packages/utils/src/string.test.ts' ,
4434 // Published standalone CLIs: `@sim/utils` is private, so they carry local
4535 // copies rather than a dependency that only resolves inside the monorepo.
4636 'packages/cli/src/index.ts' ,
@@ -53,9 +43,6 @@ const ALLOWLISTED_FILES = new Set([
5343 'packages/testing/src/factories/id.ts' ,
5444] )
5545
56- /** Leading `'use client'` directive, after any comment header. */
57- const USE_CLIENT_PROLOGUE = / ^ (?: \s * (?: \/ \/ [ ^ \n ] * | \/ \* [ \s \S ] * ?\* \/ ) ) * \s * [ ' " ] u s e c l i e n t [ ' " ] /
58-
5946/** App Router files Next only ever evaluates on the server, plus `*.server.ts` modules. */
6047const SERVER_ONLY_FILE =
6148 / \/ (?: r o u t e | s i t e m a p | r o b o t s | m a n i f e s t ) \. [ j t ] s x ? $ | \/ (?: o p e n g r a p h - i m a g e | t w i t t e r - i m a g e | i c o n | a p p l e - i c o n ) \. [ ^ / ] + $ | \. s e r v e r \. [ j t ] s x ? $ /
@@ -71,17 +58,25 @@ function isClientRenderPath(rel: string, content: string): boolean {
7158 / ^ a p p s \/ s i m \/ ( a p p | c o m p o n e n t s | h o o k s | s t o r e s ) \/ / . test ( rel ) ||
7259 / ^ a p p s \/ d o c s \/ ( a p p | c o m p o n e n t s ) \/ / . test ( rel ) ||
7360 / ^ p a c k a g e s \/ ( e m c n | w o r k f l o w - r e n d e r e r ) \/ s r c \/ / . test ( rel ) ||
74- USE_CLIENT_PROLOGUE . test ( content )
61+ leadingDirective ( content ) === 'use client'
7562 )
7663}
7764
65+ /** `s.slice(0, n)` plus a suffix, by `+` or in a template literal; `\1` is `s` and `\2` is `n`. */
66+ const TRUNCATED = String . raw `(?:\`\$\{\1\.(?:slice|substring)\(\s*0\s*,\s*\2\s*\)\}[^\`$]*\`|\1\.(?:slice|substring)\(\s*0\s*,\s*\2\s*\)\s*\+\s*(?:'[^']*'|"[^"]*"|\w+))`
67+ /** Literal gate for both truncate patterns, whose backreferences are slow over every file. */
68+ const TRUNCATE_PREFILTER = / \. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , [ ^ ) ] * \) \s * (?: \} | \+ ) /
69+
70+ /** Literal gate shared by the `filterUndefined` and `omit` patterns. */
71+ const FROM_ENTRIES = / O b j e c t \. f r o m E n t r i e s \( /
72+
7873const BANNED_PATTERNS : Array < {
7974 pattern : RegExp
8075 description : string
8176 suggestion : string
8277 /** Restricts the pattern to matching files; unrestricted patterns apply everywhere. */
8378 appliesTo ?: ( rel : string , content : string ) => boolean
84- /** Cheap literal test that gates a backreference-heavy pattern, which is slow over every file. */
79+ /** Cheap literal test that skips the pattern on files that cannot match; memoized per file. */
8580 prefilter ?: RegExp
8681} > = [
8782 // Randomness / ID generation — global property access that import bans miss
@@ -130,31 +125,38 @@ const BANNED_PATTERNS: Array<{
130125 / 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,
131126 description : "typeof v === 'object' && v !== null && !Array.isArray(v)" ,
132127 suggestion : 'isRecordLike(v) from @sim/utils/object' ,
128+ prefilter : / ! A r r a y \. i s A r r a y \( / ,
133129 } ,
134130 {
135131 pattern :
136132 / 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,
137133 description : 'Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))' ,
138134 suggestion : 'filterUndefined(obj) from @sim/utils/object' ,
135+ prefilter : FROM_ENTRIES ,
139136 } ,
140137 {
141138 pattern :
142139 / 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,
143140 description : 'Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key))' ,
144141 suggestion : 'omit(obj, [key]) from @sim/utils/object' ,
142+ prefilter : FROM_ENTRIES ,
145143 } ,
146144 {
147- pattern :
148- / \b ( [ \w . ] + ) \. l e n g t h \s * > \s * ( [ \w . ] + ) \s * \? \s * (?: ` \$ \{ \1\. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , \s * \2\s * \) \} [ ^ ` $ ] * ` | \1\. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , \s * \2\s * \) \s * \+ \s * (?: ' [ ^ ' ] * ' | " [ ^ " ] * " | \w + ) ) \s * : \s * \1\b (? ! \. ) / g,
145+ pattern : new RegExp (
146+ String . raw `\b([\w.]+)\.length\s*>\s*([\w.]+)\s*\?\s*${ TRUNCATED } \s*:\s*\1\b(?!\.)` ,
147+ 'g'
148+ ) ,
149149 description : 's.length > n ? s.slice(0, n) + suffix : s' ,
150- prefilter : / \. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , [ ^ ) ] * \) \s * (?: \} | \+ ) / ,
150+ prefilter : TRUNCATE_PREFILTER ,
151151 suggestion : "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')" ,
152152 } ,
153153 {
154- pattern :
155- / \b ( [ \w . ] + ) \. l e n g t h \s * < = \s * ( [ \w . ] + ) \s * \? \s * \1\s * : \s * (?: ` \$ \{ \1\. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , \s * \2\s * \) \} [ ^ ` $ ] * ` | \1\. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , \s * \2\s * \) \s * \+ \s * (?: ' [ ^ ' ] * ' | " [ ^ " ] * " | \w + ) ) / g,
154+ pattern : new RegExp (
155+ String . raw `\b([\w.]+)\.length\s*<=\s*([\w.]+)\s*\?\s*\1\s*:\s*${ TRUNCATED } ` ,
156+ 'g'
157+ ) ,
156158 description : 's.length <= n ? s : s.slice(0, n) + suffix' ,
157- prefilter : / \. (?: s l i c e | s u b s t r i n g ) \( \s * 0 \s * , [ ^ ) ] * \) \s * (?: \} | \+ ) / ,
159+ prefilter : TRUNCATE_PREFILTER ,
158160 suggestion : "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')" ,
159161 } ,
160162 {
@@ -173,6 +175,7 @@ const BANNED_PATTERNS: Array<{
173175 pattern : / \b u s e R e f (?: < (?: [ ^ < > ] | < [ ^ < > ] * > ) * > ) ? \( \s * n e w \s + [ A - Z ] \w * / g,
174176 description : 'useRef(new X()) allocates a throwaway X on every render' ,
175177 suggestion : 'useRef<X | null>(null), then `ref.current ??= new X()` before first use' ,
178+ prefilter : / u s e R e f / ,
176179 } ,
177180 {
178181 pattern :
@@ -275,7 +278,7 @@ async function main() {
275278
276279 for ( const file of allFiles ) {
277280 const rel = path . relative ( ROOT , file )
278- if ( ALLOWLISTED_FILES . has ( rel ) ) continue
281+ if ( rel . startsWith ( UTILS_SOURCE ) || ALLOWLISTED_FILES . has ( rel ) ) continue
279282
280283 const content = await readFile ( file , 'utf8' )
281284 const matches : Array < {
@@ -284,9 +287,17 @@ async function main() {
284287 suggestion : string
285288 } > = [ ]
286289
290+ const prefilterHits = new Map < RegExp , boolean > ( )
287291 for ( const { pattern, description, suggestion, appliesTo, prefilter } of BANNED_PATTERNS ) {
288292 if ( appliesTo && ! appliesTo ( rel , content ) ) continue
289- if ( prefilter && ! prefilter . test ( content ) ) continue
293+ if ( prefilter ) {
294+ let hit = prefilterHits . get ( prefilter )
295+ if ( hit === undefined ) {
296+ hit = prefilter . test ( content )
297+ prefilterHits . set ( prefilter , hit )
298+ }
299+ if ( ! hit ) continue
300+ }
290301 pattern . lastIndex = 0
291302 for ( let match = pattern . exec ( content ) ; match !== null ; match = pattern . exec ( content ) ) {
292303 matches . push ( { index : match . index , description, suggestion } )
0 commit comments