Skip to main content

tool-registry-boundary

Keep the executable tool registry out of client-reachable module graphs — when to read `@/tools/metadata` instead of `getTool`, how to measure whether an import edge pulls the registry, and how to regenerate the metadata artifacts. Use when touching `apps/sim/tools/registry.ts`, `tools/utils.ts`, `tools/params.ts`, or anything that calls `getTool`.

Informations de source

Dépôt
simstudioai/sim
Dernière activité de la source
2 octobre 2026 à 08:59
Langue détectée de SKILL.md
anglais
Étoiles
29 779
Forks
3 852

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
name
tool-registry-boundary
description
Keep the executable tool registry out of client-reachable module graphs — when to read `@/tools/metadata` instead of `getTool`, how to measure whether an import edge pulls the registry, and how to regenerate the metadata artifacts. Use when touching `apps/sim/tools/registry.ts`, `tools/utils.ts`, `tools/params.ts`, or anything that calls `getTool`.
# Tool Registry Boundary Skill You keep the 5,000+-tool executable registry out of module graphs that don't execute tools. ## The rule > Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**. `@/tools/registry` is a 10,000+-line barrel importing every tool. External `ToolConfig` entries mix plain data (`params`, `outputs`, `name`) with request/response closures, while `InternalToolConfig` entries contain semantic input projection and load their server implementation through `lib/internal/tool-operations/registry.server.ts`. Request closures can still reach SDK clients, API helpers, and parsers, which is what makes the executable barrel expensive: reaching it costs roughly 4,700 additional modules (measured; re-measure with `--verbose`). `getTool()` returns the whole `ExecutableToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in. ## Which module to import | you need | import | notes | | --- | --- | --- | | whether a tool id exists | `hasToolId` from `@/tools/tool-ids` | ~110 KB — the cheapest module | | to resolve an unversioned name | `resolveToolId` from `@/tools/tool-ids` | | | every tool id | `getToolIds` from `@/tools/tool-ids` | | | a tool's params | `getToolParams` / `getToolMetadata` from `@/tools/metadata` | ~4 MB | | a tool's declared outputs | `getToolOutputsMetadata` from `@/tools/metadata-outputs` | ~4 MB, separate on purpose | | to **execute** a tool | `getTool` from `@/tools/utils`, or `getToolAsync` from `@/tools/utils.server` (async/workspace-context lookup) | server paths only | Three modules, cheapest first. Ids are their own artifact because resolution and existence checks need only the key set; outputs are their own because they are the larger half of the data with a single consumer. `@/tools/metadata` and `@/tools/metadata-outputs` both resolve ids through `@/tools/tool-ids`, which is what keeps them independent of each other — do not "helpfully" re-export one from another, or every caller pays for all three. All lookups guard with `Object.hasOwn`. `JSON.parse` yields an object with the normal prototype, so a bare bracket lookup returns inherited members: a bare bracket lookup would answer `getToolMetadata('constructor')` with a *function* typed as tool metadata. ## The generated artifacts `apps/sim/tools/generated/tool-ids.ts`, `tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`: ```bash bun run tool-metadata:generate # after adding/changing a tool bun run tool-metadata:check # what CI runs; fails if stale ``` Never hand-edit them. If you add a tool or change a tool's `params`/`outputs`, regenerate and commit the result, or CI fails. Four non-obvious properties, each of which was measured and is easy to undo by accident: - **The data is a JSON string parsed at runtime, not an imported `.json` and not an object literal.** With `resolveJsonModule` (which this repo enables), a `.json` import makes TypeScript infer a literal type for all 5,000+ entries and takes `tsc --noEmit` from **12.6s to 8m07s** — a 38x regression. An ambient `declare module` does *not* short-circuit it, and an object literal costs the same. A single string literal is one cheap token for both the compiler and the bundler, and `JSON.parse` beats evaluating the equivalent literal at runtime. Do not "clean this up" into a `.json` import. - **The generator refuses to emit function values.** If you add a field to `METADATA_FIELDS` that contains a closure, generation fails loudly rather than shipping executable config to the client. `hosting` and `schemaEnrichment` are excluded for exactly this reason (`hosting.enabled`, `pricing`, and `enrichSchema` are functions) — they are server-only. - **Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating. - **Lookups resolve versions.** `getTool` maps an unversioned name onto the newest version, and a few hundred tools are versioned. A plain key lookup would silently report them missing — a quiet correctness bug, not a crash. `resolveToolId` reproduces that against the id set and is differentially tested against the original. ## Testing code that reads tool metadata Mock the module the code under test actually reads. `@/tools/metadata` and `@/tools/metadata-outputs` are mocked globally in `apps/sim/vitest.setup.ts` and return `undefined`; never `vi.mock` them again (`check:test-patterns` fails a `global-remock`). Install fixtures on the global mock instead: ```ts import { toolsMetadataMock } from '@sim/testing/mocks/blocks.mock' import { getToolMetadata } from '@/tools/metadata' vi.mocked(getToolMetadata).mockImplementation(toolsMetadataMock.getToolMetadata) ``` Do the same for `getToolParams`. `vi.mock('@/tools/utils', () => toolsUtilsMock)` controls only `getTool`. Both mocks share `mockToolConfigs`, so together they give 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` (run in CI by `check:audits`) 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 (e.g. `tools/merge-params.ts`). Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph. The same command also ratchets those counts against `check-tool-registry-boundary.baseline.json`. `--check` (what CI runs) fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, naming the import chain responsible. The baseline also catches graph growth that never touches `@/tools/registry`. Re-record with `--update-baseline` and commit the JSON when growth is deliberate. A *shrink* passes but is reported — re-record then too, or the win is silently spendable again. ## How to verify an edge actually got cut Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph: 1. From the entry you care about, follow `import` and `export … from` (skipping `import type`), resolving `@/` against `apps/sim`. 2. Check whether `apps/sim/tools/registry.ts` is in the reachable set, and print the parent chain if it is. 3. Compare the reachable module count before and after. A route can reach the registry through several redundant edges; cutting one changes nothing until all are gone, so measure the route, not the file you edited. ## When adding a new caller Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph. If it genuinely executes — builds an external request, transforms a response, or dispatches a registered internal operation — use `getTool`, and keep that file off client-reachable paths.
Voir sur GitHub