Skip to content

Commit 3ae2fa1

Browse files
committed
improvement(audits): extend check:utils to the remaining written idioms
Adds toError, isRecordLike, filterUndefined, omit, truncate, and escapeRegExp idioms from CLAUDE.md, plus render-path rules: ES2023 array methods in browser code, useRef(new X()), and h-N w-N pairs.
1 parent 9dc6b41 commit 3ae2fa1

1 file changed

Lines changed: 90 additions & 5 deletions

File tree

‎scripts/check-utils-enforcement.ts‎

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
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*['"]use client['"]/
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 (/\.(test|spec)\.[cm]?[jt]sx?$/.test(rel) || /\/app\/api\//.test(rel)) return false
65+
return (
66+
/^apps\/sim\/(app|components|hooks|stores)\//.test(rel) ||
67+
/^apps\/docs\/(app|components)\//.test(rel) ||
68+
/^packages\/(emcn|workflow-renderer)\/src\//.test(rel) ||
69+
USE_CLIENT_PROLOGUE.test(content)
70+
)
71+
}
72+
5073
const 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+instanceof\s+Error\s*\?\s*\1\s*:\s*new\s+Error\(\s*String\(\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+
/typeof\s+([\w.]+)\s*===\s*'object'\s*&&\s*\1\s*!==\s*null\s*&&\s*!Array\.isArray\(\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+
/Object\.fromEntries\(\s*Object\.entries\([^()]*\)\s*\.filter\(\s*\(\[\s*\w*\s*,\s*(\w+)\s*\]\)\s*=>\s*\1\s*!==?\s*undefined\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+
/Object\.fromEntries\(\s*Object\.entries\([^()]*\)\s*\.filter\(\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.]+)\.length\s*>\s*([\w.]+)\s*\?\s*(?:`\$\{\1\.(?:slice|substring)\(\s*0\s*,\s*\2\s*\)\}[^`$]*`|\1\.(?:slice|substring)\(\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.]+)\.length\s*<=\s*([\w.]+)\s*\?\s*\1\s*:\s*(?:`\$\{\1\.(?:slice|substring)\(\s*0\s*,\s*\2\s*\)\}[^`$]*`|\1\.(?:slice|substring)\(\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: /\.(?:toSorted|toReversed|toSpliced)\s*\(|\.with\(\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: /\buseRef(?:<[^>]*>)?\(\s*new\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.]+|px|full|screen|auto|fit|min|max)\s+\1w-\2|w-(\[[^\]\s]+\]|[\d.]+|px|full|screen|auto|fit|min|max)\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

91175
async 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

Comments
 (0)