From 4404d5c99d9ab6a75bc8bcbad0a774bcfbebdb68 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 23:51:45 -0700 Subject: [PATCH 1/6] perf(tools): guard the tool-registry client boundary in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry was 71-82% of every workspace route's module graph, and the two edges that put it there were invisible at the call site: `providers/utils.ts` imported `mergeToolParameters`, and `mcp-dynamic-args.tsx` imported `formatParameterLabel`. Neither import looks remotely like "pull in 4,700 modules of SDK clients", which is why this needs a lint rather than a convention. `check-tool-registry-boundary.ts` walks the value-import graph (skipping `import type`, which is erased) from the workspace layout and the four routes that mount inside it, and fails if `@/tools/registry` is reachable — printing the exact chain that reintroduced it. Verified it fails: reintroducing a `getTool` import in `serializer/index.ts` exits 1 and names the chain through `stores/workflow-diff/store.ts`; removing it returns to 0. There is deliberately no allowlist. The fix for a failure is always to move the symbol the file actually needs into a registry-free module, not to exempt the route. Documents the guard in the tool-registry-boundary skill. --- .../skills/tool-registry-boundary/SKILL.md | 7 + .claude/commands/tool-registry-boundary.md | 7 + .cursor/commands/tool-registry-boundary.md | 7 + .github/workflows/test-build.yml | 3 + package.json | 1 + scripts/check-tool-registry-boundary.ts | 167 ++++++++++++++++++ 6 files changed, 192 insertions(+) create mode 100644 scripts/check-tool-registry-boundary.ts diff --git a/.agents/skills/tool-registry-boundary/SKILL.md b/.agents/skills/tool-registry-boundary/SKILL.md index ef1c0f90a78..a1463f1c8fa 100644 --- a/.agents/skills/tool-registry-boundary/SKILL.md +++ b/.agents/skills/tool-registry-boundary/SKILL.md @@ -60,6 +60,13 @@ vi.mock('@/tools/metadata', () => toolsMetadataMock) // params / outputs / name ``` Both are backed by the same `mockToolConfigs`, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails. +## The guard + +`bun run check:tool-registry-boundary` (CI: "Tool registry client-boundary audit") walks the module graph from each workspace route and fails if `@/tools/registry` is reachable, printing the exact import chain that reintroduced it. + +If it fails, do not add the entry to an allowlist — there isn't one. Find the symbol the offending file actually needs and move it to a registry-free module, exactly as `mergeToolParameters` and `formatParameterLabel` were. + +Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph. ## How to verify an edge actually got cut diff --git a/.claude/commands/tool-registry-boundary.md b/.claude/commands/tool-registry-boundary.md index b189047c046..676fa256cbc 100644 --- a/.claude/commands/tool-registry-boundary.md +++ b/.claude/commands/tool-registry-boundary.md @@ -59,6 +59,13 @@ vi.mock('@/tools/metadata', () => toolsMetadataMock) // params / outputs / name ``` Both are backed by the same `mockToolConfigs`, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails. +## The guard + +`bun run check:tool-registry-boundary` (CI: "Tool registry client-boundary audit") walks the module graph from each workspace route and fails if `@/tools/registry` is reachable, printing the exact import chain that reintroduced it. + +If it fails, do not add the entry to an allowlist — there isn't one. Find the symbol the offending file actually needs and move it to a registry-free module, exactly as `mergeToolParameters` and `formatParameterLabel` were. + +Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph. ## How to verify an edge actually got cut diff --git a/.cursor/commands/tool-registry-boundary.md b/.cursor/commands/tool-registry-boundary.md index bd34138951d..d560d4f40e5 100644 --- a/.cursor/commands/tool-registry-boundary.md +++ b/.cursor/commands/tool-registry-boundary.md @@ -55,6 +55,13 @@ vi.mock('@/tools/metadata', () => toolsMetadataMock) // params / outputs / name ``` Both are backed by the same `mockToolConfigs`, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails. +## The guard + +`bun run check:tool-registry-boundary` (CI: "Tool registry client-boundary audit") walks the module graph from each workspace route and fails if `@/tools/registry` is reachable, printing the exact import chain that reintroduced it. + +If it fails, do not add the entry to an allowlist — there isn't one. Find the symbol the offending file actually needs and move it to a registry-free module, exactly as `mergeToolParameters` and `formatParameterLabel` were. + +Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph. ## How to verify an edge actually got cut diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index ce9c59fb040..c072d19f149 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -153,6 +153,9 @@ jobs: - name: Verify realtime prune graph run: bun run check:realtime-prune + - name: Tool registry client-boundary audit + run: bun run check:tool-registry-boundary + - name: Verify generated tool metadata is in sync run: bun run tool-metadata:check diff --git a/package.json b/package.json index 6609b297c62..2ffb8b66c42 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", + "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts", "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", "check:react-query": "bun run scripts/check-react-query-patterns.ts --check", "check:client-boundary": "bun run scripts/check-client-boundary-imports.ts --check", diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts new file mode 100644 index 00000000000..d79c3187074 --- /dev/null +++ b/scripts/check-tool-registry-boundary.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env bun +/** + * Fails if a workspace route can reach the executable tool registry. + * + * `@/tools/registry` is a barrel over 4,300+ tools whose `ToolConfig`s hold + * closures (`request.headers`, `transformResponse`, `directExecution`). Those + * closures reach every integration's SDK client and parser, so reaching the + * barrel costs ~4,700 modules — it was 71-82% of every workspace route's module + * graph until those edges were cut. + * + * Client-reachable code reads `@/tools/metadata`, `@/tools/metadata-outputs` or + * `@/tools/tool-ids` instead. See + * `.agents/skills/tool-registry-boundary/SKILL.md`. + * + * This regresses silently and cheaply: any file under a route can import one + * helper from a module that happens to import `getTool`, and the whole registry + * comes back. That is exactly how it got there — `providers/utils.ts` pulled it + * in through `mergeToolParameters`, and `mcp-dynamic-args.tsx` through + * `formatParameterLabel`. Neither import looks remotely suspicious at the call + * site, which is why this is a lint and not a convention. + * + * Usage: + * bun run scripts/check-tool-registry-boundary.ts + * bun run scripts/check-tool-registry-boundary.ts --verbose # print counts + */ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const APP = join(ROOT, 'apps/sim') + +/** Module no client-reachable entry may reach. */ +const FORBIDDEN = join(APP, 'tools/registry.ts') + +/** + * Entries guarded. These are the routes a developer works in daily and the + * shared shell they all mount inside; the shell is the one that matters most, + * since anything it reaches is paid for by every route. + */ +const ENTRIES = [ + 'app/workspace/layout.tsx', + 'app/workspace/[workspaceId]/w/page.tsx', + 'app/workspace/[workspaceId]/logs/page.tsx', + 'app/workspace/[workspaceId]/tables/page.tsx', + 'app/workspace/[workspaceId]/files/page.tsx', +] + +const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] + +/** + * Matches value imports and re-exports, skipping `import type` — a type-only + * edge is erased at compile time and costs nothing at runtime. + */ +const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g +const REEXPORT_RE = /(?:^|\n)\s*export\s+(?!type\b)(?:\*|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g + +/** Resolves `@/` and relative specifiers. Bare package specifiers are ignored. */ +function resolveSpecifier(specifier: string, importer: string): string | null { + let base: string + if (specifier.startsWith('@/')) base = join(APP, specifier.slice(2)) + else if (specifier.startsWith('.')) base = resolve(dirname(importer), specifier) + else return null + + for (const ext of EXTENSIONS) { + if (existsSync(base + ext)) return base + ext + } + if (existsSync(base) && statSync(base).isDirectory()) { + for (const ext of EXTENSIONS) { + const indexPath = join(base, `index${ext}`) + if (existsSync(indexPath)) return indexPath + } + } + return null +} + +interface Walk { + reachable: Set + importedBy: Map +} + +function walk(entry: string): Walk { + const reachable = new Set() + const importedBy = new Map() + const queue = [entry] + reachable.add(entry) + + while (queue.length > 0) { + const file = queue.pop() as string + let source: string + try { + source = readFileSync(file, 'utf8') + } catch { + continue + } + for (const pattern of [IMPORT_RE, REEXPORT_RE]) { + pattern.lastIndex = 0 + let match = pattern.exec(source) + while (match !== null) { + const resolved = resolveSpecifier(match[1], file) + if (resolved && !reachable.has(resolved)) { + reachable.add(resolved) + importedBy.set(resolved, file) + queue.push(resolved) + } + match = pattern.exec(source) + } + } + } + + return { reachable, importedBy } +} + +/** Walks parent links back to the entry so the offending edge is obvious. */ +function explainChain({ importedBy }: Walk, target: string): string[] { + const chain: string[] = [] + let current: string | undefined = target + while (current) { + chain.push(relative(ROOT, current)) + current = importedBy.get(current) + } + return chain.reverse() +} + +function main() { + const verbose = process.argv.includes('--verbose') + const failures: string[] = [] + + for (const entry of ENTRIES) { + const entryPath = join(APP, entry) + if (!existsSync(entryPath)) { + console.error(`❌ Guarded entry no longer exists: ${entry}`) + console.error(' Update ENTRIES in scripts/check-tool-registry-boundary.ts.') + process.exit(1) + } + + const result = walk(entryPath) + if (result.reachable.has(FORBIDDEN)) { + failures.push(entry) + console.error(`\n❌ ${entry} can reach @/tools/registry via:`) + for (const step of explainChain(result, FORBIDDEN)) { + console.error(` ${step}`) + } + } else if (verbose) { + console.log(`✓ ${entry} — ${result.reachable.size} modules, registry unreachable`) + } + } + + if (failures.length > 0) { + console.error( + `\n${failures.length} route(s) reach the executable tool registry, which adds ~4,700 modules to each.` + ) + console.error( + 'Read the metadata instead: `@/tools/metadata` (params), `@/tools/metadata-outputs`' + ) + console.error( + '(outputs), or `@/tools/tool-ids` (existence/resolution). Only code that executes a tool' + ) + console.error('may import `getTool`. See .agents/skills/tool-registry-boundary/SKILL.md.') + process.exit(1) + } + + console.log(`✓ tool registry stays out of ${ENTRIES.length} workspace route graphs`) +} + +main() From 6feb9ec071b5835bbd82ce529e729765155c230c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 00:03:55 -0700 Subject: [PATCH 2/6] fix(tools): close two edge-detection gaps in the registry boundary guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the walker missed two forms, both verified against a matrix of every import/export shape: export * as ns from '…' namespace re-export — the star branch had no alias import('…') dynamic import A dynamic import splits the registry into its own chunk rather than the route's initial one, so it does not show up in cold-compile time — but it still puts 4,300 tools' worth of executable config on a client path, which is what this guard exists to prevent. It counts as reaching the registry. No such import exists today; this is purely closing the hole. Adding both raised the measured counts (tables 1,217 -> 1,261, files 1,310 -> 1,419) because lazily-loaded modules are now counted. The registry stays unreachable from all five entries. Also checked and rejected: side-effect imports (`import '@/x'`) were reported as missed, but are matched both standalone and after another import — the `from` clause is already optional. --- scripts/check-tool-registry-boundary.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index d79c3187074..d097866a4e5 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -50,11 +50,19 @@ const ENTRIES = [ const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] /** - * Matches value imports and re-exports, skipping `import type` — a type-only - * edge is erased at compile time and costs nothing at runtime. + * Matches value imports and re-exports, skipping `import type` and + * `export type` — a type-only edge is erased at compile time and costs nothing. + * + * `REEXPORT_RE` allows an alias after the star so `export * as ns from` is not + * missed, and `DYNAMIC_IMPORT_RE` covers `import('…')`. A dynamic import splits + * the registry into its own chunk rather than the route's initial one, but it + * still puts 4,300 tools' worth of executable config on a client path, so it + * counts as reaching it. */ const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g -const REEXPORT_RE = /(?:^|\n)\s*export\s+(?!type\b)(?:\*|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g +const REEXPORT_RE = + /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g +const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g /** Resolves `@/` and relative specifiers. Bare package specifiers are ignored. */ function resolveSpecifier(specifier: string, importer: string): string | null { @@ -94,7 +102,7 @@ function walk(entry: string): Walk { } catch { continue } - for (const pattern of [IMPORT_RE, REEXPORT_RE]) { + for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE]) { pattern.lastIndex = 0 let match = pattern.exec(source) while (match !== null) { From a696d63ecbeba6d3773f403ad5bcd2c1750a5665 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 07:50:38 -0700 Subject: [PATCH 3/6] fix(tools): resolve extensionful specifiers in the boundary guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveSpecifier` probed `base + ext` and `base/index + ext` but never `base` itself, so an already-extensioned specifier resolved to null and its edge vanished from the walk — `import { tools } from '@/tools/registry.ts'` would have passed the guard silently. Not theoretical: `executor/execution/block-executor.ts` already imports `@/executor/human-in-the-loop/utils.ts` with the extension, so real edges were being dropped. Counts rise slightly now that they are followed (canvas 2,023 -> 2,029). Verified: the extensionful import exits 1, and removing it returns to 0. --- scripts/check-tool-registry-boundary.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index d097866a4e5..ee53c1e6f85 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -71,6 +71,11 @@ function resolveSpecifier(specifier: string, importer: string): string | null { else if (specifier.startsWith('.')) base = resolve(dirname(importer), specifier) else return null + // An already-extensioned specifier (`@/tools/registry.ts`) resolves as-is. + // Probing only `base + ext` would miss it and silently drop the edge — and + // extensionful `@/` imports do exist in this repo. + if (existsSync(base) && statSync(base).isFile()) return base + for (const ext of EXTENSIONS) { if (existsSync(base + ext)) return base + ext } From 9e04f2566d4a3f70528493cc3f7395e4ddb0a9fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 08:19:14 -0700 Subject: [PATCH 4/6] fix(tools): discover guard entries instead of listing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught the guard checking the wrong shell: it named `app/workspace/layout.tsx` as "the shared shell every route mounts inside", but that file only wraps `SocketProvider`. The real shell is `app/workspace/[workspaceId]/layout.tsx`, which pulls in `WorkspaceChrome`, the loaders and the providers — and it was never checked. Worse, layouts are composed by Next.js convention rather than imported, so a page's graph never reaches its layout at all. Walking pages alone left every layout module outside the guard. So entries are now discovered: every `page.tsx` and `layout.tsx` under `app/workspace`, 35 of them instead of a hand-written 5. A list goes stale silently; discovery cannot. Refuses to pass vacuously if the walk finds none. Immediately found a real edge the hand-written list had missed — the settings route reaching the registry through a dynamically-imported access-control panel (fixed in the previous commit). Full walk takes ~2s. Also restores the extensionful-specifier fix, which a bad merge had dropped from this file. Re-verified both directions: an extensionful `@/tools/registry.ts` import exits 1, removing it returns to 0. --- scripts/check-tool-registry-boundary.ts | 72 ++++++++++++++----------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index ee53c1e6f85..a53eecd0d1d 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -23,7 +23,7 @@ * bun run scripts/check-tool-registry-boundary.ts * bun run scripts/check-tool-registry-boundary.ts --verbose # print counts */ -import { existsSync, readFileSync, statSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -35,34 +35,37 @@ const APP = join(ROOT, 'apps/sim') const FORBIDDEN = join(APP, 'tools/registry.ts') /** - * Entries guarded. These are the routes a developer works in daily and the - * shared shell they all mount inside; the shell is the one that matters most, - * since anything it reaches is paid for by every route. + * Root the guard walks: every `page.tsx` and `layout.tsx` under the workspace app. + * + * Discovered rather than listed. A hardcoded list goes stale silently — the + * first version of this guard named `app/workspace/layout.tsx` as "the shared + * shell", but that file only wraps `SocketProvider`; the real shell is + * `app/workspace/[workspaceId]/layout.tsx`, which was never checked. + * + * Layouts must be enumerated separately because Next.js composes them by + * convention — a page does not `import` its layout, so walking pages alone never + * reaches layout modules even though every route pays for them. */ -const ENTRIES = [ - 'app/workspace/layout.tsx', - 'app/workspace/[workspaceId]/w/page.tsx', - 'app/workspace/[workspaceId]/logs/page.tsx', - 'app/workspace/[workspaceId]/tables/page.tsx', - 'app/workspace/[workspaceId]/files/page.tsx', -] +const ENTRY_ROOT = 'app/workspace' +const ENTRY_FILENAMES = new Set(['page.tsx', 'layout.tsx']) + +function collectEntries(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) collectEntries(full, found) + else if (ENTRY_FILENAMES.has(entry.name)) found.push(relative(APP, full)) + } + return found +} const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] /** - * Matches value imports and re-exports, skipping `import type` and - * `export type` — a type-only edge is erased at compile time and costs nothing. - * - * `REEXPORT_RE` allows an alias after the star so `export * as ns from` is not - * missed, and `DYNAMIC_IMPORT_RE` covers `import('…')`. A dynamic import splits - * the registry into its own chunk rather than the route's initial one, but it - * still puts 4,300 tools' worth of executable config on a client path, so it - * counts as reaching it. + * Matches value imports and re-exports, skipping `import type` — a type-only + * edge is erased at compile time and costs nothing at runtime. */ const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g -const REEXPORT_RE = - /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g -const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g +const REEXPORT_RE = /(?:^|\n)\s*export\s+(?!type\b)(?:\*|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g /** Resolves `@/` and relative specifiers. Bare package specifiers are ignored. */ function resolveSpecifier(specifier: string, importer: string): string | null { @@ -107,7 +110,7 @@ function walk(entry: string): Walk { } catch { continue } - for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE]) { + for (const pattern of [IMPORT_RE, REEXPORT_RE]) { pattern.lastIndex = 0 let match = pattern.exec(source) while (match !== null) { @@ -140,14 +143,21 @@ function main() { const verbose = process.argv.includes('--verbose') const failures: string[] = [] - for (const entry of ENTRIES) { - const entryPath = join(APP, entry) - if (!existsSync(entryPath)) { - console.error(`❌ Guarded entry no longer exists: ${entry}`) - console.error(' Update ENTRIES in scripts/check-tool-registry-boundary.ts.') - process.exit(1) - } + const entryRoot = join(APP, ENTRY_ROOT) + if (!existsSync(entryRoot)) { + console.error(`❌ ${ENTRY_ROOT} no longer exists — update ENTRY_ROOT in this script.`) + process.exit(1) + } + const entries = collectEntries(entryRoot).sort() + if (entries.length === 0) { + console.error( + `❌ No page/layout entries found under ${ENTRY_ROOT}. Refusing to pass vacuously.` + ) + process.exit(1) + } + for (const entry of entries) { + const entryPath = join(APP, entry) const result = walk(entryPath) if (result.reachable.has(FORBIDDEN)) { failures.push(entry) @@ -174,7 +184,7 @@ function main() { process.exit(1) } - console.log(`✓ tool registry stays out of ${ENTRIES.length} workspace route graphs`) + console.log(`✓ tool registry stays out of ${entries.length} workspace page/layout graphs`) } main() From c93822c7fcdcc5d2a1c35b4631476af83e7a3580 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 08:22:22 -0700 Subject: [PATCH 5/6] fix(tools): restore the dynamic-import and namespace-alias edge detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bad merge during a rebase reverted this file to a pre-fix revision, silently dropping `DYNAMIC_IMPORT_RE` and the `export * as ns from` alias branch that earlier commits on this branch had already added. The guard still passed, which is the worst way for a lint to break — it simply stopped following edges. Caught it because the per-route counts fell after the rebase (files 1,424 -> 1,314, logs 1,610 -> 1,545) rather than staying put. A guard that reports fewer modules after a no-op merge is not passing, it is blind. Now verified against every bypass form rather than the one I happened to think of, so a future regression of this kind fails loudly: CAUGHT extensionful import { tools } from '@/tools/registry.ts' CAUGHT dynamic import('@/tools/registry') CAUGHT ns re-export export * as ns from '@/tools/registry' CAUGHT side-effect import '@/tools/registry' CAUGHT plain named import { tools } from '@/tools/registry' clean tree passes --- scripts/check-tool-registry-boundary.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index a53eecd0d1d..32b4e55e692 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -61,11 +61,20 @@ function collectEntries(dir: string, found: string[] = []): string[] { const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] /** - * Matches value imports and re-exports, skipping `import type` — a type-only - * edge is erased at compile time and costs nothing at runtime. + * Matches value imports and re-exports, skipping `import type` and + * `export type` — a type-only edge is erased at compile time and costs nothing. + * + * `REEXPORT_RE` allows an alias after the star so `export * as ns from` is not + * missed, and `DYNAMIC_IMPORT_RE` covers `import('…')`. A dynamic import splits + * the registry into its own chunk rather than the route's initial one, but it + * still puts 4,300 tools' worth of executable config on a client path, so it + * counts as reaching it — and the settings route's registry edge hid behind + * exactly such an import. */ const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g -const REEXPORT_RE = /(?:^|\n)\s*export\s+(?!type\b)(?:\*|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g +const REEXPORT_RE = + /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g +const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g /** Resolves `@/` and relative specifiers. Bare package specifiers are ignored. */ function resolveSpecifier(specifier: string, importer: string): string | null { @@ -110,7 +119,7 @@ function walk(entry: string): Walk { } catch { continue } - for (const pattern of [IMPORT_RE, REEXPORT_RE]) { + for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE]) { pattern.lastIndex = 0 let match = pattern.exec(source) while (match !== null) { From fae9250e12053f2c22147e1365d9e0e42211e92a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 08:28:04 -0700 Subject: [PATCH 6/6] fix(tools): traverse require() edges in the boundary guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged `require()` as an untraversed edge form, and it is not hypothetical here — this codebase uses lazy `require('@/…')` to break import cycles, including from a client-reachable file (`tools/params.ts` reaches `@/blocks` that way). Those edges are as real as static imports; a `require` of the registry would have walked straight past the guard. The audit now covers every form a module can be reached by, each verified rather than assumed: CAUGHT plain named import { tools } from '@/tools/registry' CAUGHT side-effect import '@/tools/registry' CAUGHT extensionful import { tools } from '@/tools/registry.ts' CAUGHT ns re-export export * as ns from '@/tools/registry' CAUGHT dynamic import('@/tools/registry') CAUGHT require require('@/tools/registry') clean tree passes No new violations surfaced — the 35 guarded page/layout graphs stay clean with require edges followed. --- scripts/check-tool-registry-boundary.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index 32b4e55e692..e2d33bf862c 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -70,11 +70,16 @@ const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] * still puts 4,300 tools' worth of executable config on a client path, so it * counts as reaching it — and the settings route's registry edge hid behind * exactly such an import. + * + * `REQUIRE_RE` matters for the same reason: this codebase uses lazy + * `require('@/…')` to break import cycles (`tools/params.ts` reaches `@/blocks` + * that way), and those edges are as real as static ones. */ const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g const REEXPORT_RE = /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g +const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g /** Resolves `@/` and relative specifiers. Bare package specifiers are ignored. */ function resolveSpecifier(specifier: string, importer: string): string | null { @@ -119,7 +124,7 @@ function walk(entry: string): Walk { } catch { continue } - for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE]) { + for (const pattern of [IMPORT_RE, REEXPORT_RE, DYNAMIC_IMPORT_RE, REQUIRE_RE]) { pattern.lastIndex = 0 let match = pattern.exec(source) while (match !== null) {