11#!/usr/bin/env bun
22/**
3- * Enforces use of shared @sim/utils helpers over inline implementations.
3+ * Bans inline idioms that a shared helper or a written rule already replaces.
4+ *
5+ * Most patterns point at an `@sim/utils` helper (CLAUDE.md "Common utilities"). A few encode
6+ * render-path rules from `.claude/rules/sim-react-performance.md` and `sim-styling.md` that no
7+ * linter covers: ES2023 array methods that Safari 15 lacks, `useRef(new X())` allocating on
8+ * every render, and `h-N w-N` where `size-N` is the convention.
49 *
510 * Biome's noRestrictedImports covers the import-based bans it lists — today `nanoid` and
611 * `uuid`. It does NOT cover named crypto imports; `import { randomBytes } from 'node:crypto'`
712 * passes both gates, and deliberately so, since server code building cipher IVs and tokens
813 * wants node's crypto rather than the cross-context wrapper in `@sim/utils/random`.
914 *
10- * This script catches what static import analysis misses — global property access, inline
11- * idioms, and reimplemented helpers that should live in @sim/utils.
12- *
1315 * Patterns are matched against the whole file, not line by line: every idiom banned here is a
1416 * multi-token expression that the formatter wraps at 100 columns, and a line-scoped scan sees
1517 * none of the wrapped forms. Deliberate exceptions carry `// utils-lint-allow: <reason>`.
@@ -31,26 +33,49 @@ const ALLOWLISTED_FILES = new Set([
3133 'packages/utils/src/id.ts' ,
3234 'packages/utils/src/object.ts' ,
3335 'packages/utils/src/retry.ts' ,
36+ 'packages/utils/src/string.ts' ,
3437 'packages/utils/src/errors.test.ts' ,
3538 'packages/utils/src/helpers.test.ts' ,
3639 'packages/utils/src/random.test.ts' ,
3740 'packages/utils/src/id.test.ts' ,
3841 'packages/utils/src/object.test.ts' ,
3942 'packages/utils/src/retry.test.ts' ,
43+ 'packages/utils/src/string.test.ts' ,
4044 // Published standalone CLIs: `@sim/utils` is private, so they carry local
4145 // copies rather than a dependency that only resolves inside the monorepo.
4246 'packages/cli/src/index.ts' ,
4347 'packages/ts-sdk/src/index.ts' ,
4448 // CJS bundle — cannot use ES module imports
4549 'apps/sim/lib/execution/isolated-vm-worker.cjs' ,
50+ // Emits the sandbox-side event filter as plain JS source, which cannot import @sim/utils
51+ 'apps/sim/executor/handlers/pi/cloud/event-filter-source.ts' ,
4652 // Uses crypto.getRandomValues() directly (not crypto.randomUUID) — TSDoc comment triggers false positive
4753 'packages/testing/src/factories/id.ts' ,
4854] )
4955
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+
59+ /**
60+ * Files that ship to the browser: client directories plus any `'use client'` module. Server-only
61+ * code (route handlers, tests) may use Node 20+ runtime methods freely.
62+ */
63+ function isClientRenderPath ( rel : string , content : string ) : boolean {
64+ if ( / \. ( t e s t | s p e c ) \. [ c m ] ? [ j t ] s x ? $ / . test ( rel ) || / \/ a p p \/ a p i \/ / . test ( rel ) ) return false
65+ return (
66+ / ^ 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 ) ||
67+ / ^ a p p s \/ d o c s \/ ( a p p | c o m p o n e n t s ) \/ / . test ( rel ) ||
68+ / ^ 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 ) ||
69+ USE_CLIENT_PROLOGUE . test ( content )
70+ )
71+ }
72+
5073const BANNED_PATTERNS : Array < {
5174 pattern : RegExp
5275 description : string
5376 suggestion : string
77+ /** Restricts the pattern to matching files; unrestricted patterns apply everywhere. */
78+ appliesTo ?: ( rel : string , content : string ) => boolean
5479} > = [
5580 // Randomness / ID generation — global property access that import bans miss
5681 {
@@ -86,6 +111,65 @@ const BANNED_PATTERNS: Array<{
86111 description : 'new Promise(resolve => setTimeout(resolve, ms))' ,
87112 suggestion : 'sleep(ms) from @sim/utils/helpers' ,
88113 } ,
114+ {
115+ pattern :
116+ / \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,
117+ description : 'e instanceof Error ? e : new Error(String(e))' ,
118+ suggestion : 'toError(e) from @sim/utils/errors' ,
119+ } ,
120+ {
121+ pattern :
122+ / 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,
123+ description : "typeof v === 'object' && v !== null && !Array.isArray(v)" ,
124+ suggestion : 'isRecordLike(v) from @sim/utils/object' ,
125+ } ,
126+ {
127+ pattern :
128+ / 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,
129+ description : 'Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))' ,
130+ suggestion : 'filterUndefined(obj) from @sim/utils/object' ,
131+ } ,
132+ {
133+ pattern :
134+ / 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,
135+ description : 'Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key))' ,
136+ suggestion : 'omit(obj, [key]) from @sim/utils/object' ,
137+ } ,
138+ {
139+ pattern :
140+ / \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,
141+ description : 's.length > n ? s.slice(0, n) + suffix : s' ,
142+ suggestion : "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')" ,
143+ } ,
144+ {
145+ pattern :
146+ / \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,
147+ description : 's.length <= n ? s : s.slice(0, n) + suffix' ,
148+ suggestion : "truncate(s, n, suffix?) from @sim/utils/string (suffix defaults to '...')" ,
149+ } ,
150+ {
151+ pattern : / \/ \[ \. \* \+ \? \^ \$ \{ \} \( \) \| \[ \\ \] \\ \\ \] \/ g / g,
152+ description : 'hand-rolled regex-metacharacter escape' ,
153+ suggestion : 'escapeRegExp(value) from @sim/utils/string' ,
154+ } ,
155+ // Render-path rules (.claude/rules/sim-react-performance.md, sim-styling.md)
156+ {
157+ pattern : / \. (?: t o S o r t e d | t o R e v e r s e d | t o S p l i c e d ) \s * \( | \. w i t h \( \s * - ? \d + \s * , / g,
158+ description : 'ES2023 array method in browser code (throws on Safari/iOS 15)' ,
159+ suggestion : 'a copy you then mutate: [...arr].sort(), [...arr].reverse(), [...arr].splice()' ,
160+ appliesTo : isClientRenderPath ,
161+ } ,
162+ {
163+ pattern : / \b u s e R e f (?: < [ ^ > ] * > ) ? \( \s * n e w \s + [ A - Z ] \w * / g,
164+ description : 'useRef(new X()) allocates a throwaway X on every render' ,
165+ suggestion : 'useRef<X | null>(null), then `ref.current ??= new X()` before first use' ,
166+ } ,
167+ {
168+ pattern :
169+ / (?< = [ \s ' " ` ] | ^ ) ( (?: [ \w - ] + : ) * ) (?: h - ( \[ [ ^ \] \s ] + \] | [ \d . ] + | p x | f u l l | s c r e e n | a u t o | f i t | m i n | m a x ) \s + \1w - \2| w - ( \[ [ ^ \] \s ] + \] | [ \d . ] + | p x | f u l l | s c r e e n | a u t o | f i t | m i n | m a x ) \s + \1h - \3) (? = [ \s ' " ` ] | $ ) / gm,
170+ description : 'h-N w-N with equal N' ,
171+ suggestion : 'size-N (Tailwind) — e.g. `size-4`, `size-full`' ,
172+ } ,
89173]
90174
91175async function walk ( dir : string , results : string [ ] = [ ] ) : Promise < string [ ] > {
@@ -176,7 +260,8 @@ async function main() {
176260 suggestion : string
177261 } > = [ ]
178262
179- for ( const { pattern, description, suggestion } of BANNED_PATTERNS ) {
263+ for ( const { pattern, description, suggestion, appliesTo } of BANNED_PATTERNS ) {
264+ if ( appliesTo && ! appliesTo ( rel , content ) ) continue
180265 pattern . lastIndex = 0
181266 for ( let match = pattern . exec ( content ) ; match !== null ; match = pattern . exec ( content ) ) {
182267 matches . push ( { index : match . index , description, suggestion } )
0 commit comments