GitNexus/gitnexus/test/unit/impact-pdg-ascent-note.test.ts
Gergő Magyar 9eaf2e6c4e
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Skill copy sync / shipped skills drift guard (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
perf(mcp): cut the analyze-only language-provider closure out of MCP server startup (#2802) (#2806)
* fix(mcp): key the empty-ascent note on CALL_SUMMARY data, not language (#2802)

`pdg-impact.ts` decided whether to append a "return-value ascent is
TypeScript/JavaScript-only" caveat to the `impact(mode:'pdg')` note by
looking up the criterion file's language. That put language-specific
logic in a layer that must be language-agnostic, and it was a lossy proxy
for a fact the graph already holds.

Whether the ascent can fire is a property of the persisted CALL_SUMMARY
edges. The descent already computes it, so thread the resolved-callee and
return-flowing counts out of `interproceduralDescent` and key the note on
those instead.

Three defects the language proxy carried, all gone:

  - Wrong for `.mjs`/`.cjs`/`.mts`/`.cts`: the provider registry's
    extension arrays omit them while the ingestion pipeline parses them
    as TS/JS, so those files were harvested but the note claimed their
    ascent was empty.
  - Silently stale: any language whose harvester started recording formal
    indices would keep getting the caveat until someone edited the list.
  - Wrong in reverse: a TS/JS callee with no return-flow got no caveat, so
    an ascent that found nothing read like one that covered the slice.

`pdg-impact.ts` now names no language and imports nothing from the
language layer, which also drops the analyze-only provider closure from
MCP server startup. Measured on overlayfs against a full build:

  import mcp/local/local-backend.js  before  565-648 ms / 548 modules
  import mcp/local/local-backend.js  after   458-463 ms / 170 modules

Tests hold CALL_SUMMARY content fixed while varying the file extension
across nine languages and assert the note text is identical, then hold the
extension fixed and vary the summary to show the note tracks the data.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): guard MCP startup against the language-provider closure returning

The eager `pdg-impact.ts -> core/ingestion/languages` edge was found and
lost once already during #2793 before #2802 re-derived it, so it gets a
test rather than a comment.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(lbug): record why csv-generator is not lazy-imported

#2802 proposed cutting `csv-generator.js` out of the adapter chain to
shorten MCP server startup. Measured on a native filesystem, the marginal
cost is small relative to the siblings this module already imports, and
`core/search/bm25-index.ts` statically imports `normalizeFtsText` from the
same module on a path `local-backend.ts` reaches dynamically for FTS — so
deferring would relocate the cost to first query, not remove it.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(pdg): pin chained receiver calls reaching BasicBlock.calleeIds

The PDG inter-procedural descent hops through `BasicBlock.calleeIds`, so
it can only cross a call boundary the resolver resolved. Chained receiver
calls reach `calleeIds` through the receiver-typing pass's own
`calleeIdSink` — a separate path from plain calls.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(analyze): drop the stale per-language cross-reference (#2802 review P3-4)

`pdgModeMismatch`'s comment told readers to keep "the diagnostic
per-language refinement in the impact CONSUMER (see pdg-impact.ts
assemblePdgImpactResult)". That refinement is no longer per-language —
removing it is the point of #2802, which now keys the empty-ascent note on
the persisted CALL_SUMMARY data instead.

The comment's real invariant is untouched and still correct: the values in
`resolvePdgConfig` must stay scalar, because the comparison below is a
shallow `!==` and an object would compare by reference. Only the
cross-reference was stale.

Comment-only; no executable line changes.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): probe the real module loader for the startup language closure (#2802 review P1-2)

The previous guard hand-rolled a regex walk over TypeScript source to
assert `core/ingestion/languages` was not statically reachable from MCP
startup. Four bypasses were reproduced against it, any one of which let
the exact 226-module regression return while the test stayed green:

  a. Wrong entry root. It walked from `mcp/local/local-backend.ts`, but the
     server module is `mcp/server.ts` — which imports LocalBackend as
     `import type`, so the guard's anchor was not even on server.ts's
     runtime closure. Ten real startup modules sat outside it.
  b. A top-level `await import(...)` executes during module evaluation, so
     it is eager at startup — but the walker skipped every `import(...)`
     by construction.
  c. The `import type` strip deleted a 16,445-character window of
     `pdg-impact.ts`: an `export type X =` matched lazily to the next
     `from "…"`, which lives inside a string literal. Any import in that
     window was invisible.
  d. The comment strip treated a `/*` inside a string literal as a comment
     opener.

Replace the approximation with a real module-load probe: spawn a child
node process per entry, import the built `dist/` entry, and report what
the loader actually pulled in. Rooted at `dist/mcp/server.js` and
`dist/cli/mcp.js` (the real startup entries) plus
`dist/mcp/local/local-backend.js`. Syntax cannot fool it.

One deviation from the two existing sibling probes is load-bearing:
`dist/` is ESM, so a `require.cache` diff alone cannot see the first-party
`dist/**` graph — it only catches CJS and native modules, which is why
`import-closure.test.ts` gets away with it (it asserts on
`@ladybugdb/core`). A pure cache diff here would have reported zero
language modules unconditionally, i.e. a new vacuous guard. This probe
unions `module.registerHooks({ load })` with the cache diff, and each
entry carries a non-vacuity anchor and a module floor so an empty result
fails loudly.

Verified load-bearing: adding a top-level
`await import('../core/ingestion/languages/index.js')` to
`src/mcp/resources.ts` and rebuilding turns `dist/mcp/server.js` red with
70+ named offenders, while the `local-backend` and `cli/mcp` cases stay
green — which is bypass (a) demonstrated directly. The old guard passed
that poisoned tree entirely.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(lbug): drop the unreproducible 9p multiplier from the csv-generator note (#2802 review P3-2)

The comment justifying why `csv-generator.js` is NOT lazy-imported carried
a hard "~40x" figure for how much a 9p mount inflates per-file ESM
resolve. Three independent measurements during review produced ~40x, ~7.3x
and ~30x, so the multiplier is not a reproducible quantity and had no
business being stated as one in a durable comment.

Reworked so the STRUCTURAL argument leads and the numbers only support it.
That argument is what actually settles the question and it does not rot:
`core/search/bm25-index.ts` statically imports `normalizeFtsText` from
`csv-generator.js`, and `local-backend.ts` reaches bm25-index through a
dynamic import on the FTS query path — so deferring here relocates the
cost to first query rather than removing it. Both verified again at
`bm25-index.ts:15` and `local-backend.ts:2756`.

Remaining figures are re-measured, attributed to a date and issue, and
labelled by filesystem: ~1.6 ms marginal (median of 45 cold imports on
local disk) versus ~50 ms for the same import on a network mount, stated
as environment-bound rather than as a property of the module. The
provider-registry cost is given as "several hundred modules" — the static
walk, the runtime hook, and the reviewer's probe each counted it
differently (375 / 439 / 407), so no single number was picked to go stale.
The old "226 modules" was real but counted only the `languages/` subtree
and undercounted the win.

Also repoints the trailing reference to the guard's new home at
`test/integration/mcp/startup-language-closure.test.ts` (same comment
block, inseparable from this rewrite).

Comment-only; no executable line changes.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mcp): stop the empty-ascent note asserting a fact an undecodable summary contradicts (#2802 review P2-2)

The note claimed "this is a property of the persisted summaries" whenever
the descent resolved callees and none carried a return-flow. But
`decodeCallSummary` never throws by design: a version-skewed (`2|r:1`),
corrupt (`1|r:zz`), or NULL `reason` yields no entry, which was
indistinguishable from a cleanly-decoded empty summary. So the note could
assert "no formal parameter is recorded as flowing to its return value"
about a callee whose CALL_SUMMARY actually records `p0 -> return`.
`meta.pdg.hasCallSummary` is a plain boolean and stores no codec version,
so nothing else caught it.

`calleesWithReturnFlow` now reports three outcomes instead of two —
flowing, decoded-empty, and undecodable — and the undecodable count is
threaded through the descent to the note. When it is non-zero the note
says so and points at a re-index; when every summary decoded, the
persisted-summaries claim is kept and now explicitly conditioned on that.

Soundness is unchanged: an undecodable summary still licenses no ascent
and never enters the return-flowing set, so the ascent path is
byte-identical. Only the note's wording moves.

Tests drive all three undecodable forms through the mock and assert the
false claim is gone, the remedy is reported, and the ascent is still
withheld. A companion assertion pins that the all-decoded case KEEPS the
persisted-summaries claim, so the fix cannot degenerate into deleting the
sentence. Verified load-bearing: reverting the source alone fails 6 of 34.

Impact analysis: `calleesWithReturnFlow` upstream LOW (2 callers, both in
this file); `assemblePdgImpactResult` upstream LOW (1 caller).

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(pdg): cover every chained-receiver shape and pin the inference gap (#2802 review P2-1, P3-1)

The fixture proved chained receiver calls reach `BasicBlock.calleeIds`
using exactly one receiver form — a local `const`. That is the shape that
works, so a single-shape fixture implied general support the resolver does
not have. This repo has been burned by that before: a drop-count gate
blind to fixed shapes.

Measuring nine forms against the real pipeline also corrects how the gap
was originally characterised. It is NOT local-versus-field. An annotated
field resolves fine, including the constructor-assigned variant:

  private p: Outer = new Outer();          -> both links
  private p: Outer; this.p = new Outer();  -> both links
  private p = new Outer();                 -> EMPTY CELL
  private p; this.p = new Outer();         -> EMPTY CELL

The discriminator is the type ANNOTATION. When a field's type must be
inferred from its initializer the whole `calleeIds` cell empties — so even
`Outer.inner`, an ordinary named-receiver call, is lost, and the
inter-procedural descent cannot cross the boundary at all. Pre-existing;
independent of #2802, which does not touch receiver resolution.

The fixture is now table-driven over seven working forms (local const,
local in a method, annotated field, ctor-assigned annotated, ctor-param
assigned, call-result receiver, three-link chain) plus the two
inference-typed forms, each row carrying its expected chain-link ids.

Assertions moved from substring to exact id membership, split with the
production `splitCalleeIds` reader — so `Inner.compute` can no longer be
satisfied by `Inner.computeExtra` or `OtherInner.compute`, which matters
because the descent keys on exact ids for span and CALL_SUMMARY lookup.

The two known-gap rows are pinned with `it.fails` plus a hard assertion on
the exact gap-row set, so a resolver fix turns them red instead of passing
silently, and an anti-vacuity guard requires every shape to match exactly
one block — without it a drifted fixture matching zero blocks would let
`it.fails` pass for the wrong reason. Proven by mutation: relabelling a
working row as a known gap fails both pins.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mcp): qualify the empty-ascent note when the examined callee set is incomplete (#2802 review P2-4)

The note asserted "none of the N resolved callees carry a CALL_SUMMARY
return-flow", and on the all-decoded path that this is "a property of the
persisted summaries". Both are universal claims over the callees the
descent actually examined, and two mechanisms can leave that set
incomplete without the note saying so:

  1. Budget truncation. The descent stops on depth/limit/node-cap, so a
     callee that DOES carry a return-flow can sit in a hop never reached.
     A 4-deep chain reported "none of the 3 resolved callees" while link 4
     held the only summary.
  2. Emit-time capping. When a block's `calleeIds` cell was capped,
     `splitCalleeIds` strips CALLEES_TRUNCATED_SENTINEL, so the dropped
     callees are invisible to both the scan and the counters — even though
     the callgraph bridge in this same file already treats such a block as
     callee-incomplete.

Add `calleeIdsWereTruncated`, the counterpart to the sentinel strip, read
from the raw cell before splitting so a block whose entire list was capped
away still raises the flag. Thread it through the descent to the note.
Case 1 needs no new plumbing — the aggregate `truncated` is already on the
input object.

Using the aggregate rather than a descent-only flag is deliberate: seed
truncation and intra-BFS depth truncation also shrink the initial slice, so
their callees are never gathered either. It is a sound superset that never
under-hedges.

When either mechanism fired, one clause naming the reasons is appended and
the whole-slice assertion softens to "every summary examined decoded … a
property of those summaries". When the set is complete both branches stay
byte-identical to before, so this does not become a blanket hedge.

Tests pin truncated, untruncated, emit-capped-alone, both-mechanisms, and
undecodable+truncated, asserting the truncation premise rather than
assuming it. Verified load-bearing: reverting the source alone fails 6 of
42, and the HEAD note printed in those failures is the bug verbatim.

Impact analysis: `assemblePdgImpactResult`, `calleeIdsByBlock`,
`interproceduralDescent` all upstream LOW; every caller is in this file and
`runImpactPDG`'s exported signature is unchanged.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mcp): stop the empty-ascent note calling call-site references "resolved callees" (#2802 review P3-7)

The note printed "none of the N resolved callees carry a CALL_SUMMARY
return-flow (no formal parameter is recorded as flowing to its return
value)". N counted the raw `BasicBlock.calleeIds` cell, which carries ids
`resolveCalleeSpans` never enters — out-of-repo targets, interface
methods, and the `Class:` id a `new X()` emits. On the chained-receiver
fixture that inflated N from 1 to 3.

Two defects, both in the wording rather than the arithmetic: "resolved"
implies a symbol-table lookup that did not happen for those ids, and the
parenthetical asserted a FORMALS-level property about symbols never
resolved to a body.

Reworded rather than re-seeded, deliberately. `calleesWithReturnFlow`
scans the RAW id set, so the claim "none of these carries a return-flow"
is exactly established for all N — the scan really did check the `Class:`
id. Re-seeding N from the resolved spans would make the sentence quantify
over a strict SUBSET of what was checked, silently dropping the
un-enterable references from a claim that genuinely covers them, and would
desync N from `calleesUndecodable`, which is derived from the same scan
population.

  none of the N resolved callees carry ...
  none of the N call-site callee references carry ...

and the formals parenthetical is dropped. The note gets shorter, not
longer. `calleesResolved` is renamed `calleeReferences` end-to-end
(file-local; nothing outside referenced it), and the descent's return-type
doc — which called them "callee symbols the descent resolved" and
reinforced the wrong reading — now states that un-enterable ids ride the
same cell, are scanned, and are never entered.

The `> 0` gate is unchanged, so no slice that previously produced the note
stops producing one. A test pins that explicitly: an all-un-enterable cell
resolves no span, takes no hop, and emits no ascent sentence despite a
non-zero count — so a future re-seeding cannot silently move when the note
fires.

Tests also pin the quoted number and singular/plural against a mixed cell,
with a discriminator asserting `reachableBlocks` is byte-identical while
the count moves 1 -> 3. Verified load-bearing: reverting the source alone
fails 6 of 7 new tests, printing the finding verbatim.

Impact analysis: `assemblePdgImpactResult` and `interproceduralDescent`
upstream LOW, sole caller `runImpactPDG` in the same file; exported
signature unchanged.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): pin cross-hop callee accumulation and the mixed return-flow contract (#2802 review P2-5)

Every case in this file drove a single hop, so the Set union the descent
performs across hops (`calleeReferencesSeen` / `calleesReturnFlowingSeen`)
was never proven to accumulate rather than overwrite — a one-hop descent
cannot tell the two apart. And although a sibling commit added a
three-id cell, none of those ids return-flowed, so the
"some callees flow, some do not" boundary was entirely unpinned.

Extends the mock with a `secondSummary` knob that drives a genuine second
hop: `helper2` is named only in `helper`'s own body block, so the descent
must cross a second boundary to reach it. Three mock handlers are made
faithful to the parameters they already bind — `calleeIdsByBlock` now
routes on the asked `$ids`, and the CALL_SUMMARY scan and span resolve
answer per asked id — which is what makes a second callee answerable at
all. Existing cases are behavior-identical.

Five tests: the union count across two hops; a return-flow on hop 0
surviving a later empty hop; a return-flow found only on hop 1; mixed
callees in one examined set going silent rather than partial; and a
flowing callee alongside an undecodable sibling staying silent including
the decode remedy.

The mixed case pins a deliberate contract rather than proposing one. The
production condition is `calleesReturnFlowing === 0`, so partial coverage
is reported as silence. A reviewer considered and dropped "report partial
coverage" as a product change; this makes flipping it a conscious edit
instead of an accident.

Verified load-bearing against three separate source mutations: accumulating
only on hop 0 (2 fail), each hop overwriting instead of unioning (3 fail),
and flipping the gate to partial-coverage reporting (4 fail). In all three
every PRE-EXISTING test still passed — which is the finding restated as
evidence.

Test-only; `pdg-impact.ts` is byte-identical to HEAD.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mcp): consolidate the empty-ascent rationale to one canonical site (#2802 review P3-6)

The "keyed on observed CALL_SUMMARY data, never on the criterion's
language" rationale was restated in full at four comment sites. It exists
because a reviewer asked "why not just look up the language?", so it has to
stay findable — but not four times.

The canonical explanation now lives in `interproceduralDescent`'s
return-type doc, where the counters are actually computed, organised as
POPULATION (why the raw `calleeIds` tally is the right set to quantify
over) and OBSERVED DATA, NEVER THE CRITERION'S LANGUAGE (the full
answer, including the producer-change argument and the no-language-naming
rule). The other three sites keep only what is locally load-bearing and
point here.

Deliberately preserved, because each carries a non-obvious fact: why an
undecodable summary licenses no ascent, why the aggregate `truncated` is
used rather than a descent-only flag, and the raw-id-tally population
argument. Net comment delta -11 lines.

The reviewer also flagged the local/field naming asymmetry
(`calleeReferencesSeen` vs `calleeReferences`). Keeping the suffix, with a
comment recording why so it is not re-raised: the premise that every other
local matches its field is true, but those locals are identity-returned,
whereas these are `Set<string>` accumulators returned as `.size`. Dropping
the suffix would give one identifier two types in one file — a `Set` at the
accumulation site and a `number` where the note does arithmetic and
pluralisation on it ~900 lines away. The Set-ness is also load-bearing: the
dedup is why a callee invoked from two hops is not double-counted, which
is what makes the note's count correct.

Comment-only. Verified mechanically: every added and removed line in
`git diff -U0` matches a comment pattern, so the note's template literals
are untouched and its rendered text is byte-identical. 89 tests unchanged.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(mcp): collapse the ascent plumbing accreted across 13 fix commits

Quality cleanup, no behavior change. Four independent review passes
converged on the same root cause: thirteen commits each fixed one review
finding in isolation, and the ascent facts grew one loose field at a time
until 62% of the changed region was comments explaining plumbing.

Five changes:

  - `calleeIdsFromBlocks` deleted. Zero call sites anywhere in src/ or
    test/ — already dead on main, and this branch had edited it to keep it
    compiling. Its only reference was a stale `{@link}` in a neighbour's
    doc, now rewritten to stand alone.

  - `parseCalleeIdsCell` replaces the two-pass read. `calleeIdsWereTruncated`
    and `splitCalleeIds` were splitting the same cell on adjacent lines,
    which measured ~2x the parse cost (0.82 -> 1.59 ms at a realistic hop,
    57.7 -> 92.7 ms at the per-statement site cap) and was a second
    independent encoding of the sentinel format — exactly what
    `splitCalleeIds` was extracted to prevent. One pass classifies as it
    walks; `splitCalleeIds` stays as a wrapper so its two external callers
    are untouched. The single-use `export` is gone.

  - `AscentCoverage` replaces four fields threaded through three
    signatures. ~12 declaration sites become 3, and the canonical rationale
    now lives on the type by construction — which is why the earlier
    doc-consolidation commit was needed at all.

  - `calleesReturnFlowing` becomes a boolean. Its only reads were
    `=== 0`, twice; it cost a Set sized to every callee in the slice plus a
    per-hop union loop. The flag is set inside the existing
    `returnFlowing.size > 0` branch — equivalent, since the cross-hop union
    is non-empty iff some hop's was.

  - The duplicated empty-ascent note head is collapsed to one gate and one
    head with per-arm tails. Both arms had been edited in lockstep twice in
    this branch's own history.

The rendered note text is byte-identical. Verified structurally and then
empirically: both expressions reconstructed standalone and diffed across
the full cross product of references x returnFlowing x undecodable x
truncated x listTruncated — 288 combinations, 0 mismatches.

Net -53 lines. 102 tests pass unedited; the unused-symbol lint warning is
gone.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): parallelise the startup probes, drop a redundant pin, name the mock knobs

Quality cleanup from the same review passes. The set of verified behaviors
is unchanged except where noted.

**Startup probes run concurrently.** `spawnSync` blocks the event loop and
vitest runs a file's tests in order, so the three probes strictly
serialised. Launching all three with async `spawn` in `beforeAll` and
asserting over the collected outcomes cuts the file from ~12.7 s to ~3.9 s
wall (-69%). Every promise is caught before `Promise.all`, so all three
children are reaped and failures report per entry rather than surfacing
only the first rejection. Preserved and each proven by mutation: the
missing-dist error names its entry, a raised module floor fails only its
own row, and a bogus anchor still reports the loaded-module count.

**The two `it.fails` rows are removed.** They pinned the inference-typed
receiver gap that the strict `toEqual` pin beside them already covers —
and they were the weaker of the two, because `it.fails` passes when the
body throws for ANY reason, including `idsFor`'s own non-vacuity guard. A
renamed fixture marker would have kept them green on a rotted premise. The
strict pin is self-diffing and was verified load-bearing on its own:
pointing a known-gap marker at a resolving shape fails it with the two
newly-present ids listed. The file header now carries the gap's durable
description.

**The ascent-note mock takes options objects.** `descentExec` and `run`
had grown to five and seven positional parameters in the order five agents
added them, so call sites read `run(FILE, true, null, 3, false, undefined,
null)` — several carrying `undefined` purely to reach a later argument. All
34 call sites are converted; nine that used only defaults are now bare
`run(file)`. No knob renamed — they are orthogonal and correctly named.
Code lines are exactly neutral (353 -> 353); the win is at the call sites.

Also refreshes five comments that still described `calleesReturnFlowingSeen`
and the two-branch note, both of which the preceding commit replaced.

102 unit and 10 integration tests pass; test count moves 9 -> 7 in the
chained-receiver file, exactly the two redundant rows.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(mcp): publish return-value-ascent coverage on the PDG impact result

`impact(mode:'pdg')` computed four facts about ascent coverage and used
them exactly once — to interpolate an English sentence. They never reached
the result object, so an agent consuming this MCP output could only ask
"was the ascent complete, and if not why" by regexing prose. The cost was
already demonstrated: a pure rewording commit earlier in this branch broke
~30 assertions and would have silently broken any consumer keying on the
old phrase.

Adds `pdgEvidence.ascent`:

    referencesScanned        how many call-site callee references were scanned
    returnFlowFound          did the ascent fire anywhere in this slice
    undecodableSummaryCount  summaries the codec could not decode
    examinedComplete         was the examined set the whole callee list
    incompleteReasons        'traversal-truncated' | 'callee-list-capped'
    callSummaryLayerPresent  false => pre-FU-C (v3) index

Nested under `pdgEvidence` because that is the established counts-and-
classification namespace, and `composeUnifiedPdgImpactResult` already
spreads it, so the member survives the unified compose untouched.

`incompleteReasons` carries CODES, following the existing
`truncatedByReasons: ('depth'|'limit')[]` precedent. The prose clause and
the structured field now render from one array computed once, so an agent
branching on codes and a human reading the note cannot disagree, and a
third reason becomes a rendering decision rather than a contract change.

Two shape decisions worth recording. `callSummaryLayerPresent` exists
because without it a v3 index publishes `referencesScanned: N,
returnFlowFound: false`, which reads as "these callees record no
return-flow" when the truth is "the layer that records it is absent" — the
note already distinguishes those, and the structured surface must not be
less honest than the prose. And the field is ABSENT rather than zeroed when
the descent never ran (upstream slices): "nothing was scanned" is a
different fact from "we scanned and found nothing".

`pdgResultVersion` stays 2. The documented trigger is a BREAKING change to
the result shape; this removes nothing, renames nothing, and changes no
existing field's meaning. Confirmed mechanically: zero top-level key drift
across 2304 cases. The historical v2 bump was for changing an existing
field's semantics (startLine 0- to 1-based).

The note prose is byte-identical, proven across the same 2304 cases with a
negative control — perturbing one character of the phrase table produces 60
drifts, so the harness demonstrably detects what it asserts. 14 new tests
cover the structured surface and all 14 fail when the source is reverted,
while the 54 prose tests pass unchanged.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(helpers): share one module-load probe, and fix two guards that passed on broken builds

Three tests independently spawned a child node process to inspect what a
built `dist/` entry loads, duplicating the REPO_ROOT derivation, the probe
source, the missing-dist guard, the spawn with NODE_OPTIONS cleared, the
status-vs-signal rendering, and the payload parse. The newest copy was also
the only correct one, so the next author had 2-in-3 odds of copying a
weaker probe.

The two older probes diff `require.cache` only, which is structurally blind
to the first-party ESM `dist/**` graph. That is not theoretical — both were
demonstrated passing on genuinely broken builds:

  - Severing `dist/cli/mcp.js -> stdio-context.js` (a pure ESM change)
    leaves the require.cache diff EMPTY, so `import-closure.test.ts`'s two
    assertions reduce to `[].filter(...) === []`. It reported 2 passed on a
    severed graph.
  - Severing `registry -> swift/query.js` leaves 76 unrelated CJS entries,
    which satisfied `registry-import-closure.test.ts`'s indirect guard. The
    Swift half of its headline had gone vacuous and it reported 1 passed.

Both now fail on those same builds, naming the missing anchor.

`test/helpers/module-load-probe.ts` unions the ESM `registerHooks({ load })`
channel with the cache diff, probes entries concurrently, and makes
non-vacuity STRUCTURAL: `anchor` and `minModules` are required fields and
the helper throws when either fails. A vacuous probe is a harness failure,
not a silently green test, so it cannot be forgotten. Forbidden patterns
and remedy text stay per-test — the harness is the shared part, the policy
is not.

Also fixes `toRepoRelativePosix` resolving non-absolute specifiers against
`process.cwd()`, and dedupes modules a CJS-from-ESM import reported once
per channel.

Faster despite doing more: the registry file goes 12.4s -> 6.75s, because
`spawnSync` burned the parent thread polling while the child loaded native
grammars. `import-closure` drops to one spawn from two.

The `local-backend.js` entry is kept although its closure is currently a
strict subset of `server.js`'s: that is an observation, not an invariant.
If `server.js` ever stops eagerly reaching the local backend, the server
probe stays green while the module #2802 actually changed goes unobserved —
and now that anchors are mandatory, that entry is what pins `pdg-impact.js`.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(lbug): trim the csv-generator note and fix the claim it got wrong

Two reviewers split on this comment: one wanted it cut to the structural
argument, the other said a comment is the right depth for documenting a
rejected change since there is no invariant to guard. Both are right, so
it stays a comment and gets shorter — 13 lines to 6.

Trimmed because it had already taken two corrections (an unreproducible
"~40x" figure, and a pointer to a test file that no longer exists), and its
tail had drifted from its own guard: the comment said "several hundred
modules, ~150 ms" where `startup-language-closure.test.ts` says "~226
extra modules and ~130 ms". Two numbers for one fact. That tail is
documented better in the guard's own header, so deleting it loses nothing.

It also stated the load-bearing claim inaccurately. The old text said
bm25-index imports `normalizeFtsText` "from here" — but `lbug-adapter.ts`
neither exports nor re-exports it; the only occurrence of the identifier in
this file WAS the comment. Anyone verifying would have grepped, found
nothing, and concluded the note was stale. Now names `csv-generator.js`
explicitly, re-verified at `bm25-index.ts:15` (static) and
`local-backend.ts:2756` (dynamic, on the FTS query path).

Comment-only, proven two ways: every changed line matches a comment
pattern, and stripping all `//` lines from HEAD and from the working tree
yields byte-identical text.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(helpers): extract the temp-repo lifecycle, collapsing five hand-rolled cleanups into one

Four cfg integration tests each hand-rolled a `tmpDirs` array, a
mkdtemp-and-register step, and an `afterAll` rmSync. It is actually five
registrations across six creation sites — `pipeline-pdg.test.ts` keeps a
second pool for its C-family fixtures.

Seeding genuinely varies four ways (recursive cpSync, single copyFileSync,
inline mkdir+writeFile, and nothing at all), so a fixture-copier helper
would have fitted about half the sites and made things worse. Extracted the
LIFECYCLE instead — mkdtemp, register, afterAll cleanup — which is
byte-identical at all five registrations and is the correctness-critical
part. `dir()` returns an empty registered directory for callers that seed
themselves; `fromFixture()` covers the common case. That fits 6/6.

The duplication had already produced a latent defect: `cFamilyTmpDirs` was
cleaned by TWO `afterAll` blocks, harmless only because `rmSync` was called
with `force: true`. Now one hook.

`createTempDirPool` is a function called from each test file's module scope
rather than a top-level hook in the helper, because under ESM caching a
module-level `afterAll` would register once, against whichever file
imported it first. That hazard is documented in the helper.

Raw line count is roughly neutral (-44 across the tests, +62 for the
helper, 29 of which are the rationale). The win is that a cleanup invariant
went from five copies to one.

Cleanup verified empirically, including the failure path: a throwaway suite
whose `beforeAll` throws still has its directory removed, and every
temp directory created by the four migrated files is gone after a run.
46 tests pass across the four files.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(resolvers): pin the inference-typed field receiver gap at the resolver level

The gap was pinned only in a PDG test, asserting on `BasicBlock.calleeIds`
behind the full `--pdg` pipeline. But it is a resolver fact: when a class
field's type must be inferred from its initializer, chained receiver calls
resolve to nothing. Whoever closes it will be working in the resolver
suite and would have got a red CFG/PDG test with no resolver-side signal.

Asserts CALLS edges directly, alongside `python-constructor-field-receiver.test.ts`.
Nine receiver shapes run the identical statement; seven resolve, two do not:

    const o = new Outer()                     resolves
    private p: Outer = new Outer()            resolves
    private p: Outer;  this.p = new Outer()   resolves
    private p: Outer;  this.p = p  (ctor arg) resolves
    constructor(private p: Outer) {}          resolves
    makeOuter().inner().compute()             resolves
    o.inner().mid().compute()  (three links)  resolves
    private p = new Outer()                   NO EDGES
    private p;  this.p = new Outer()          NO EDGES

Two things the fixture establishes that the PDG-side pin could not. The
discriminator is the type ANNOTATION, not local-versus-field — the
parameter-property form resolves fine. And the initializer is NOT invisible
to the resolver: `new Outer()` still emits its own constructor CALLS edge,
byte-identical to the annotated twin. Only the initializer-to-field-type
binding is missing, which narrows where a fix belongs.

Assertions key on exact node ids rather than names, because `compute` is
ambiguous across two classes and keying on the source name collides with
`Object.prototype.constructor`.

No `describe.skip` and no `it.fails` — the latter passes when the body
throws for ANY reason, so it can go green on a rotted premise. The gap is
pinned as its explicit current value, which self-diffs: simulating the fix
fails one test showing the two newly-resolved ids, and renaming a fixture
symbol fails the non-vacuity guard.

Runtime is comparable to the PDG-side pin (~9-11s, both dominated by
worker startup), so this is an altitude and scope win, not a speed one.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): replace the extension sweeps with a stronger language-agnosticism pin

Two `it.each` sweeps over nine file extensions asserted that the
empty-ascent caveat was present (or absent) for each. They looked like the
pin for the property the whole change exists for — `pdg-impact.ts` must
name no language and its output must not vary by extension — but they were
the weakest available form of it.

They asserted substring presence/absence, so a language dependence that
ADDS text while leaving the caveat intact passes them. Demonstrated, not
assumed: injecting a `.py`-only hedge inside the caveat sentence and
replaying the two sweeps verbatim against that source gives 18 passed. The
byte-identity test beside them caught it.

So the sweeps are deleted and the identity test carries the property alone,
hardened in two ways:

  - Two rows instead of one, covering BOTH sides of the caveat gate. The
    silent (return-flow present) branch previously had no identity
    counterpart at all — nine runs proving one fact, with nothing checking
    that its rendering was extension-invariant.
  - The fingerprint spans the note AND the reachable blocks, not just the
    note. Strictly more than the sweeps verified.

Entailment is exact: identity across the extension set, plus the two
existing single-extension content assertions, gives "every extension gets
the caveat" and "no extension gets it". Reducing a sweep to one extension
was rejected because it reproduces an assertion already present verbatim.

Also converts the incompleteness block from six near-identical bodies to a
3-row premise table crossed with two assertions. Each row now names the
exact phrase set its clause must contain, so presence and absence are
asserted together — which adds three checks the longhand version lacked
(the budget row now also proves the emit-cap phrase is absent). And three
tests that re-rendered one fixture to make one assertion each are hoisted
to a single render.

97 tests, down from 116: -18 sweep cases, -2 from the hoist, +1 identity
row. No assertion was lost; several were added.

Verified by injection: a `.py`-only note change fails the identity pin,
and a dependence in the shared hop sentence fails BOTH rows, confirming the
second row is load-bearing rather than decorative.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(mcp): lazy-import syncGroup so MCP startup skips the group extractor closure

`core/group/service.ts` statically imported `./sync.js`, which pulls all six
contract extractors, five of which statically import the native `tree-sitter`
binding. That put the whole parser stack on every MCP server start, for a
server that never syncs.

Only `groupSync` needs it. The other seven group tools — `group_list`,
`group_impact`, `group_query`, `group_contracts`, `group_status`,
`group_trace`, `group_context` — do not, and now never load it. `syncGroup`
has a single call site, already inside an `async` method, so this is a lazy
`await import(...)` at that call site and nothing else: no signature change,
no async ripple, no change to `local-backend.ts`.

The pattern is already established on this exact module — `cli/group.ts`'s
sync command lazy-imports `sync.js` the same way. `service.ts` was the
outlier.

Measured on a native filesystem (overlayfs; /workspace is a 9p mount that
inflates ESM resolve, so it is not a valid measurement surface), 5 cold runs,
medians:

  dist/mcp/server.js              521 ms -> 133 ms   (-75%)
  dist/mcp/local/local-backend.js 453 ms -> 66 ms    (-85%)
  tree-sitter modules at both entries: 11 -> 0

Same defect class as #2802, which cut the language-provider registry from the
same startup path; this is what remained.

The cost is moved rather than deleted: the first `group_sync` call now pays
the module load. That is the right trade — `group_sync` is already a
long-running operation, and sessions that never sync pay nothing.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): guard MCP startup against the group extractor closure returning

Sibling forbidden-pattern case in the #2802 startup guard, reusing the
concurrent probes it already collects — no new spawn, no new harness.

Asserts that none of `dist/mcp/server.js`, `dist/cli/mcp.js`, or
`dist/mcp/local/local-backend.js` loads a `core/group/extractors/` module or
the native `tree-sitter` package. The parser is matched by package prefix
rather than a bare substring, so a source file that merely mentions the word
can neither satisfy nor trip it.

Verified load-bearing rather than assumed: restoring the static
`import { syncGroup }` in `core/group/service.ts` and rebuilding turns
`dist/mcp/server.js` red and names all seven offenders —
http-route, grpc, thrift, topic, include, manifest and workspace extractors.
Reverted and re-confirmed green.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(mcp): keep the analyze-only CFG closure off MCP server startup (#2802 review)

`mcp/local/pdg-impact.ts` imported `CALLEES_TRUNCATED_SENTINEL` and
`CALLEE_ID_SEP` from `core/ingestion/cfg/emit.ts`. ESM evaluates a module to
import any binding from it, so those two strings dragged the whole analyze-only
CFG closure into every MCP server start.

Measured against a clean build, per entry point: 8 modules — `emit`,
`reaching-defs`, `reaching-defs-graph`, `control-dependence`, `post-dominators`,
`synthetic-escape`, `call-site-harvest`, `reaching-def-reason-codec` — present at
`dist/mcp/server.js`, `dist/mcp/local/local-backend.js` and
`dist/mcp/http-transport.js`.

Same defect class as the language-provider closure this branch already removed,
and the guard could not see it: `FORBIDDEN_RE` covers `core/ingestion/languages/`
and `FORBIDDEN_GROUP_RE` covers `core/group/extractors/|node_modules/tree-sitter`,
neither of which matches `core/ingestion/cfg/`.

The format constants move to a new LEAF module `cfg/callee-cell-format.ts` that
imports nothing; `emit.ts` re-exports both names so every existing importer is
untouched, and producer and consumer still resolve to one definition — the drift
the shared constant exists to prevent stays impossible.

Deleted, not deferred — the same bar #2802 held its own csv-generator proposal
to. After: cfg modules at startup 8 -> 2, and both survivors
(`callee-cell-format`, `reaching-def-reason-codec`) are leaves that import
nothing. Totals: `server.js` 387 -> 380, `local-backend.js` 163 -> 156,
`http-transport.js` 523 -> 516.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mcp): stop pdgEvidence.ascent claiming a completeness it cannot have (#2802 review)

`examinedComplete` is the field a consumer reads to decide whether
`returnFlowFound: false` is a whole-slice claim. It could be published `true`
over a callee set the descent never finished examining — the exact false
all-clear the field was added to prevent.

Root cause: `bfsReachableBlocks` sets `truncatedByDepth` when its frontier is
still non-empty at the budget, but both call sites inside `interproceduralDescent`
folded only the row-limit flag and dropped the depth flag. The top-level intra
BFS's copy of that same flag was already propagated, so the asymmetry was
unintended — one `if`-pair folding limit-but-not-depth, within a merge that
already folds the node cap too.

Reproduced at `maxDepth: 3`, the shipped default: a criterion calling a helper
whose body is a 5-block dependence chain, with the return-flowing callee on the
block past the clamp. Result reported `truncated: undefined`,
`examinedComplete: true`, `incompleteReasons: []` and an unqualified universal
note sentence.

Fixed by propagating the dropped flags rather than inventing a parallel channel:
`intraDepthBudget` is documented in-file as the SAME clamp the top-level intra
BFS applies, and that one's depth truncation is already result-level. So the
result's own `truncated`/`truncatedBy` were under-reporting for the same reason,
and both surfaces are corrected together.

Four further honesty fixes to the same published record:

- Blocks reached only by the U-C4 ascent went into `reachable` but never
  `hopReached`, so their `calleeIds` cells were never scanned, never counted, and
  could not raise `callee-list-capped`. They are slice blocks; they now enter the
  hop set and get the same treatment as every other one.
- `pdgEvidence.ascent` was absent on the empty-slice early return even though the
  descent had already run and scanned, contradicting the "present iff the descent
  ran" contract this branch itself added to `tools.ts`. Both exits now classify
  through one shared helper so they cannot disagree.
- A block carrying call sites in `callees` but no resolved ids in `calleeIds`
  (the whole-file case where `emit.ts` has no fileMap) silently shrank the
  population while `examinedComplete` still reported `true`. That now raises a
  third reason, `callee-ids-unrecorded`.
- `referencesScanned` is a distinct-callee tally and both surfaces described it as
  a call-site count. Field name kept — a rename is breaking at
  `pdgResultVersion: 2` — and the prose corrected instead.

`PdgAscentIncompleteReason` gains a member, which is additive, so
`pdgResultVersion` stays 2. Visible output change worth knowing: slices whose
callee chain outruns `maxDepth` now report `truncatedBy: 'depth'` where they
previously reported none, and a repo with id-less call sites now reports
`examinedComplete: false`. Both are strictly more honest.

Every behavioural change carries a mutation proof — revert the source, watch the
new test go red, restore. One exception is documented inline rather than faked:
the ascent-side fold cannot be observed independently, because the re-seed shares
the caller's `visited` set and so can only reach past the budget when the
traversal that covered that closure was already cut and had already raised a flag.

Suite: 49 -> 59 tests.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): anchor each import-closure policy on the edge it polices (#2802 review)

`module-load-probe.ts` makes non-vacuity structural via a required `anchor` — but
the anchor was one per ENTRY while `startup-language-closure.test.ts` now runs TWO
independent policies. The group-extractor policy added in 83e8cf7c5 therefore had
no anchor of its own, and one of its three rows was already vacuous: `cli/mcp.js`
loads four leaf modules and reaches no `core/group/` module at all, so its group
assertion could not fail for any policy-related reason while its
`dist/mcp/stdio-context.js` anchor stayed green.

Proven, not argued. `dist/mcp/local/local-backend.js` is the only static importer
of `core/group/service.js` in the whole build; severing that one edge — the exact
next lazy-load step — and re-probing:

  OLD shape (anchor per entry):  server 385, http-transport 521, local-backend 161
                                 reaches group/service = false, group offenders 0
                                 -> GREEN on all three
  NEW shape (anchor per policy): -> RED on all three, each naming the missing
                                    dist/core/group/service.js

Counts fell only 387->385 and 163->161, so `minModules` was structurally blind to
the severance; the anchor is the only thing that catches it.

`anchor` accepts `string | readonly string[]` and every listed anchor must load.
Existing single-anchor call sites are unchanged. `anchorsOf()` lets the group
`it.each` DERIVE its entries by filtering on the group anchor, with a test pinning
that derivation, so the policy cannot silently register zero cases. `cli/mcp.js`
is dropped from the group policy — it cannot honestly carry that anchor — and the
doc-comment now states the invariant: an anchor is per-POLICY, not per-entry.

Also:

- `mcp/http-transport.js` gets a row. It is the largest startup entry (516
  modules) and `src/cli/mcp.ts` imports it directly rather than through
  `server.js`, so nothing about the server row constrained it. Measured clean
  today; the gap was coverage, not a broken claim.
- The three spawn-based closure tests are registered in `SPAWN_CLI`, so the
  Windows-safety plumbing this branch wrote for them (POSIX normalisation,
  `pathToFileURL`, `NODE_OPTIONS` clearing, array-form `spawn`) is finally
  exercised on the Windows/macOS matrix. Measured cost ~11.7s on Linux; budget
  ~60s on Windows against a 25-minute job.
- `PROBE_TARGET` now wins over `extraEnv`, which was spread last and could have
  silently redirected a probe while `anchor`/`minModules` stayed keyed on `entry`.
- The child's JSON payload is validated through a type predicate instead of a bare
  `as string[]`, and the spawn timeout escalates SIGTERM to SIGKILL so a child
  stalled in native code is reaped rather than orphaned.
- Recorded baselines re-measured (server 380, local-backend 156, cli/mcp 4) and
  relabelled a snapshot rather than a contract — they moved twice inside this
  branch alone. The subset claim was re-verified exactly: 0 of local-backend's 156
  modules are absent from server's 380.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(helpers): survive a failing temp-dir removal instead of leaking the rest (#2802 review)

`createTempDirPool`'s `afterAll` ran a bare `for (const d of created) fs.rmSync(d, {recursive, force})`.
`force` suppresses only `ENOENT` — not the `EBUSY`/`EPERM`/`ENOTEMPTY` class a
Windows runner produces when a pipeline test still holds a handle — so the FIRST
failure threw out of the loop and leaked every directory registered after it.

Pre-existing: all four hand-rolled cleanups this helper consolidated had the same
shape. But the blast radius is now shared across four consumers, which is exactly
why it is worth fixing at the point of consolidation.

Cleanup is now per-directory best-effort via `removeTempDirs`, plus Node's own
documented mitigation for that error class (`maxRetries: 3, retryDelay: 50`),
which costs nothing on the happy path.

Warn rather than swallow or rethrow, and the reasoning is in the doc comment, not
just here: rethrowing would fail an otherwise green suite from `afterAll` over
housekeeping the OS reclaims anyway, where it reads as a test failure and buries
the real result — a Windows EBUSY on a temp dir is not a defect in the code under
test. Silence is the opposite hazard: a systematic leak would be invisible with
nothing naming the responsible suite. The warning carries the path, and the
`mkdtemp` prefix is per-pool, so it names the suite that made it.

Failure is injected through a scripted remover keyed by path (a Map lookup, so no
`if` in a test body and no dependence on producing a real locked handle). Beyond
the three behavioural pins there is a wiring pin — a nested `describe` creates a
real pool and a sibling `it` declared after it asserts the dirs are gone — so the
tested function cannot drift into "tested helper plus an untested copy of the
loop".

Mutation proof: restoring the abort-on-first-failure loop turns 3 of the 5 tests
red, the throw escaping `removeTempDirs` outright so the third real directory is
never attempted. With the fix, `[first, blocked, last].map(existsSync)` is
`[false, true, false]` — the injected failure survives and the directory after it
is really gone, through the remover that actually ships.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(pdg): point the self-diffing receiver pins at #2807, not at this PR (#2802 review)

Both pins named the gap "(#2802 follow-up)". The gap has its own tracking issue —
#2807, "Inference-typed field receivers resolve to no CALLS edges at all" (open,
labeled bug) — and PR #2810 is already open against it. As written, after merge
the gap was discoverable only by reading a KNOWN GAP marker inside a test file,
not from the issue tracker.

Both describe names now read "(known gap: #2807)" and both KNOWN GAP test names
carry the number. #2802 is kept only as provenance: the gap was FOUND during
#2802 work but is pre-existing and independent of it.

Each header gains an explicit "this pin is self-diffing: it will go red on
purpose" section naming #2807 with its exact title, noting #2810 is open against
it at the time of writing, and stating that the pin asserts the gap EXISTS — so
closing #2807 fails it by design, and the correct response is to update the
expected value, not to relax the assertion. The same note is repeated inline
above each KNOWN GAP test, where a maintainer editing it will actually see it.

No pin is weakened. Both deliberately reject `it.fails` in favour of exact
`toEqual` assertions with a non-vacuity probe, and that design is left untouched.

Refs #2802, #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(group): cover the lazy syncGroup import that no test reached (#2802 review)

9ea9676dc turned `GroupService.groupSync`'s `syncGroup` into
`await import('./sync.js')` — this branch's one changed control-flow line in
production code — and nothing exercised it. Every existing test stopped short:
`service.test.ts` returns at the empty-name guard; `group-service-not-found.test.ts`
mocks `loadGroupConfig` to reject and never invokes its `syncGroupMock`;
`group-sync.test.ts` imports `syncGroup` directly, bypassing `GroupService`; and
the startup guard asserts only the negative, that `sync.js` is absent at startup.
`tsc` catches a path typo, but nothing verified the import resolves and hands off
correctly — while every production `group_sync` call goes through that line.

No production change was needed; the reviewed design was sound. This is the
missing coverage.

The happy-path test mocks nothing: it points `GITNEXUS_HOME` at a pool temp dir,
seeds a real `group.yaml`, and calls `groupSync`, so `loadGroupConfig` resolves,
`groupDir` is found, and execution falls through into the REAL `syncGroup`. What
makes a real sync reachable with no indexed repo: an empty registry puts both
members in `missingRepos`, but one declared manifest link still yields
synthetic-UID contracts. It asserts the returned counts AND reads back the
`contracts.json` that real `syncGroup` wrote into `groupDir` via the production
`readContractRegistry`, which pins the option handoff too.

Two further tests use `vi.doMock` to re-evaluate the service against a `sync.js`
whose load throws: one asserts the call rejects with the load failure in its
`cause` chain — so the caller gets a catchable rejection, not a floating
unhandled one — and one asserts both pre-import guards still answer with
`sync.js` unloadable, which is also a structural pin that the module has no
STATIC import of it (a static one would throw at re-import, before any call).

Mutation proofs: pointing the specifier at `./sync-nope.js` turns 2 of 3 red
("Cannot find module .../sync-nope.js ... at GroupService.groupSync
service.ts:349"); aliasing a real-but-wrong export turns 1 red. Restored, all 3
green, and `service.ts` verified byte-identical to HEAD.

Out of scope, stated rather than glossed: the final `isError: true` MCP envelope
is produced above `GroupService` and needs a full `LocalBackend`; the rejection
test is the in-scope half of that claim.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(mcp): close the gaps a cleanup pass found in the #2802 review fixes

Quality pass over the review-response series (reuse / simplification /
efficiency / altitude). No behaviour change except where noted.

The two that mattered:

- **The cfg/emit fix had no guard.** `FORBIDDEN_RE` covers
  `core/ingestion/languages/` and `FORBIDDEN_GROUP_RE` covers
  `core/group/extractors/|tree-sitter`; neither matches `core/ingestion/cfg/`.
  Because `emit.ts` re-exports the constants, pointing `pdg-impact.ts` back at
  `cfg/emit.js` typechecks identically and silently restores all 7 modules.
  Verified: with the import reverted, `tsc --noEmit` still exits 0 and every
  test stayed green before this commit; after it, 3 rows go red naming the
  offenders. Written as an ALLOWLIST of genuine leaves rather than a denylist of
  the 7 already-suffered modules, because the next regression is a module nobody
  has thought of yet.

- **`FORBIDDEN_GROUP_RE`'s parser matcher was forward-slash only** while both
  sibling probe regexes spell the separator `[\\/]`. Native bindings arrive via
  the `require.cache` channel as absolute paths and `toRepoRelativePosix` only
  normalises paths inside the repo root, so a hoisted `node_modules` renders as
  `…\node_modules\tree-sitter\…` on Windows and matched nothing. The same series
  put this file on the Windows matrix, where that half of the assertion would
  have been vacuous.

Reuse — three re-implementations of existing helpers:

- `removeTempDirRecursive` re-rolled `fs.rmSync` retries; it now delegates to
  `cleanupTempDirSync` (`test-db.ts`), the repo's Windows-lock-aware remover.
  The copy had already drifted on both knobs that matter — 3 retries at 50 ms
  vs 5 at 100–400 ms, and warn-on-everything vs swallow-lock-codes-rethrow-rest
  — which is how one half of a suite goes green-with-a-warning on the same
  `EBUSY` the other half fails on. The per-directory try/warn loop, which is the
  actual fix, is unchanged.
- `errorChainText` re-rolled the cause-chain walk that `causeChain`
  (`src/lib/utils.ts`) exists to be the single copy of — its own doc asks
  callers not to.
- The SIGKILL escalation (a timer, an `unref`, and two `clearTimeout`s) is
  `spawn`'s own `killSignal` option, which Node's `timeout` already delivers.

Simplification and altitude:

- `'callee-ids-unrecorded'` documented ONE of its three producer paths. The
  unnamed common one is a call site that did not RESOLVE — exactly the
  receiver gaps this repo pins (#2807) — so on a real index the reason fires
  broadly, driven by resolution quality rather than a missing `--pdg` layer,
  and "re-run analyze --pdg" is the wrong remedy for it. Doc now names all
  three and states the consequence: `examinedComplete: true` is the strong,
  rare signal.
- The derived policy-entry list was re-pinned against a hand-written 3-element
  literal, reinstating one layer down the list the derivation removes. Now
  asserts the properties that are actually at risk — non-emptiness (a policy
  going silent) and `cli/mcp.js` staying excluded (a row that cannot fail).
- A test fixture spread `ascentBlockCell: 'idless'` and then overrode it to
  `'capped'` in both runs, so the id-less shape never reached the mock while
  reading as though it did.
- `idlessCallSites` is sticky, so its per-row string allocation now
  short-circuits once set.
- Dropped an unused `export` on `CleanupWarner`.

Refs #2802

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* revert(ci): unregister the module-load closure guards from the Windows matrix

Registering the three `dist/` closure guards in `SPAWN_CLI` turned the Windows
`platform-sensitive 1/3` shard red at the 20-minute watchdog. Baseline
83e8cf7c5 was green on all three shards; a4245119c (which added them) failed
1/3; d0b201442 failed the same way.

It is not the files themselves. On the Windows runner they are among the
cheapest in the suite — `registry-import-closure` 448 ms, `import-closure`
53 ms — and both passed. vitest shards this list by file COUNT, not runtime, so
adding three files RESHUFFLED the split: shard 1 went to 32 files against 26 and
29, concentrating the heavy CLI e2e suites. It timed out with `cli-e2e`,
`group/cross-trace-e2e`, `lbug-orphan-sidecar-recovery` and `server-http-startup`
still queued — `cli-e2e` being the ~50-spawn suite whose setup flakiness already
needed fixing once (PR #2000).

That clustering fragility is pre-existing and this file's own header documents
it (#2449: "the heaviest spawn suites can cluster on one shard", busiest Windows
shard already at 14m57s against the old watchdog). These three files only tipped
it over, and unblocking the PR beats holding it for a CI-infra fix that belongs
in its own change.

Reverted rather than worked around: raising the shard count would keep the
coverage but is a repo-wide CI change made on a 25-minute feedback loop with no
guarantee the reshuffle balances, and this PR is about MCP startup. The removed
entries are replaced by a comment recording WHY they are absent, what they were
measured to cost, and the precondition for re-landing them — so the gap is
documented at the point someone would otherwise re-add them blind.

Verified: the emitted file list is byte-identical to 83e8cf7c5's, so the shard
split returns to the configuration that was green.

The Windows-specific bug this series found is unaffected — `FORBIDDEN_GROUP_RE`
now spells its separator `[\\/]` like its siblings, which was a real
forward-slash-only vacuity, and that fix stays.

Refs #2802, #2449

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): shard the cross-platform matrix by measured weight, not file count

Restores the three `dist/` module-load closure guards to the Windows/macOS
matrix, and fixes the reason they could not stay there.

They must run on every OS — the shared probe in `test/helpers/module-load-probe.ts`
IS the platform-varying code (array-form `process.execPath` spawn, cleared
NODE_OPTIONS, `pathToFileURL` because Windows rejects a bare absolute path as an
ESM specifier, and a `path.sep`→POSIX normalisation the anchors and offender
regexes depend on). Ubuntu-only coverage of a platform guard is no coverage.

The earlier attempt turned Windows `platform-sensitive 1/3` red at the 20-minute
watchdog, and the reflex fix — unregistering them — treated the symptom. The
files are among the cheapest in the suite (measured 448 ms, 53 ms, sub-second,
and both that completed passed). The defect is that `run-cross-platform.ts`
handed vitest all 84 files plus `--shard=i/n`, and vitest partitions by file
COUNT. Runtimes here span three orders of magnitude, so a count-split is blind
to the thing that decides the budget, AND re-partitions on every insertion:
adding three free files reshuffled the list and happened to co-locate `cli-e2e`
(361 s) with `cli-limit-e2e` (75 s) and `analyze-heap-oom-e2e` (23 s) — 32 files
against 26 and 29 — which timed out with four still queued.

The split now happens in `scripts/cross-platform-shard.ts`, longest-processing-
time first over measured Windows runtimes, and only the chosen shard's files are
passed to vitest (`--shard` is consumed, never forwarded — forwarding would
re-partition the slice a second time and silently drop most of it).

Weights are measured, from the last green matrix run plus the timed files of the
failing one, and every file also carries an 8 s per-file floor. That floor is
calibrated, not guessed: the last green busiest shard ran 736 s of wall clock
over ~511 s of attributed file time. Without it the balancer isolates the two
monsters and then piles every light file onto the remaining shards — trading a
runtime imbalance for a count imbalance that costs the same.

Result at TOTAL=3, with the three guards back in: 521 s / 527 s / 519 s across
20 / 33 / 34 files. The previous green configuration's busiest shard was 736 s,
so this is better balanced than the state before any of this, and the busiest
shard is now bounded by construction rather than by sort-order luck.

`test/unit/cross-platform-shard.test.ts` pins the properties, and the
load-bearing one is not "the split is even" — it is "adding a cheap file cannot
move a heavy one", the property whose absence caused the outage. Two details in
that test are themselves load-bearing, and earlier drafts got both wrong and were
vacuous: the inserted names must sort EARLY (names sorting last disturb nothing
under any scheme) and the count must not be a multiple of the shard total
(adding exactly `total` files leaves an equal-weight round-robin in the same
rotation). Mutation-proved: replacing `weightOf` with a constant — i.e.
count-based sharding — turns that test and the per-file-floor test red; restored,
all 8 pass.

Refs #2802, #2449

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:26:13 +01:00

1117 lines
56 KiB
TypeScript

// U7 — when the CALL_SUMMARY layer is present but none of the callees the slice
// resolved carries a return-flow summary, the impact note says the ascent was
// structurally empty instead of letting the omission read as "ascent ran and
// found nothing".
//
// #2802 — the note keys on the PERSISTED SUMMARIES, never on the criterion's
// language. `pdg-impact.ts` names no language and imports nothing from the
// language layer. The tests below pin that: the note flips on CALL_SUMMARY
// content while the file extension is held constant, and is identical across
// extensions while the CALL_SUMMARY content is held constant.
import { describe, expect, it } from 'vitest';
import {
runImpactPDG,
type PdgAscentCoverage,
type PdgAscentIncompleteReason,
type RunPdgImpactDeps,
} from '../../src/mcp/local/pdg-impact.js';
import { encodeCallSummary } from '../../src/core/ingestion/taint/call-summary-codec.js';
import { CALLEES_TRUNCATED_SENTINEL, CALLEE_ID_SEP } from '../../src/core/ingestion/cfg/emit.js';
/**
* What the mock's CALL_SUMMARY query returns for `helper`:
* - `null` — no CALL_SUMMARY row at all (a callee whose summary was never
* persisted);
* - `'params'` — a real `encodeCallSummary` wire string (the codec is the
* producer, so the round trip is genuine);
* - `'raw'` — a `reason` cell verbatim, used for the UNDECODABLE cases the
* codec must reject without throwing.
*/
type Summary =
| null
| { readonly kind: 'params'; readonly params: readonly number[] }
| { readonly kind: 'raw'; readonly reason: unknown };
const flow = (params: readonly number[]): Summary => ({ kind: 'params', params });
const raw = (reason: unknown): Summary => ({ kind: 'raw', reason });
// The one block reachable ONLY through the U-C4 return-value ascent: the caller
// continuation re-seeded FROM the call block once a callee's CALL_SUMMARY
// licenses the ascent. Its presence in `reachableBlocks` is the direct
// observable of "the ascent fired"; its absence, of "the ascent was withheld".
const ascentOnlyBlock = (file: string): string => `BasicBlock:${file}:1:0:9`;
// The one callee id in the mock's `calleeIds` cell that RESOLVES to a span: a
// `Function` node, which is what `resolveCalleeSpans` matches, so the descent
// enters its body. Every other id a test puts in the cell (P3-7 below) is
// deliberately un-enterable.
const helperCalleeId = (file: string): string => `Function:${file}:helper`;
// P2-5 — the SECOND, DISTINCT callee. It is named ONLY in the `calleeIds` cell of
// `helper`'s own body block, so the descent has to cross a second call boundary
// before it ever sees the id. That is the only shape under which the cross-hop
// accumulators (`calleeReferencesSeen`, and the sticky `anyReturnFlow`) do any
// work — a cell carrying several ids is still one hop, and one hop cannot tell an
// accumulation apart from an overwrite.
const secondCalleeId = (file: string): string => `Function:${file}:helper2`;
// The two callees' 0-based symbol spans. `blockAnchorForResolvedSymbol` binds
// `$symStart = startLine + 1` on the RANGE-anchored per-callee seed fetch, so the
// spans are what route that fetch to each callee's own body block — the descent
// never asks for a body by callee id, only by span.
const HELPER_SPAN = { startLine: 4, endLine: 6 } as const;
const SECOND_SPAN = { startLine: 8, endLine: 10 } as const;
const symStartOf = (span: { readonly startLine: number }): number => span.startLine + 1;
// `helper2`'s body seed — the direct observable of "hop 1 reached new ground",
// which is what separates a genuine second hop from a wider first one.
const secondCalleeSeedBlock = (file: string): string =>
`BasicBlock:${file}:${symStartOf(SECOND_SPAN)}:0:0`;
// P1 — `helper`'s body as a straight dependence CHAIN hanging off its seed block
// (`calleeSeed` → C1 → C2 → C3 → C4, one dependence level per link). The
// per-callee BFS runs under the same depth clamp as the top-level intra pass, so
// at the production default `maxDepth: 3` it spends its whole budget on C1..C3 and
// never reaches C4. That is the only shape in which a callee's OWN traversal, and
// nothing else, is what stops the slice.
const CALLEE_CHAIN_LENGTH = 4;
const calleeChainBlock = (file: string, step: number): string =>
`BasicBlock:${file}:${symStartOf(HELPER_SPAN)}:0:${step}`;
// The callee named ONLY in the deepest chain block's cell, carrying a REAL
// `encodeCallSummary([0])` return-flow. It is what makes "was the set examined?"
// observable: reach C4 and `returnFlowFound` flips to true, so a run that reports
// `examinedComplete: true` without reaching it is publishing a false all-clear.
const deepCalleeId = (file: string): string => `Function:${file}:deep`;
// A callee id no fixture gives a span or a summary — it can be SCANNED but never
// entered, so it moves the coverage population without moving the slice.
const hiddenCalleeId = (file: string): string => `Function:${file}:hidden`;
// The mock's knobs — all orthogonal, each with a safe default.
interface DescentOptions {
// What `helper`'s CALL_SUMMARY row holds — the fact the note keys on. Default `null`.
readonly summary?: Summary;
// P2-4 case 2: emit capped this block's `calleeIds` cell, so the cell carries
// the truncation sentinel alongside the ids that survived. `splitCalleeIds`
// strips the sentinel, which is exactly why the dropped callees are invisible
// to the summary scan and the note's counters.
readonly calleeCellCapped?: boolean;
// The OTHER way a block's call sites leave the population: `calleeIdsOfBlock`
// writes an EMPTY `calleeIds` cell for a whole file whose resolved-id map is
// absent, while the sibling `callees` NAME cell still records the call sites.
// An empty cell carries no sentinel, so `calleeCellCapped` cannot report it.
readonly calleeCellIdless?: boolean;
// P3-7 case: the exact id list the call block's `calleeIds` cell carries.
// Defaults to the single enterable `helper`. A test overrides it to mix in ids
// the descent can never enter — `resolveCalleeSpans` matches only
// Function/Method/Constructor, so anything else yields no span and is skipped
// while still riding the cell (and still being scanned for a CALL_SUMMARY).
readonly calleeIds?: readonly string[];
// P2-5 case: what the SECOND, distinct callee's CALL_SUMMARY row holds.
// OMITTED ⇒ no second hop at all (every case above the P2-5 block). Any
// `Summary` — including `null`, meaning "a callee with no CALL_SUMMARY row" —
// puts `helper2` in the cell of HELPER's body block, so the descent reaches it
// only by crossing a second call boundary.
readonly secondSummary?: Summary;
// P1: give `helper` the deep body chain described above.
readonly calleeChain?: boolean;
// The `calleeIds` cell the U-C4 ASCENT-reached block carries. OMITTED ⇒ that
// block has no cell at all (the historical fixture). A block reached only by
// the ascent is still a slice block, so its call sites must join the scanned
// population exactly like a descent-reached block's.
readonly ascentBlockCallees?: readonly string[];
// How that cell was written: `'capped'` ⇒ ids + the emit sentinel, `'idless'` ⇒
// an empty id cell with the NAME cell still populated.
readonly ascentBlockCell?: 'capped' | 'idless';
}
// A mock that drives ONE real inter-procedural descent hop: the criterion's
// reachable block calls `helper`, the descent resolves helper's span (so
// interproceduralHops > 0 and the note block fires).
//
// The dependence BFS is routed by its bound `$frontier` (never by call order),
// so the ascent re-seed FROM the call block is deterministically distinguishable
// from the intra BFS out of the criterion seed.
function descentExec(
file: string,
{
summary = null,
calleeCellCapped = false,
calleeCellIdless = false,
calleeIds,
secondSummary,
calleeChain = false,
ascentBlockCallees,
ascentBlockCell,
}: DescentOptions = {},
): RunPdgImpactDeps['executeParameterized'] {
const seed = `BasicBlock:${file}:1:0:0`;
const callBlock = `BasicBlock:${file}:1:0:2`;
const calleeSeed = `BasicBlock:${file}:${symStartOf(HELPER_SPAN)}:0:0`;
const secondSeed = secondCalleeSeedBlock(file);
const ascentOnly = ascentOnlyBlock(file);
const helper = helperCalleeId(file);
const second = secondCalleeId(file);
const deep = deepCalleeId(file);
const chainTail = calleeChainBlock(file, CALLEE_CHAIN_LENGTH);
const cellIds = calleeIds ?? [helper];
// A `calleeIds` cell exactly as the emitter writes it, plus the sibling `callees`
// NAME cell it always writes alongside. `'idless'` is the shape the emitter
// produces for a file with no resolved-id map — names, no ids, no sentinel.
const cellOf = (
ids: readonly string[],
written?: 'capped' | 'idless',
): { calleeIds: string; callees: string } => ({
calleeIds:
written === 'idless'
? ''
: (written === 'capped' ? [...ids, CALLEES_TRUNCATED_SENTINEL] : [...ids]).join(
CALLEE_ID_SEP,
),
callees: ids.map((id) => id.slice(id.lastIndexOf(':') + 1)).join(' '),
});
const calleeCell = cellOf(
cellIds,
calleeCellIdless ? 'idless' : calleeCellCapped ? 'capped' : undefined,
);
// Both the span resolve and the CALL_SUMMARY scan bind the ids they ask about
// as `$ids`, so the mock answers PER ASKED ID — an id the descent cannot enter
// must not borrow another callee's span or summary, and `helper2` must not be
// answerable until the hop that actually asks for it.
const spans = new Map<string, { readonly startLine: number; readonly endLine: number }>([
[helper, HELPER_SPAN],
]);
const summaries = new Map<string, Summary>([[helper, summary]]);
if (secondSummary !== undefined) {
spans.set(second, SECOND_SPAN);
summaries.set(second, secondSummary);
}
// No span for `deep`: it is scanned for its summary, never entered, so the only
// thing reaching C4 changes is whether that return-flow was EXAMINED.
if (calleeChain) summaries.set(deep, flow([0]));
const askedIds = (params: Record<string, unknown>): string[] => {
const ids = params['ids'];
return Array.isArray(ids) ? ids.map((id) => String(id)) : [];
};
return async (_repo, query, params: Record<string, unknown>) => {
// Top-level seed fetch is line-anchored (`a.startLine = $line`); the descent's
// callee seed fetch is range-anchored — route by that.
// Matches the seed fetch without pinning the clauses after the projection —
// #2787 added `ORDER BY a.startLine, id` between the RETURN and the LIMIT.
if (query.includes('RETURN a.id AS id')) {
if (query.includes('a.startLine = $line')) return [{ id: seed }];
// Range-anchored, so `$symStart` (= the callee span's startLine + 1) is the
// only thing distinguishing the two callees' bodies.
return params['symStart'] === symStartOf(SECOND_SPAN)
? [{ id: secondSeed }]
: [{ id: calleeSeed }];
}
if (query.includes('MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)')) {
const frontier = params['frontier'];
const ids = Array.isArray(frontier) ? frontier.map((id) => String(id)) : [];
if (ids.includes(seed)) return [{ id: callBlock }];
// Only the ascent re-seed (and, at maxDepth > 1, the intra BFS's own next
// level) expands the call block.
if (ids.includes(callBlock)) return [{ id: ascentOnly }];
// `helper`'s own body chain — one dependence level per step, so the callee's
// BFS needs CALLEE_CHAIN_LENGTH levels of budget to walk it all.
for (let step = 0; calleeChain && step < CALLEE_CHAIN_LENGTH; step++) {
const from = step === 0 ? calleeSeed : calleeChainBlock(file, step);
if (ids.includes(from)) return [{ id: calleeChainBlock(file, step + 1) }];
}
return [];
}
if (query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')) {
const asked = askedIds(params);
const rows: Array<{ id: string; calleeIds: string; callees: string }> = [];
if (asked.includes(callBlock)) rows.push({ id: callBlock, ...calleeCell });
// The ascent-reached block's own cell. It is only ever ASKED about once the
// block is in the descent's slice, which is the whole point of the case.
if (ascentBlockCallees !== undefined && asked.includes(ascentOnly)) {
rows.push({ id: ascentOnly, ...cellOf(ascentBlockCallees, ascentBlockCell) });
}
// Hop 1 gathers callees from HELPER's body blocks; that cell — and only that
// cell — carries the second callee, so the second hop cannot be reached by
// widening the first block's cell.
if (secondSummary !== undefined && asked.includes(calleeSeed)) {
rows.push({ id: calleeSeed, ...cellOf([second]) });
}
// The DEEPEST chain block's cell: askable only once the callee's own BFS had
// the budget to reach C4.
if (calleeChain && asked.includes(chainTail)) rows.push({ id: chainTail, ...cellOf([deep]) });
return rows;
}
if (query.includes("r.type = 'CALL_SUMMARY'")) {
return askedIds(params).flatMap((id) => {
const row = summaries.get(id);
// Not in the table (an id no test gave a callee fixture) or an explicit
// `null` (a callee whose summary was never persisted) ⇒ no row.
if (row === undefined || row === null) return [];
const reason = row.kind === 'params' ? encodeCallSummary(row.params) : row.reason;
return [{ id, reason }];
});
}
if (query.includes('s.id IN $ids') && query.includes('AS filePath')) {
return askedIds(params).flatMap((id) => {
const span = spans.get(id);
return span === undefined
? []
: [{ id, filePath: file, startLine: span.startLine, endLine: span.endLine }];
});
}
if (query.includes('MATCH (b:BasicBlock) WHERE b.id IN $ids')) {
return [
{ id: seed, line: 1, endLine: 1, text: 'run()' },
{ id: callBlock, line: 3, endLine: 3, text: 'x = helper()' },
{ id: ascentOnly, line: 4, endLine: 4, text: 'y = x + 1' },
{
id: calleeSeed,
line: 5,
endLine: 5,
text: secondSummary === undefined ? 'return 1' : 'return helper2()',
},
// Only ever reachable — and so only ever projected — on a second hop.
{
id: secondSeed,
line: symStartOf(SECOND_SPAN),
endLine: symStartOf(SECOND_SPAN),
text: 'return 2',
},
];
}
if (query.includes('MATCH (s:`Function`)')) return [];
return [];
};
}
// `run`'s own knobs on top of the mock's; the rest pass through untouched, so
// every default is written once, at the function that consumes it.
interface RunOptions extends DescentOptions {
// `false` ⇒ a v3 index with no CALL_SUMMARY layer, which gets the re-index note
// instead of the empty-ascent caveat. Defaults to `true`.
readonly callSummaryAvailable?: boolean;
// `1` confines the intra BFS to a single dependence level, so the call block is
// expanded ONLY by the ascent re-seed — the ascent's observable is then exact.
// It also leaves the BFS frontier non-empty at the budget, which is how the
// P2-4 cases below produce a genuinely TRUNCATED traversal. Defaults to `3`.
readonly maxDepth?: number;
}
const run = (
file: string,
{ callSummaryAvailable = true, maxDepth = 3, ...descent }: RunOptions = {},
) =>
runImpactPDG({
repo: { lbugPath: 'repo' },
sym: { id: `Function:${file}:run`, name: 'run', filePath: file, startLine: 0, endLine: 7 },
symType: 'Function',
direction: 'downstream',
maxDepth,
limit: 50,
line: 1,
executeParameterized: descentExec(file, descent),
callSummaryAvailable,
});
const CAVEAT = 'no return-value ascent in this slice';
// The sentence P2-2 flagged: an assertion about what the PERSISTED summaries
// record, which an UNDECODABLE summary contradicts (the codec never throws, so
// an unreadable `reason` is otherwise reported as one recording no return-flow).
const PERSISTED_CLAIM = 'property of the persisted summaries';
// P2-4 — the qualifier the note must carry whenever the callee set the descent
// EXAMINED is known to be a strict subset of the slice's real one, plus the three
// reasons that can put it there.
const QUALIFIER = 'so callees past the examined set were not checked';
const BUDGET_REASON = 'the traversal stopped at its depth/size budget';
const EMIT_CAP_REASON = "a slice block's call-site list was capped at emit";
const IDLESS_REASON = 'a slice block records call sites but no resolved callee ids';
const noteOf = (result: Awaited<ReturnType<typeof run>>): string =>
'affectedStatements' in result ? (result.note ?? '') : '';
const blocksOf = (result: Awaited<ReturnType<typeof run>>): readonly string[] =>
'reachableBlocks' in result ? result.reachableBlocks : [];
// The traversal-truncation premise of the P2-4 cases, asserted directly so a
// mock drift that stops truncating fails loudly instead of quietly turning the
// "qualifier appears" cases into copies of the "claim stays flat" ones.
const truncatedOf = (result: Awaited<ReturnType<typeof run>>): boolean =>
'reachableBlocks' in result && result.truncated === true;
// Held constant across the language-agnosticism cases below. One per language
// family the analyzer supports parsing, including the module-suffix variants the
// old provider-registry lookup did not recognise.
const EXTENSIONS = [
'src/svc.ts',
'src/svc.js',
'src/svc.mts',
'src/svc.cjs',
'src/svc.py',
'src/svc.go',
'src/svc.rs',
'src/svc.java',
'src/svc.zzz',
];
describe('runImpactPDG — empty-ascent note (U7)', () => {
it('callees with no CALL_SUMMARY row → notes the ascent was structurally empty', async () => {
const result = await run('src/svc.ts');
expect('affectedStatements' in result).toBe(true);
expect(noteOf(result)).toContain(CAVEAT);
});
it('callee with a non-empty return-flow summary → no empty-ascent caveat', async () => {
expect(noteOf(await run('src/svc.ts', { summary: flow([0]) }))).not.toContain(CAVEAT);
});
// An `r:0` summary decodes cleanly but records no formal→return flow, so the
// ascent is still structurally empty. Pins that the note keys on the DECODED
// return-flow rather than on the mere presence of a CALL_SUMMARY edge.
it('callee with an empty (r:0) return-flow summary → caveat present', async () => {
expect(noteOf(await run('src/svc.ts', { summary: flow([]) }))).toContain(CAVEAT);
});
// A cleanly-decoded EMPTY summary is the one case where the note may speak for
// the persisted data — every summary in the slice was read.
it('every summary decodes → the note keeps the persisted-summaries claim', async () => {
expect(noteOf(await run('src/svc.ts', { summary: flow([]) }))).toContain(PERSISTED_CLAIM);
});
it('v3 index (callSummaryAvailable false) → re-index note, not the empty-ascent caveat', async () => {
const note = noteOf(await run('src/svc.ts', { callSummaryAvailable: false }));
expect(note).toContain('re-index for CALL_SUMMARY');
expect(note).not.toContain(CAVEAT);
});
// #2802 — THE language-agnosticism pin, and the only test carrying it: the whole
// observable (note text AND reachable blocks) must be byte-identical across every
// extension once the path it legitimately echoes is masked — on BOTH sides of the
// caveat gate, since the CALL_SUMMARY content is the only thing allowed to flip
// the note. That SUBSUMES the per-extension caveat sweeps it replaces: identity
// across EXTENSIONS plus the two single-extension content assertions above
// entails "every extension gets the caveat" / "no extension gets it", and entails
// it more strongly — a `.py`-only note change that still CONTAINED the caveat
// slips past a substring sweep and fails here.
it.each([
{ label: 'no return-flow summary (caveat branch)', options: {} },
{ label: 'a return-flow summary (silent branch)', options: { summary: flow([0]) } },
])(
'note and reach do not vary with the criterion file extension — $label',
async ({ options }) => {
const fingerprints = await Promise.all(
EXTENSIONS.map(async (file) => {
const result = await run(file, options);
return [noteOf(result), ...blocksOf(result)].join('\n').split(file).join('<FILE>');
}),
);
expect(new Set(fingerprints).size).toBe(1);
},
);
});
// P2-2 — `decodeCallSummary` NEVER throws, so an unreadable `reason` yields no
// entry, indistinguishable from a cleanly-decoded empty summary. Each row below
// is a CALL_SUMMARY that DOES record `p0 -> return`, in a form this reader cannot
// unpack. The note must therefore stop asserting what the persisted summaries
// record — while the ascent stays withheld (a decode failure means "no usable
// ascent fact", never a claimed return-flow).
const UNDECODABLE: ReadonlyArray<{ label: string; reason: unknown }> = [
// Future codec version, same `r:1` payload `encodeCallSummary([0])` emits today.
{ label: 'version skew (2|r:1)', reason: '2|r:1' },
// Version 1, non-hex payload.
{ label: 'corrupt payload (1|r:zz)', reason: '1|r:zz' },
// A NULL `reason` cell.
{ label: 'NULL reason', reason: null },
];
describe('runImpactPDG — undecodable CALL_SUMMARY (P2-2)', () => {
it.each(UNDECODABLE)('$label → note drops the persisted-summaries claim', async ({ reason }) => {
const note = noteOf(await run('src/svc.ts', { summary: raw(reason) }));
expect(note).toContain(CAVEAT);
expect(note).not.toContain(PERSISTED_CLAIM);
});
it.each(UNDECODABLE)(
'$label → note reports the undecodable summary + remedy',
async ({ reason }) => {
const note = noteOf(await run('src/svc.ts', { summary: raw(reason) }));
expect(note).toContain('1 callee summary could not be decoded (version skew or corruption)');
expect(note).toContain('re-run gitnexus analyze --pdg to rebuild them');
},
);
// Soundness, unchanged: an unreadable summary must NEVER license the ascent.
// `maxDepth: 1` confines the intra BFS to one dependence level, so the
// ascent-only block is reachable through the U-C4 re-seed and nothing else.
it.each(UNDECODABLE)('$label → the return-value ascent is still withheld', async ({ reason }) => {
const result = await run('src/svc.ts', { summary: raw(reason), maxDepth: 1 });
expect(blocksOf(result)).not.toContain(ascentOnlyBlock('src/svc.ts'));
expect(noteOf(result)).toContain(CAVEAT);
});
// The discriminator for the row above: the SAME mock with a decodable
// `p0 -> return` summary does re-seed the caller continuation.
it('a decodable p0->return summary licenses the ascent', async () => {
const result = await run('src/svc.ts', { summary: flow([0]), maxDepth: 1 });
expect(blocksOf(result)).toContain(ascentOnlyBlock('src/svc.ts'));
expect(noteOf(result)).not.toContain(CAVEAT);
});
});
// P2-4 — "none of the N distinct callees carry a … return-flow" is a UNIVERSAL
// claim over the callees the descent actually examined, and so is "this is a
// property of the persisted summaries". Four premises make that examined set a
// strict subset of the slice's real callee list, and under any of them the note
// must describe what it examined rather than assert a property of the whole slice:
// 1. the TOP-LEVEL traversal stopped at a depth/size budget (`maxDepth: 1` below
// leaves the intra BFS frontier non-empty) — a callee that DOES carry a
// return-flow can sit past the frontier;
// 2. a CALLEE's OWN traversal stopped at the same depth budget (`calleeChain`
// below, at the PRODUCTION DEFAULT `maxDepth: 3`, with the top-level intra BFS
// completing so the callee's frontier is the only source left). The per-callee
// BFS is clamped by the same `maxDepth`, so a callee whose dependence chain
// outruns it hides its deeper call sites exactly the way case 1 does — and the
// hidden callee here carries a REAL `encodeCallSummary([0])` return-flow, so
// "the set was not fully examined" is not a hypothetical;
// 3. a block's `calleeIds` cell was capped at emit (`calleeCellCapped` below,
// with the traversal COMPLETING so the cap is the only source left) —
// `splitCalleeIds` strips the sentinel, so those callees reach neither the
// summary scan nor the counters;
// 4. a block records call SITES but no resolved callee ids (`'idless'` below) —
// an empty cell carries no sentinel, so case 3's flag cannot see it either.
// Those four, plus none of them (the control that keeps the fix from being "always
// hedge"), are the premise rows below, each crossed with the two assertions the
// note owes: is the qualifier clause present, and does the unqualified
// persisted-summaries claim survive. `reasons` is the EXACT phrase set the clause
// must name, so a row also asserts the absence of every phrase it omits;
// `truncated` is the premise's own observable, asserted rather than assumed so a
// mock drift that stops truncating fails loudly.
const REASON_PHRASES = [BUDGET_REASON, EMIT_CAP_REASON, IDLESS_REASON] as const;
const INCOMPLETENESS_PREMISES: ReadonlyArray<{
readonly label: string;
readonly premise: RunOptions;
readonly truncated: boolean;
readonly reasons: readonly string[];
}> = [
{ label: 'depth budget', premise: { maxDepth: 1 }, truncated: true, reasons: [BUDGET_REASON] },
{
// The production default is the point: no caller has to opt into a small
// maxDepth for a callee's own chain to outrun the budget.
label: "a callee's own depth budget at the default maxDepth 3",
premise: { calleeChain: true },
truncated: true,
reasons: [BUDGET_REASON],
},
{
label: 'emit-capped calleeIds cell',
premise: { calleeCellCapped: true },
truncated: false,
reasons: [EMIT_CAP_REASON],
},
{
label: 'a slice block with call sites but no resolved callee ids',
premise: {
ascentBlockCallees: [hiddenCalleeId('src/svc.ts')],
ascentBlockCell: 'idless',
},
truncated: false,
reasons: [IDLESS_REASON],
},
{ label: 'neither mechanism', premise: {}, truncated: false, reasons: [] },
];
describe('runImpactPDG — empty-ascent note over an incomplete callee set (P2-4)', () => {
it.each(INCOMPLETENESS_PREMISES)(
'$label → the qualifier clause names exactly this premise',
async ({ premise, truncated, reasons }) => {
const result = await run('src/svc.ts', premise);
expect(truncatedOf(result)).toBe(truncated);
const note = noteOf(result);
expect(note).toContain(CAVEAT);
expect(note.includes(QUALIFIER)).toBe(reasons.length > 0);
expect(REASON_PHRASES.filter((phrase) => note.includes(phrase))).toEqual(reasons);
},
);
// An `r:0` summary decodes cleanly, so this is the branch that asserts "a
// property of the persisted summaries" — a whole-slice claim an incomplete
// examined set did not establish, and a complete one did.
it.each(INCOMPLETENESS_PREMISES)(
'$label → the unqualified persisted-summaries claim survives iff the set is complete',
async ({ premise, reasons }) => {
const note = noteOf(await run('src/svc.ts', { ...premise, summary: flow([]) }));
expect(note.includes(QUALIFIER)).toBe(reasons.length > 0);
expect(note.includes(PERSISTED_CLAIM)).toBe(reasons.length === 0);
},
);
// Both mechanisms at once: ONE clause naming both reasons, never two clauses.
it('both mechanisms → one qualifier clause names both reasons', async () => {
const note = noteOf(await run('src/svc.ts', { maxDepth: 1, calleeCellCapped: true }));
expect(note).toContain(`(${BUDGET_REASON} and ${EMIT_CAP_REASON}, ${QUALIFIER})`);
expect(note.split(QUALIFIER)).toHaveLength(2);
});
// The undecodable-summary branch carries the same universal quantifier, so it
// gets the same qualifier — alongside its own (unrelated) P2-2 wording.
it('undecodable summary + truncated traversal → both qualifications appear', async () => {
const note = noteOf(await run('src/svc.ts', { summary: raw('1|r:zz'), maxDepth: 1 }));
expect(note).toContain(QUALIFIER);
expect(note).toContain('could not be decoded (version skew or corruption)');
expect(note).not.toContain(PERSISTED_CLAIM);
});
});
// P3-7 — the number the empty-ascent sentence quotes is the count of DISTINCT
// CALLEES the descent scanned for a CALL_SUMMARY (the raw `calleeIds` ids,
// accumulated into a Set), NOT the count of callees it resolved to a body and
// descended into, and NOT a count of call SITES. Two ways the three differ:
// - resolved-to-a-body: a cell can carry an id `resolveCalleeSpans` does not
// match (an out-of-repo target, an interface method, a node kind with no CFG
// body). The scan really is run over all of them, so the claim is exact at
// this granularity — but calling them "resolved" asserted a symbol-table
// lookup that never happened, and the old formals parenthetical ("no formal
// parameter is recorded as flowing to its return value") asserted a
// FORMALS-level property about symbols never resolved to a body at all;
// - call SITES: the accumulator is a Set of ids, so two blocks calling the same
// callee are ONE member. The earlier "call-site callee reference(s)" wording
// described the value as a site count it never was — pinned below.
const FILE = 'src/svc.ts';
// Ids a real `calleeIds` cell genuinely carries and the descent can never enter.
// The `Class:` id is the reproduced case — a `new Outer()` call site contributes
// it (see test/integration/cfg/pdg-chained-receiver-callees.test.ts), which is
// what inflated the quoted number from 1 to 3 there.
const UNENTERABLE_CALLEES = [`Class:${FILE}:Outer`, `Interface:${FILE}:Sink.write`] as const;
const MIXED_CALLEES = [helperCalleeId(FILE), ...UNENTERABLE_CALLEES] as const;
describe('runImpactPDG — the empty-ascent count is distinct callees (P3-7)', () => {
// One render, four readings of it: the wording the note must now carry, plus
// the three it must have dropped (all explained in the block comment above).
it('un-enterable callee ids count, and the note names them as distinct callees', async () => {
const note = noteOf(await run(FILE, { calleeIds: MIXED_CALLEES }));
expect(note).toContain('none of the 3 distinct callees carry a CALL_SUMMARY return-flow');
expect(note).not.toContain('resolved callee');
expect(note).not.toContain('no formal parameter is recorded');
expect(note).not.toContain('call-site callee reference');
});
// The call-SITE distinction, which the old wording got backwards: TWO slice
// blocks each recording a call to `helper` are ONE distinct callee, and the
// note quotes 1 — because a CALL_SUMMARY is a property of the callee, so
// scanning the same id twice could not change the answer.
it('two call sites to the SAME callee are one distinct callee, not two', async () => {
const result = await run(FILE, { ascentBlockCallees: [helperCalleeId(FILE)] });
// Premise: a SECOND slice block, distinct from the criterion's call block,
// is in the slice and records its own call to `helper`.
expect(blocksOf(result)).toContain(ascentOnlyBlock(FILE));
expect(ascentOf(result)).toMatchObject({ referencesScanned: 1 });
expect(noteOf(result)).toContain('none of the 1 distinct callee carries');
// The discriminator that keeps the 1 from being vacuous: three DISTINCT ids,
// in a SINGLE block's cell, do quote 3. The tally counts callees — neither
// the blocks nor the cells they sit in.
expect(noteOf(await run(FILE, { calleeIds: MIXED_CALLEES }))).toContain(
'none of the 3 distinct callees carry',
);
});
// The same slice with only the enterable callee: the number tracks the CELL,
// and the singular form agrees with it.
it('dropping the un-enterable ids drops the quoted number to 1', async () => {
expect(noteOf(await run(FILE))).toContain(
'none of the 1 distinct callee carries a CALL_SUMMARY return-flow',
);
});
// The load-bearing discriminator: the two extra ids raise the quoted number by
// 2 while adding NOTHING to the traversal — the descent resolved no span for
// them, so it entered no body. That gap is exactly what "resolved" papered over.
it('the un-enterable ids add to the number without adding any reach', async () => {
const [mixed, helperOnly] = await Promise.all([
run(FILE, { calleeIds: MIXED_CALLEES }),
run(FILE),
]);
// Same slice, byte-identical reach — the descent entered exactly one body in
// both runs …
expect(blocksOf(mixed).length).toBeGreaterThan(0);
expect(blocksOf(mixed)).toEqual(blocksOf(helperOnly));
// … while the quoted number moved 1 → 3, which is only honest because the
// note quotes the distinct ids scanned rather than the callees resolved.
expect(noteOf(mixed)).toContain('none of the 3 distinct callees carry');
expect(noteOf(helperOnly)).toContain('none of the 1 distinct callee carries');
});
// The undecodable branch quotes the same count and needed the same rewording.
it('undecodable branch → same distinct-callee wording over the same count', async () => {
const note = noteOf(await run(FILE, { summary: raw('1|r:zz'), calleeIds: MIXED_CALLEES }));
expect(note).toContain(
'none of the 3 distinct callees carry a decodable CALL_SUMMARY return-flow',
);
expect(note).not.toContain('resolved callee');
});
// GATE: the sentence fires on the descent having CROSSED a hop, never on the
// reference count. A cell whose ids are ALL un-enterable resolves no span, so
// no hop is taken and no ascent sentence is emitted — even though the reference
// count is 2. Pinned so re-seeding the count from the resolved spans cannot
// silently change WHEN the note fires.
it('a cell with no enterable callee takes no hop, so no ascent sentence fires', async () => {
const note = noteOf(await run(FILE, { calleeIds: UNENTERABLE_CALLEES }));
expect(note).not.toContain('inter-procedural hop');
expect(note).not.toContain(CAVEAT);
});
});
// P2-5 — what the note quotes is CROSS-HOP: `calleeReferencesSeen` in
// `interproceduralDescent` is a union of every hop's callee set, and
// `anyReturnFlow` is a flag that sticks once ANY hop found a return-flow. Both are
// accumulated once per hop and read only after the hop loop ends. Every case above
// takes exactly ONE hop, so none of them can tell that accumulation from a per-hop
// overwrite: with one hop both produce the same numbers. The cases below take TWO
// hops reaching DIFFERENT callees — `helper` on hop 0, `helper2` (named only in
// helper's own body block) on hop 1 — which is the only shape where the two
// implementations disagree.
//
// They also pin the MIXED boundary. The empty-ascent sentence is gated on
// `anyReturnFlow` being false, so a single return-flowing callee silences the note
// entirely — no caveat, no "1 of 3" partial figure, not even the
// undecodable-summary remedy. That binary behavior is deliberate (partial-coverage
// reporting was considered and dropped); pinned here so changing it is a decision
// rather than an accident.
const TWO_HOPS = 'crosses 2 inter-procedural hops';
describe('runImpactPDG — cross-hop accumulation and mixed return-flow (P2-5)', () => {
it('two hops over DISTINCT callees → the reference count is their UNION', async () => {
const result = await run(FILE, { secondSummary: null });
// Premise, asserted rather than assumed: the descent crossed TWO call
// boundaries and the second one reached ground the first did not.
expect(noteOf(result)).toContain(TWO_HOPS);
expect(blocksOf(result)).toContain(secondCalleeSeedBlock(FILE));
// Nothing truncated, so the count is quoted flat and the P2-4 qualifier is
// not what is being read here.
expect(truncatedOf(result)).toBe(false);
expect(noteOf(result)).not.toContain(QUALIFIER);
// hop 0 contributes {helper}, hop 1 contributes {helper2} ⇒ 2. A per-hop
// overwrite ends holding only hop 1's set and quotes 1.
expect(noteOf(result)).toContain(
'none of the 2 distinct callees carry a CALL_SUMMARY return-flow',
);
});
it('a return-flow found on hop 0 survives a later hop that finds none', async () => {
const result = await run(FILE, { summary: flow([0]), secondSummary: null });
expect(noteOf(result)).toContain(TWO_HOPS);
expect(blocksOf(result)).toContain(secondCalleeSeedBlock(FILE));
// `helper` return-flows, `helper2` does not. The flag raised on hop 0 sticks,
// so the note stays silent; a per-hop overwrite would end on hop 1's EMPTY
// result and wrongly emit the caveat over 2 references.
expect(noteOf(result)).not.toContain(CAVEAT);
});
it('a return-flow found only on hop 1 also silences the note', async () => {
const result = await run(FILE, { secondSummary: flow([0]) });
expect(noteOf(result)).toContain(TWO_HOPS);
expect(blocksOf(result)).toContain(secondCalleeSeedBlock(FILE));
// The mirror image of the row above: hop 0 found nothing, hop 1 did. The note
// keys on the ACCUMULATED flag, never on the first hop's view of it.
expect(noteOf(result)).not.toContain(CAVEAT);
});
it('mixed callees in ONE examined set → the note goes silent, never partial', async () => {
const [mixed, none] = await Promise.all([
run(FILE, { summary: flow([0]), calleeIds: MIXED_CALLEES }),
run(FILE, { calleeIds: MIXED_CALLEES }),
]);
// Same 3 call-site references in both runs; only `helper`'s summary differs.
// One return-flow raises `anyReturnFlow`, which is enough to close the gate, so
// NO empty-ascent sentence is emitted — the note quotes no count at all rather
// than reporting "1 of 3 carried a return-flow".
expect(noteOf(mixed)).not.toContain(CAVEAT);
expect(noteOf(mixed)).not.toContain('distinct callee');
// The discriminator that makes the silence load-bearing: drop that one
// return-flow and the SAME 3 references do produce the sentence.
expect(noteOf(none)).toContain('none of the 3 distinct callees carry');
});
it('a return-flowing callee alongside an UNDECODABLE one → not even the decode remedy', async () => {
const note = noteOf(await run(FILE, { summary: flow([0]), secondSummary: raw('1|r:zz') }));
expect(note).toContain(TWO_HOPS);
// The empty-ascent sentence — and so both of its tails — is gated on
// `anyReturnFlow`, so the hop-1 undecodable summary, normally reported with a
// rebuild remedy, is suppressed by the hop-0 return-flow. Silent end to end.
expect(note).not.toContain(CAVEAT);
expect(note).not.toContain('could not be decoded');
});
});
// ── Structured ascent coverage: pdgEvidence.ascent ───────────────────────────
// Every fact the note interpolates into English is ALSO published structurally.
// This is MCP output read by AGENTS, not only humans: the only way to ask "was
// the ascent complete, and if not why" must not be a regex over prose. The
// branch already proved the cost — a pure rewording ("resolved callees" →
// "call-site callee references", P3-7 above) moved ~30 assertions and would have
// silently broken any consumer keyed on the old phrase.
//
// The field is ADDITIVE: every prose assertion above is unchanged, and the cases
// below are the same scenarios read through the structured surface instead.
const ascentOf = (result: Awaited<ReturnType<typeof run>>): PdgAscentCoverage | undefined =>
'pdgEvidence' in result ? result.pdgEvidence?.ascent : undefined;
// How each structured reason code is expected to READ in the note. The
// production mapping (`ASCENT_INCOMPLETE_PHRASE`) is module-private by design —
// the codes are the contract, the phrasing is a rendering — so this table is
// where the two surfaces are compared, and a reworded phrase fails HERE rather
// than drifting apart unnoticed.
const REASON_PHRASE: Readonly<Record<PdgAscentIncompleteReason, string>> = {
'traversal-truncated': BUDGET_REASON,
'callee-list-capped': EMIT_CAP_REASON,
'callee-ids-unrecorded': IDLESS_REASON,
};
// The `run` helper above is downstream-only (the descent's direction gate). An
// UPSTREAM slice never runs the descent at all, which is the case the field has
// to distinguish from "the descent ran and scanned nothing".
const runUpstream = (file: string, options: DescentOptions = {}) =>
runImpactPDG({
repo: { lbugPath: 'repo' },
sym: { id: `Function:${file}:run`, name: 'run', filePath: file, startLine: 0, endLine: 7 },
symType: 'Function',
direction: 'upstream',
maxDepth: 3,
limit: 50,
line: 1,
executeParameterized: descentExec(file, options),
callSummaryAvailable: true,
});
// A slice whose seed block has NO outgoing dependence edge, yet DOES record a call
// site — the shape that routes `runImpactPDG` through its empty-slice exit with a
// descent already behind it. The single callee id is deliberately un-enterable, so
// the descent scans it for a `CALL_SUMMARY` and adds no block: `reachableBlocks`
// stays empty while the coverage is a real, non-zero reading.
const runEmptySlice = (file: string) => {
const seed = `BasicBlock:${file}:1:0:0`;
const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query, params) => {
if (query.includes('RETURN a.id AS id')) {
return query.includes('a.startLine = $line') ? [{ id: seed }] : [];
}
if (query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')) {
const ids = (params as Record<string, unknown>)['ids'];
const asked = Array.isArray(ids) ? ids.map((id) => String(id)) : [];
return asked.includes(seed)
? [{ id: seed, calleeIds: `Class:${file}:Outer`, callees: 'Outer' }]
: [];
}
// No dependence edges, no CALL_SUMMARY rows, no resolvable callee spans.
return [];
};
return runImpactPDG({
repo: { lbugPath: 'repo' },
sym: { id: `Function:${file}:run`, name: 'run', filePath: file, startLine: 0, endLine: 7 },
symType: 'Function',
direction: 'downstream',
maxDepth: 3,
limit: 50,
line: 1,
executeParameterized: exec,
callSummaryAvailable: true,
});
};
describe('runImpactPDG — structured ascent coverage (pdgEvidence.ascent)', () => {
// The whole record is pinned with toEqual rather than toMatchObject: the point
// of the field is that a consumer can read it without a fallback, so an
// omitted member is a contract break, not a detail.
//
// FIXTURE NOTE: at maxDepth 3 the block the U-C4 re-seed targets is ALREADY
// intra-reachable, so what this row pins is a return-flow being FOUND over a
// COMPLETE population — not the ascent adding ground. It is given a `calleeIds`
// cell so the population is a real reading of two blocks' cells (2: `helper`
// from the criterion's call block, `hidden` from the ascent target) instead of a
// vacuous 1 over a block carrying nothing. The case where the ascent adds ground
// — and where that block's own call sites have to join the population — is the
// separate row below, which is the only shape where the two differ.
it('ascent fired → returnFlowFound over a complete examined set', async () => {
const result = await run(FILE, {
summary: flow([0]),
ascentBlockCallees: [hiddenCalleeId(FILE)],
});
// Premise: this is exactly the run whose note carries NO caveat.
expect(noteOf(result)).not.toContain(CAVEAT);
expect(blocksOf(result)).toContain(ascentOnlyBlock(FILE));
expect(ascentOf(result)).toEqual({
referencesScanned: 2,
returnFlowFound: true,
undecodableSummaryCount: 0,
examinedComplete: true,
incompleteReasons: [],
callSummaryLayerPresent: true,
});
});
// P3 — a block the descent reaches ONLY through the U-C4 re-seed is still a
// slice block: it is published in `reachableBlocks`, so its own call sites must
// reach the CALL_SUMMARY scan, the distinct-callee tally, AND the emit-cap flag.
// `maxDepth: 1` is what makes it ascent-only: the intra BFS stops at the call
// block, so nothing but the ascent can put the next block in the slice.
it('a block reached only by the ascent contributes its call sites to the scan', async () => {
const cell = {
ascentBlockCallees: [hiddenCalleeId(FILE)],
ascentBlockCell: 'capped',
} as const;
const [ascended, withheld] = await Promise.all([
run(FILE, { maxDepth: 1, summary: flow([0]), ...cell }),
run(FILE, { maxDepth: 1, ...cell }),
]);
// Premise: the block below is in the slice ONLY because the ascent fired —
// withhold the return-flow and it is gone.
expect(blocksOf(ascended)).toContain(ascentOnlyBlock(FILE));
expect(blocksOf(withheld)).not.toContain(ascentOnlyBlock(FILE));
// So its cell has to be read: `hidden` joins the population (1 → 2) and the
// cell's emit-cap sentinel is reported, neither of which the descent-visited
// blocks could have contributed.
expect(ascentOf(ascended)).toEqual({
referencesScanned: 2,
returnFlowFound: true,
undecodableSummaryCount: 0,
examinedComplete: false,
incompleteReasons: ['traversal-truncated', 'callee-list-capped'],
callSummaryLayerPresent: true,
});
// The discriminator that makes the reading load-bearing: with the ascent
// withheld that block is not in the slice, so its call sites are correctly
// absent and the cap it carries is correctly unreported.
expect(ascentOf(withheld)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: false,
incompleteReasons: ['traversal-truncated'],
callSummaryLayerPresent: true,
});
});
// P1 — the per-callee BFS's OWN depth exhaustion, at the production default.
// `helper`'s body is a 4-link dependence chain whose deepest block calls a
// callee carrying a real `encodeCallSummary([0])` return-flow; at maxDepth 3 the
// callee's BFS stops one link short, so that return-flow is never examined. The
// top-level intra BFS completes here, so the callee's frontier is the ONLY thing
// cutting the slice — and `examinedComplete` must not read as an all-clear.
it('a callee whose own BFS runs out of depth → examinedComplete false', async () => {
const result = await run(FILE, { calleeChain: true });
expect(truncatedOf(result)).toBe(true);
expect(ascentOf(result)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: false,
incompleteReasons: ['traversal-truncated'],
callSummaryLayerPresent: true,
});
});
// The discriminator for the row above: the SAME fixture with budget to walk the
// whole chain DOES reach the deepest block, scans the callee it calls, and finds
// its return-flow. So maxDepth 3 was hiding a real answer, not an empty region —
// which is exactly why publishing `examinedComplete: true` there was false.
it('with budget to walk the chain the hidden return-flow IS found', async () => {
const result = await run(FILE, { calleeChain: true, maxDepth: CALLEE_CHAIN_LENGTH + 1 });
expect(truncatedOf(result)).toBe(false);
expect(ascentOf(result)).toEqual({
referencesScanned: 2,
returnFlowFound: true,
undecodableSummaryCount: 0,
examinedComplete: true,
incompleteReasons: [],
callSummaryLayerPresent: true,
});
});
// A block that records call SITES but no resolved callee ids shrinks the
// population silently: nothing is dropped at emit, so no sentinel exists to
// raise the cap flag. Here EVERY id is missing, so the scan's population is
// empty — and a zeroed record claiming completeness would be the strongest form
// of the false all-clear.
it('call sites with no resolved ids → the empty population is reported, not claimed complete', async () => {
const [idless, recorded] = await Promise.all([
run(FILE, { calleeCellIdless: true }),
run(FILE),
]);
expect(ascentOf(idless)).toEqual({
referencesScanned: 0,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: false,
incompleteReasons: ['callee-ids-unrecorded'],
callSummaryLayerPresent: true,
});
// The discriminator: the SAME block with its ids recorded scans 1 callee and
// is genuinely complete, so the flag tracks the missing ids and not the mock.
expect(ascentOf(recorded)).toMatchObject({
referencesScanned: 1,
examinedComplete: true,
incompleteReasons: [],
});
});
it('nothing flowed → the same scanned set with returnFlowFound false', async () => {
const result = await run(FILE);
expect(noteOf(result)).toContain(CAVEAT);
expect(ascentOf(result)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: true,
incompleteReasons: [],
callSummaryLayerPresent: true,
});
});
// The P2-2 fact, structurally: a non-zero count is what tells a consumer that
// `returnFlowFound: false` is NOT a statement about what the summaries record.
it.each(UNDECODABLE)('$label → undecodableSummaryCount reports it', async ({ reason }) => {
expect(ascentOf(await run(FILE, { summary: raw(reason) }))).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 1,
examinedComplete: true,
incompleteReasons: [],
callSummaryLayerPresent: true,
});
});
// P2-4 case 1, structurally — the reason is a CODE, not a sentence.
it('incomplete via budget truncation → examinedComplete false + traversal-truncated', async () => {
const result = await run(FILE, { maxDepth: 1 });
expect(truncatedOf(result)).toBe(true);
expect(ascentOf(result)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: false,
incompleteReasons: ['traversal-truncated'],
callSummaryLayerPresent: true,
});
});
// P2-4 case 2 in isolation: the traversal COMPLETED, so the emit-time cap is
// the only thing that can make the examined set a prefix — and it is a
// mechanism the result's own `truncated` flag cannot express.
it('incomplete via emit cap → callee-list-capped with nothing else truncated', async () => {
const result = await run(FILE, { calleeCellCapped: true });
expect(truncatedOf(result)).toBe(false);
expect(ascentOf(result)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: false,
incompleteReasons: ['callee-list-capped'],
callSummaryLayerPresent: true,
});
});
it('both mechanisms → both codes, budget first', async () => {
const result = await run(FILE, { maxDepth: 1, calleeCellCapped: true });
expect(ascentOf(result)).toMatchObject({
examinedComplete: false,
incompleteReasons: ['traversal-truncated', 'callee-list-capped'],
});
});
// The two surfaces are rendered from ONE array, so the note's clause is
// exactly the published codes mapped through REASON_PHRASE, in code order.
// This is what makes a future third reason a rendering decision instead of a
// contract change.
it('the note clause is exactly the published codes, in order', async () => {
const result = await run(FILE, { maxDepth: 1, calleeCellCapped: true });
const codes = ascentOf(result)?.incompleteReasons ?? [];
expect(codes).toEqual(['traversal-truncated', 'callee-list-capped']);
expect(noteOf(result)).toContain(
`(${codes.map((code) => REASON_PHRASE[code]).join(' and ')}, ${QUALIFIER})`,
);
});
// The false-safe guard. On a PRE-FU-C (v3) index the scan runs and finds
// nothing because the layer that records return-flows does not exist — a
// consumer reading only `returnFlowFound: false` would conclude "these callees
// record no return-flow", which is exactly the misreading the note's re-index
// sentence exists to prevent for humans.
it('v3 index → callSummaryLayerPresent false alongside returnFlowFound false', async () => {
const result = await run(FILE, { callSummaryAvailable: false });
expect(noteOf(result)).toContain('re-index for CALL_SUMMARY');
expect(ascentOf(result)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: true,
incompleteReasons: [],
callSummaryLayerPresent: false,
});
});
// "Nothing was scanned" ≠ "we scanned and found nothing". An upstream slice
// never runs the descent, so the field is ABSENT rather than a zeroed record
// that would read as a completed, empty scan.
it('upstream slice → the descent never ran, so no coverage is published', async () => {
const [upstream, downstream] = await Promise.all([runUpstream(FILE), run(FILE)]);
// The evidence namespace itself is present — only the ascent member is not.
expect('pdgEvidence' in upstream && upstream.pdgEvidence?.statements).toBe('local-dependence');
expect(ascentOf(upstream)).toBeUndefined();
// The discriminator that makes the absence load-bearing rather than vacuous:
// the SAME mock run downstream DOES publish coverage, so `undefined` above is
// the descent-did-not-run signal and not simply "the field does not exist".
expect(ascentOf(downstream)).toMatchObject({ referencesScanned: 1 });
});
// The mirror of the row above, and the case the contract sentence "present iff
// the inter-procedural descent RAN" is easiest to break on: a criterion line
// whose only dependent is the callee it invokes DIRECTLY reaches no distinct
// downstream block, so `runImpactPDG` returns through its empty-slice exit —
// which sits BEFORE the result assembler and so has to publish the coverage
// itself. The descent ran and scanned; absence here would say it did not.
it('empty slice → the descent that already ran is still published', async () => {
const result = await runEmptySlice(FILE);
// Premise: this really is the empty-slice exit, not the assembled result.
expect(blocksOf(result)).toEqual([]);
// … and the descent really did scan the seed block's call site before it.
expect(ascentOf(result)).toEqual({
referencesScanned: 1,
returnFlowFound: false,
undecodableSummaryCount: 0,
examinedComplete: true,
incompleteReasons: [],
callSummaryLayerPresent: true,
});
});
// The strongest case for the structured surface: the note is SILENT (the
// ascent sentence is gated on a hop being crossed, and a cell of un-enterable
// ids resolves no span) while the descent did scan 2 references. Prose reports
// nothing here; the field reports exactly what was examined.
it('no hop crossed → note silent, coverage still reports the scan', async () => {
const result = await run(FILE, { calleeIds: UNENTERABLE_CALLEES });
expect(noteOf(result)).not.toContain('inter-procedural hop');
expect(noteOf(result)).not.toContain(CAVEAT);
expect(ascentOf(result)).toMatchObject({
referencesScanned: 2,
returnFlowFound: false,
examinedComplete: true,
});
});
// P2-5's mixed boundary: one return-flow silences the note entirely, so the
// prose quotes no count at all. The structured surface still carries both the
// population and the outcome.
it('mixed callees → note quotes no count, coverage still carries it', async () => {
const result = await run(FILE, { summary: flow([0]), calleeIds: MIXED_CALLEES });
expect(noteOf(result)).not.toContain('distinct callee');
expect(ascentOf(result)).toMatchObject({ referencesScanned: 3, returnFlowFound: true });
});
// Cross-hop accumulation (P2-5) read structurally: hop 0 contributes {helper},
// hop 1 contributes {helper2} ⇒ 2. A per-hop overwrite would publish 1.
it('two hops over DISTINCT callees → referencesScanned is their union', async () => {
const result = await run(FILE, { secondSummary: null });
expect(noteOf(result)).toContain(TWO_HOPS);
expect(ascentOf(result)).toMatchObject({ referencesScanned: 2, returnFlowFound: false });
});
});