perf(tools): guard the tool-registry client boundary in CI - #6156
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryLow Risk Overview
Wired as Reviewed by Cursor Bugbot for commit 1ea6bb3. Configure here. |
Greptile SummaryThis PR adds a CI audit that traverses workspace-route imports and rejects paths reaching the executable tool registry.
Confidence Score: 4/5The PR appears safe to merge, with a non-blocking gap in the audit's coverage of dynamic import() and require() edges. The CI check correctly covers static local import and re-export paths, but existing workspace code demonstrates runtime import forms that its parser does not traverse, leaving those forms outside the promised boundary. Files Needing Attention: scripts/check-tool-registry-boundary.ts
|
| Filename | Overview |
|---|---|
| scripts/check-tool-registry-boundary.ts | Adds the route graph walker and registry check, but its edge extraction omits dynamic import() and require(). |
| .github/workflows/test-build.yml | Adds the boundary audit to the existing test/build job. |
| package.json | Exposes the new checker through a Bun package script. |
| .agents/skills/tool-registry-boundary/SKILL.md | Documents how to run and remediate failures from the new guard. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
CI[CI boundary audit] --> E[Five workspace entries]
E --> P[Parse static imports and re-exports]
P --> R[Resolve local modules]
R -->|new edge| P
R --> F{Registry reachable?}
F -->|Yes| Fail[Fail with import chain]
F -->|No| Pass[Pass audit]
E -. omitted .-> D[Dynamic import / require edges]
Reviews (1): Last reviewed commit: "perf(tools): guard the tool-registry cli..." | Re-trigger Greptile
3cc9ffa to
31043eb
Compare
31043eb to
42c7448
Compare
face991 to
1ea6bb3
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1ea6bb3. Configure here.
f0c4604 to
69ecdc8
Compare
Note on the 4/5 score aboveThat review ran at ~06:00 UTC against an earlier head; the branch is now at a later commit and Greptile has not re-run despite repeated triggers (it does not appear to re-review on force-push for these stacked bases). Flagging it so the stale score isn't read as an open concern. Its stated reason — the guard not traversing dynamic The full Test and Build workflow was dispatched against the stack head and passed. Note that |
69ecdc8 to
4db5075
Compare
4db5075 to
e708fc1
Compare
e708fc1 to
98f6d8c
Compare
98f6d8c to
fafd0c7
Compare
fafd0c7 to
793d37d
Compare
793d37d to
15229cf
Compare
15229cf to
71c5a2a
Compare
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.
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.
`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.
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.
…tion
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
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.
71c5a2a to
fae9250
Compare

Makes the win from #6155 permanent.
Why a lint and not a convention
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.tsimportedmergeToolParametersmcp-dynamic-args.tsximportedformatParameterLabelNeither looks remotely like "pull in 4,700 modules of SDK clients." A reviewer will not catch the third one either.
What it does
check-tool-registry-boundary.tswalks the value-import graph — skippingimport type, which is erased at compile time — from the workspace layout and the four routes that mount inside it, and fails if@/tools/registryis reachable, printing the exact chain.Verified it can fail
Reintroduced a
getToolimport inserializer/index.ts:Exit 1 with the regression, exit 0 after removing it.
No allowlist, on purpose
The fix for a failure is always to move the symbol the file actually needs into a registry-free module — the way
mergeToolParametersandformatParameterLabelwere. An allowlist would let the graph silently grow back one entry at a time.--verboseprints per-route module counts, which is also the fastest way to see whether a change moved the graph.Test plan
getToolimport, with a correct chain