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..e2d33bf862c --- /dev/null +++ b/scripts/check-tool-registry-boundary.ts @@ -0,0 +1,204 @@ +#!/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, readdirSync, 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') + +/** + * 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 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 — 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 { + let base: string + if (specifier.startsWith('@/')) base = join(APP, specifier.slice(2)) + 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 + } + 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, DYNAMIC_IMPORT_RE, REQUIRE_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[] = [] + + 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) + 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 page/layout graphs`) +} + +main()