/** * MCP startup must not load the analyze-only language provider registry (#2802). * * `mcp/local/pdg-impact.ts` once imported `core/ingestion/languages/index.ts` * for a single extension→language lookup. That edge pulled all 16 providers, * their extractors, and the tree-sitter native binding into every MCP server * start: ~226 extra modules and ~130 ms, for a server that never analyzes * anything. The finding was discovered and lost once already (during #2793) * before #2802 re-derived it, so it gets a guard rather than a comment. * * The guard is a REAL MODULE-LOAD PROBE, not a source-level import walk. A * previous regex-based version of this test (`test/unit/mcp-startup-import- * closure.test.ts`) was defeated four separate ways: it walked from * `local-backend.ts` instead of the actual server entry, it was structurally * blind to eager top-level `await import(...)`, its type-only-import stripper * lazily matched across a 16 kB window of `pdg-impact.ts` (the terminating * `from "…"` lived inside a string literal), and its comment stripper treated * `/*` inside a string literal as a comment opener. * * The probe itself now lives in `test/helpers/module-load-probe.ts`, shared with * `test/integration/mcp/import-closure.test.ts` and * `test/integration/optional-grammars/registry-import-closure.test.ts` — it * spawns a child Node process, imports a built `dist/` entry, and reports every * module the loader actually pulled in. It cannot be fooled by import syntax, a * stale entry point, or regex drift: whatever Node evaluates, the probe sees. * The FORBIDDEN set and its remedy stay here, because they are specific to * #2802. * * Coverage note: `dist/mcp/server.js` is the entry that must be protected — it * is what `mcpCommand` dynamically imports and what actually serves MCP. * `dist/cli/mcp.js` is asserted too (it is the process entry, and its * deliberately leaf-only static closure is pinned separately by * `import-closure.test.ts`), as is `dist/mcp/local/local-backend.js` — the * module whose import graph #2802 actually changed, and * `dist/mcp/http-transport.js`, which is the OTHER startup entry: `gitnexus mcp * --http` reaches it through its own `await import(...)` in `mcpCommand`, not * through `server.js`, so nothing about `server.js` staying clean constrains it. * * `local-backend.js`'s closure is TODAY a strict subset of `server.js`'s (156 * of 380 modules, none of them absent from the server's), so it cannot surface * an offender the server probe would miss. It is kept anyway, for two reasons * that survive that measurement. The subset relation is an observation about * the current graph and nothing enforces it: the day `server.js` stops reaching * the local backend eagerly (remote-only default, lazy backend selection), the * server probe's anchor — `dist/mcp/resources.js` — keeps passing while the * module #2802 actually changed goes unobserved. Its own entry pins * `dist/mcp/local/pdg-impact.js` as an anchor, which is coverage the server * entry does not and cannot provide. And since the probes run concurrently, the * marginal wall-clock cost is ~0: it finishes inside the server probe's window. * * Lazy `await import(...)` inside a function body remains the sanctioned escape * hatch: it does not run at startup, so the probe does not see it. A top-level * `await import(...)` DOES run at module evaluation, and the probe reports it — * which is the point. */ import { describe, it, expect, beforeAll } from 'vitest'; import { anchorsOf, probeModuleLoads, type ModuleLoadProbes, type ModuleLoadRequest, } from '../../helpers/module-load-probe.js'; /** Modules under this directory are the analyze-only provider registry. */ const FORBIDDEN_RE = /(^|\/)core\/ingestion\/languages\//; /** * The group contract extractors, and the native parser binding they reach. * * Same defect class as #2802, found immediately after it: `core/group/service.ts` * statically imported `./sync.js`, which pulls all six contract extractors, five * of which statically import `tree-sitter`. Only `group_sync` ever needs them — * the other seven group tools do not — so a static import put the whole parser * stack on every MCP server start. Measured cost of that one edge on a native * filesystem: `dist/mcp/server.js` 521 ms -> 133 ms, `local-backend.js` 453 ms * -> 66 ms. * * Matching the parser by its package prefix rather than a bare substring so a * source file that merely mentions the word cannot satisfy or trip this. * * The separator is `[\\/]`, matching the sibling probes' `ANY_GRAMMAR_RE` and * `OPTIONAL_GRAMMAR_RE`, NOT a bare `/`. Native bindings reach the probe through * the `require.cache` channel as absolute paths, and `toRepoRelativePosix` only * POSIX-normalises paths INSIDE the repo root — a hoisted `node_modules` renders * verbatim, so on Windows this is `…\node_modules\tree-sitter\…` and a * forward-slash-only pattern silently matches nothing. This file now runs on the * Windows matrix, where that would have made the parser half of the assertion * vacuous. The `core/group/extractors/` half is first-party `dist/**`, always * in-repo and therefore already normalised. */ const FORBIDDEN_GROUP_RE = /(^|\/)core\/group\/extractors\/|[\\/]node_modules[\\/]tree-sitter/; /** * The chain the group policy above polices, and therefore ITS non-vacuity * anchor. `core/group/service.js` is the module that statically imported * `./sync.js` and dragged the extractors in; the fix made that edge lazy. An * anchor on some other chain (`mcp/resources.js`, `mcp/local/pdg-impact.js`) * plus the module-count floor both stay green when `local-backend → * core/group/service` is severed — the obvious next lazy-load step — and the * group assertion would then be vacuous on every row while the file still * reported all-pass. Anchors are per-POLICY, not per-entry; see * `test/helpers/module-load-probe.ts`. */ const GROUP_ANCHOR = 'dist/core/group/service.js'; /** * Third instance of the same defect class, and the one this file could not see. * * `pdg-impact.ts` imported two format constants from `core/ingestion/cfg/emit.ts`. * ESM evaluates a module to import any binding from it, so those two strings * pulled the whole analyze-only CFG closure — `emit`, `reaching-defs`, * `reaching-defs-graph`, `control-dependence`, `post-dominators`, * `synthetic-escape`, `call-site-harvest` — into every MCP start. The constants * moved to the leaf `cfg/callee-cell-format.ts`, but `emit.ts` still RE-EXPORTS * them, so pointing the import back at `emit.js` typechecks identically and * restores all seven modules. Neither existing policy matches * `core/ingestion/cfg/`, so nothing was stopping that. * * Allowlist rather than a denylist of the seven: the failure mode is a module * nobody has thought of yet, and a denylist only ever names the regressions * already suffered. Everything here is a genuine LEAF — zero imports — which is * why it can sit on the startup path at all; that is a real convention in * `core/ingestion` (each file's header calls itself the one shared codec), and * this is the only thing enforcing it. */ const CFG_ANCHOR = 'dist/core/ingestion/cfg/callee-cell-format.js'; const INGESTION_CFG_RE = /(^|\/)core\/ingestion\/cfg\//; const CFG_LEAVES_ALLOWED: ReadonlySet = new Set([ CFG_ANCHOR, 'dist/core/ingestion/cfg/reaching-def-reason-codec.js', ]); // Observed on Node 22.18 against a clean build at the tip of this branch: // server.js 380 distinct modules, local-backend.js 156, cli/mcp.js 4. Treat // these as a snapshot, not a contract — they moved twice inside this branch // alone (the group-extractor and cfg/emit closures each took ~100 and ~7 out), // and only the FLOORS below are asserted. The floors sit well under the // observed counts so normal dependency churn doesn't trip them, while a probe // that silently loaded nothing still fails. // // `mcp/http-transport.js` is the largest startup entry — measured 516 modules, // +136 over server.js for express, cors and the SDK's Streamable-HTTP/SSE // transports. It is what `gitnexus mcp --http` starts and the hosted-deploy // path, and `mcpCommand` imports it directly, not via `server.js`; because that // edge runs one way only, a static import added inside `http-transport.ts` // would reinstate #2802 on the HTTP path with every other row here green. The // probes run concurrently, so its marginal wall-clock cost is ~0 — it finishes // alongside the others rather than after them. // // `mcp/resources.js` (measured 57 modules, 0 offenders) and `mcp/staleness.js` // (53, 0) are named in the #2802 write-up but deliberately get NO rows: both // are eagerly inside the closures of `server.js` and `http-transport.js` // (each appears in both probes' module lists), so any offender they acquired // surfaces on those rows already. They would earn rows only if something made // them reachable other than eagerly-from-the-server. const ENTRIES = [ { entry: 'mcp/server.js', anchor: ['dist/mcp/resources.js', GROUP_ANCHOR, CFG_ANCHOR], minModules: 100, }, { entry: 'mcp/http-transport.js', anchor: ['dist/mcp/server.js', GROUP_ANCHOR, CFG_ANCHOR], minModules: 100, }, { entry: 'mcp/local/local-backend.js', anchor: ['dist/mcp/local/pdg-impact.js', GROUP_ANCHOR, CFG_ANCHOR], minModules: 50, }, // The one row with a single anchor, because it is subject to ONE policy. Its // whole design is a 4-module leaf closure that reaches nothing first-party // beyond `stdio-context → stdio-capture`, so it can never reach // `core/group/service.js` and cannot be given the group anchor honestly. It // is excluded from the group policy below for that reason: a row that cannot // fail for any reason related to the policy it is listed under is exactly the // vacuity this file's probe exists to prevent. Its leaf-only closure is // pinned exhaustively by `import-closure.test.ts` instead. { entry: 'cli/mcp.js', anchor: 'dist/mcp/stdio-context.js', minModules: 3 }, ] as const satisfies readonly ModuleLoadRequest[]; /** * The rows the group policy applies to — DERIVED from the anchors, not * hand-listed beside them, so an entry cannot join that policy without * carrying the anchor that keeps it able to fail. */ const GROUP_POLICY_ENTRIES = ENTRIES.filter((request) => anchorsOf(request.anchor).includes(GROUP_ANCHOR), ).map((request) => request.entry); /** Same derivation for the CFG-leaf policy. */ const CFG_POLICY_ENTRIES = ENTRIES.filter((request) => anchorsOf(request.anchor).includes(CFG_ANCHOR), ).map((request) => request.entry); describe('MCP startup module-load closure (#2802)', () => { let probes: ModuleLoadProbes; // Every entry is probed CONCURRENTLY here, not one per test: the probes are // independent child processes and each pays a full Node start, so running // them in parallel cuts this file's wall clock by roughly 60% and makes each // additional entry near-free. The helper labels every failure with its entry // and enforces each entry's anchors and module floor, so the `it` bodies // below are pure policy assertions. beforeAll(async () => { probes = await probeModuleLoads(ENTRIES); }, 90_000); // `%s` over the bare entries, not `$entry` over the request objects: vitest // quotes an interpolated object property, and `importing dist/'mcp/server.js'` // reads like a typo in CI output. it.each(ENTRIES.map((request) => request.entry))( 'importing dist/%s loads no language provider module', (entry) => { const probe = probes.get(entry); const offenders = probe.matching(FORBIDDEN_RE); // Headline assertion: named chains, not a bare boolean, so whoever // reintroduces the edge sees exactly which modules did it. expect( offenders, `${probe.label} eagerly loads the analyze-only language provider registry. ` + `MCP startup never analyzes anything — route the lookup through a lazy ` + `\`await import(...)\` inside the function that needs it (see #2802). ` + `Offending modules:\n${offenders.join('\n')}`, ).toEqual([]); }, ); // Pins the two derivations above. The risk each guards is a policy going // SILENT, not its exact membership: drop an anchor from every entry and the // derived list empties, so the `it.each` registers zero cases and the whole // policy disappears without one red test. Asserting non-emptiness catches // exactly that; asserting the literal list would reinstate, one layer down, // the hand-maintained list the derivation exists to remove — every entry // added or removed would then need editing in two places. // // `cli/mcp.js` is pinned OUT of both policies deliberately. It is a 4-module // leaf closure that cannot reach either policed chain, so listing it would // give each policy a row that cannot fail — the vacuity this file exists to // prevent. That exclusion is a real property, so it is asserted rather than // left to the comment above. it.each([ ['group', GROUP_POLICY_ENTRIES], ['cfg-leaf', CFG_POLICY_ENTRIES], ])('the %s policy runs over a non-empty entry set that excludes cli/mcp.js', (_name, entries) => { expect(entries.length).toBeGreaterThan(0); expect(entries).not.toContain('cli/mcp.js'); }); // The #2802 defect class, third instance: an analyze-only closure reached // through a constant. Allowlist, not denylist — see CFG_LEAVES_ALLOWED. it.each(CFG_POLICY_ENTRIES)( 'importing dist/%s loads no non-leaf core/ingestion/cfg module', (entry) => { const probe = probes.get(entry); const offenders = probe .matching(INGESTION_CFG_RE) .filter((module) => !CFG_LEAVES_ALLOWED.has(module)); expect( offenders, `${probe.label} eagerly loads analyze-only CFG modules. ESM evaluates a ` + `module to import ANY binding from it, so importing a constant from ` + `\`cfg/emit.js\` drags its whole closure onto startup — take format ` + `constants from the leaf \`cfg/callee-cell-format.js\` instead, and add ` + `a new module here only if it genuinely imports nothing (see #2802). ` + `Offending modules:\n${offenders.join('\n')}`, ).toEqual([]); }, ); it.each(GROUP_POLICY_ENTRIES)( 'importing dist/%s loads no group contract extractor or native parser', (entry) => { const probe = probes.get(entry); const offenders = probe.matching(FORBIDDEN_GROUP_RE); expect( offenders, `${probe.label} eagerly loads the group contract extractors and/or the ` + `native tree-sitter binding. Only \`group_sync\` needs them, and MCP ` + `startup never syncs — keep \`core/group/sync.js\` behind the lazy ` + `\`await import(...)\` in \`GroupService.groupSync\`. ` + `Offending modules:\n${offenders.join('\n')}`, ).toEqual([]); }, ); });