Skip to content

perf(tools): guard the tool-registry client boundary in CI - #6156

Merged
waleedlatif1 merged 6 commits into
perf/tool-metadata-adoptfrom
perf/tool-registry-boundary-guard
Aug 1, 2026
Merged

perf(tools): guard the tool-registry client boundary in CI#6156
waleedlatif1 merged 6 commits into
perf/tool-metadata-adoptfrom
perf/tool-registry-boundary-guard

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Stacked on #6155#6153#6152#6151. Bases retarget as each merges.

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.ts imported mergeToolParameters
  • mcp-dynamic-args.tsx imported formatParameterLabel

Neither 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.ts walks the value-import graph — skipping import type, which is erased at compile time — from the workspace layout and the four routes that mount inside it, and fails if @/tools/registry is reachable, printing the exact chain.

✓ app/workspace/layout.tsx — 1063 modules, registry unreachable
✓ app/workspace/[workspaceId]/w/page.tsx — 1908 modules, registry unreachable
✓ app/workspace/[workspaceId]/logs/page.tsx — 1543 modules, registry unreachable
✓ app/workspace/[workspaceId]/tables/page.tsx — 1217 modules, registry unreachable
✓ app/workspace/[workspaceId]/files/page.tsx — 1310 modules, registry unreachable

Verified it can fail

Reintroduced a getTool import in serializer/index.ts:

❌ app/workspace/[workspaceId]/w/page.tsx can reach @/tools/registry via:
     apps/sim/app/workspace/[workspaceId]/w/page.tsx
     apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
     apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts
     apps/sim/stores/workflow-diff/store.ts
     apps/sim/serializer/index.ts
     apps/sim/tools/utils.ts
     apps/sim/tools/registry.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 mergeToolParameters and formatParameterLabel were. An allowlist would let the graph silently grow back one entry at a time.

--verbose prints per-route module counts, which is also the fastest way to see whether a change moved the graph.

Test plan

  • Passes on the current tree (5 routes)
  • Fails (exit 1) with an injected getTool import, with a correct chain
  • Wired into CI next to the other graph audits
  • Skill updated with the guard and the no-allowlist rule

@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 1, 2026 06:52
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 1, 2026 6:08pm

Request Review

@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Adds static analysis and CI only; no application runtime or auth/data-path changes.

Overview
Adds a CI guard so workspace routes cannot silently pull @/tools/registry (~4,700 modules) back into client-reachable graphs after the boundary work in earlier PRs.

scripts/check-tool-registry-boundary.ts discovers every page.tsx and layout.tsx under app/workspace, walks value imports and re-exports (skipping import type), and fails with the full import chain if tools/registry.ts is reachable. --verbose prints per-entry module counts. There is no allowlist — failures are fixed by moving symbols to registry-free modules (as documented for mergeToolParameters / formatParameterLabel).

Wired as bun run check:tool-registry-boundary in root package.json and the "Tool registry client-boundary audit" step in .github/workflows/test-build.yml. The tool-registry-boundary skill/command docs add a "The guard" section describing the check and remediation.

Reviewed by Cursor Bugbot for commit 1ea6bb3. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a CI audit that traverses workspace-route imports and rejects paths reaching the executable tool registry.

  • Adds the graph-walking boundary checker and package script.
  • Runs the checker in the test/build workflow.
  • Documents the guard, diagnostics, and no-allowlist policy across agent command surfaces.

Confidence Score: 4/5

The 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

Important Files Changed

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]
Loading

Reviews (1): Last reviewed commit: "perf(tools): guard the tool-registry cli..." | Re-trigger Greptile

Comment thread scripts/check-tool-registry-boundary.ts
Comment thread scripts/check-tool-registry-boundary.ts
Comment thread scripts/check-tool-registry-boundary.ts Outdated
Comment thread scripts/check-tool-registry-boundary.ts
Comment thread scripts/check-tool-registry-boundary.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread scripts/check-tool-registry-boundary.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Note on the 4/5 score above

That 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 import() / require() edges, and guarding the wrong layout — has since been fixed, with each fix verified rather than asserted. Every review thread on this PR is resolved, and the reasoning is in the thread replies.

The full Test and Build workflow was dispatched against the stack head and passed. Note that ci.yml only fires for PRs targeting main/staging/dev, so stacked PRs don't get it automatically — worth re-checking when each retargets to staging on merge.

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.
@waleedlatif1
waleedlatif1 force-pushed the perf/tool-registry-boundary-guard branch from 71c5a2a to fae9250 Compare August 1, 2026 18:03
@waleedlatif1
waleedlatif1 merged commit e8894a8 into staging Aug 1, 2026
25 checks passed
@waleedlatif1
waleedlatif1 deleted the perf/tool-registry-boundary-guard branch August 2, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant