Commit graph

14 commits

Author SHA1 Message Date
azizur100389
7f0ab16ffe
feat(routes): support JS data route tables (#2972)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-18 04:39:45 +01:00
Gergő Magyar
9eaf2e6c4e
perf(mcp): cut the analyze-only language-provider closure out of MCP server startup (#2802) (#2806)
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
* 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
azizur100389
d268f351d3
fix(group): preserve manifest-only impact crossings (#2784)
* fix(group): preserve manifest-only impact crossings

Keep proven manifest cross-repo hits when the far endpoint has no concrete graph symbol, avoiding a guaranteed failed UID fan-out.

* fix(group): verify manifest-only neighbor repos

Keep manifest-only crossings from bypassing neighbor repository resolution so unavailable repos still surface as truncated fan-out.

* fix(group): distinguish boundary-only impact crossings

Keep manifest-only boundaries visible without treating unattempted fan-out as completed impact or escalating risk, and cover service scope, deduplication, and real bridge persistence.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-01 20:24:06 +01:00
Gergő Magyar
8402963198
fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout (#2394)
* fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout

The `windows-latest (platform-sensitive)` job was hitting its 15-min internal
vitest watchdog in run-cross-platform.ts. It's cumulative slowness, not a hang:
the fixed 72-file suite is dominated by ~50 CLI/worker process spawns, and
Windows is ~5x slower than macOS at process startup (macOS ran the same set in
~3min of tests). Two complementary changes bring it back under the watchdog with
headroom, without touching any test assertion:

- Shard the platform-sensitive matrix (windows/macos × shard [1,2]) and forward
  `--shard=i/2` through run-cross-platform.ts to vitest, which partitions the
  fixed file list deterministically (sha1, equal file-count) — halving each
  runner. macOS/Ubuntu were already under budget.
- New test/helpers/cli-entry.ts (`CLI_SPAWN_PREFIX`): spawn the built
  `dist/cli/index.js` when `GITNEXUS_E2E_CLI=dist` (set on the cross-platform job,
  which already builds) instead of `node --import tsx src/cli/index.ts`, which
  re-transpiles the whole CLI on every spawn. Defaults to tsx-on-source so local
  runs always reflect current source; `GITNEXUS_E2E_CLI=dist` on an unbuilt tree
  throws an actionable "run npm run build" error. dist is opt-in only — never
  inferred from a generic `CI` env — so an ambient `CI=1` can't silently run a
  stale build. Converted 8 spawn-based e2e suites; added test/unit/cli-entry.test.ts.

The Ubuntu coverage job leaves `GITNEXUS_E2E_CLI` unset, so the tsx-on-source path
stays exercised in CI too (both entry points covered).

Measured on Linux: cli-limit-e2e 121.5s→91s, cli-e2e 289s→217s (~25%); larger on
Windows where the transpile is a bigger share of each spawn.

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

* refactor(ci): derive platform-sensitive shard count from one source (#2394)

The shard total was hardcoded in three coupled, unenforced places (matrix
length, job-name suffix, --shard denominator); editing one without the others
silently dropped a shard's tests with green CI. Add a checkout-free shard-plan
job whose single TOTAL generates both the shard index list (consumed via
fromJSON) and the /N denominator (job name + --shard arg), so they cannot
drift. Asserts TOTAL>=1 to rule out an empty-matrix silent skip. No behavior
change — still 2 shards per OS.

Addresses PR #2394 tri-review finding F2.

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

* fix(ci): 3 shards for real Windows headroom + honest sharding comments (#2394)

vitest shards by file COUNT, not runtime, so the heaviest spawn suites cluster
into one shard: live CI showed Windows shard 1/2 at 12m12s (~81% of the 15-min
watchdog) vs shard 2/2 at 3m0s. The old comments claimed "comfortable/generous
headroom", which the count-based split doesn't deliver at 2 shards. Bump TOTAL
to 3 (one line, single source) so even the busiest Windows shard clears the
watchdog, and reword the comments to describe count-based (not time-based)
sharding.

Addresses PR #2394 tri-review finding F1.

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

* test(ci): extract testable parseShardArg from run-cross-platform (#2394)

The --shard parse/forward glue had no unit test. Extract it into a pure
scripts/shard-arg.ts (mirroring the computeSpawnPrefix extraction precedent) so
the branch logic is lockable without the script's top-level execFileSync, and
add test/unit/shard-arg.test.ts (absent -> undefined, valid token -> passed
through, found amid other args). Behavior unchanged; U4 adds the malformed
fail-loud on top.

Addresses PR #2394 tri-review finding F3.

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

* fix(ci): fail loud on a malformed --shard arg (#2394)

A shard-shaped-but-malformed arg (--shard=1, --shard, --shard=abc) was silently
ignored, dropping the shard flag so both legs ran the full unsharded ~50-spawn
suite — re-arming the Windows watchdog timeout with no signal. parseShardArg now
throws an actionable error on any --shard/--shard=… arg that fails the strict
regex (unrelated flags like --shardx= pass through), and the call site in
run-cross-platform.ts catches it into console.error + exit 1, kept outside the
execFileSync try so the message isn't swallowed by that catch's watchdog-only
branch.

Addresses PR #2394 tri-review finding F4.

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

* fix(test): fail loud on an unknown GITNEXUS_E2E_CLI value (#2394)

computeSpawnPrefix silently degraded any unknown GITNEXUS_E2E_CLI value to
tsx-on-source, so a typo (e.g. `dsit`) would make CI believe it tests the dist
entry point while actually running src. Throw on any value other than
'dist'/'src'/unset (the safe tsx default is preserved for unset/''/'src', so it
still never selects dist without an explicit opt-in). Flip the unknown-mode unit
test to assert the throw and add the missing {mode:undefined, distExists:true}
case. Only ci-tests.yml sets the var (=dist), so no existing suite is affected.

Addresses PR #2394 tri-review findings minor-a/b.

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

* test(ci): run cli-entry.test.ts on the cross-platform matrix (#2394)

cli-entry.test.ts resolves CLI_SPAWN_PREFIX from a real path, and its last
assertion (cli[/\\]index) has a Windows backslash branch that only Ubuntu
exercised. Register it in PLATFORM_LOGIC so it runs on the Windows/macOS matrix
too. (shard-arg.test.ts stays out — pure string logic, OS-independent.) List
grows 73 -> 74; the generated shard matrix keeps coverage complete.

Addresses PR #2394 tri-review finding minor-c.

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

* refactor(test): share tsxLoaderUrl(), dedup the last tsx-loader boilerplate (#2394)

bridge-cache-reopen.test.ts carried its own copy of the tsx-loader-resolution
boilerplate (createRequire -> resolve('tsx/package.json') -> pathToFileURL) —
the one site the PR's CLI_SPAWN_PREFIX migration didn't cover (it spawns a seed
script, not the CLI). Export the existing tsxLoaderUrl() from cli-entry.ts and
reuse it here; the resolved loader URL is byte-identical.

Addresses PR #2394 tri-review finding minor-d.

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

* fix(test): make skipUnlessFtsAvailable install FTS on miss so shards are self-sufficient (#2394)

Sharding the platform-sensitive suite into 3 exposed a latent test-isolation
bug: load-only FTS primitives (test/integration/lbug-core-adapter.test.ts) only
passed because a sibling installer test happened to co-locate in the same shard
and install FTS into the shared ~/.lbdb first. At 3 shards, lbug-core-adapter
landed in a shard with no installer sibling, so its load-only loadFTSExtension()
failed deterministically on macOS+Windows shard 2/3 under GITNEXUS_REQUIRE_FTS=1.

Make the gate self-sufficient: on a load-only miss under REQUIRE_FTS, install
FTS with `auto` (LOAD-first, then one bounded network INSTALL) before treating
it as a hard failure — mirroring withTestIndexedDB. A pre-installed extension
still costs no network (auto is LOAD-first); offline/local runs (no env var)
still skip gracefully. Verified: with a fresh HOME (no pre-installed FTS) +
REQUIRE_FTS=1, lbug-core-adapter now passes 15/15 (previously threw).

Addresses the 3-shard CI failure surfaced while validating PR #2394's F1 fix.

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

* ci: warm-cache the LadybugDB FTS extension across platform shards (#2394)

Follow-up to the FTS self-install fix: cache ~/.lbdb/extension per OS + lockfile
so a warm run skips the network install entirely and the parallel shards share
one download across runs. Pure reliability/speed — on a cache miss the tests
still self-install FTS on demand (test/helpers/fts-availability.ts), so this is
never a correctness dependency, just a way to cut the network-install surface
that made the sharded FTS tests flaky. Keyed by lockfile hash (a LadybugDB
version bump re-installs); per-OS since the extension is a native binary.

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

* fix(ci): pass shard via env to clear zizmor template-injection (#2394)

Interpolating ${{ matrix.shard }} (now sourced from the shard-plan job output)
directly into the run: shell tripped zizmor's template-injection audit
(code-scanning alert #824, ci-tests.yml:147). Move the value into a SHARD env
var — assigned via ${{ }} but referenced as "$SHARD" in the shell, which is not
an injection sink — and set shell: bash so the expansion is uniform across the
windows + macOS matrix (the default run shell is pwsh on Windows, where $SHARD
would be empty and trip the new malformed-shard fail-loud). Verified locally
with zizmor: the :147 template-injection finding is gone.

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

* ci: shard the ubuntu coverage job and merge blobs before the threshold gate (#2394)

The coverage job ran the full suite unsharded (~16 min). Shard it like the
cross-platform matrix, then merge the per-shard coverage before enforcing the
threshold gate:

- shard-plan now also single-sources the coverage shard count (cov_total /
  cov_shards), so the coverage matrix + /N denominator can't drift.
- The `tests` job becomes a coverage shard matrix: each shard runs
  `vitest run --shard --coverage --reporter=blob` with thresholds forced to 0
  (a single shard's partial coverage can never meet the gate) and uploads its
  blob. FTS self-installs per shard, so sharding the full suite is safe.
- New `coverage-merge` job (needs: tests) reduces the blobs with
  `vitest --mergeReports`, enforcing the REAL config thresholds on the combined
  ('new') coverage — this is the gate. It also emits the merged test-results.json
  and runs the unsharded web + docker suites, so the `test-reports` artifact
  keeps the exact shape ci-report.yml consumes for its base-branch ('baseline')
  vs new coverage delta.

The shard arg goes through a SHARD env var + shell: bash (no template-injection).
Validated locally: shard blobs write and merge into a coverage-summary.json +
merged test-results.json; the merge enforces thresholds on the union. CI Gate
still aggregates the coverage-merge result via the reusable-workflow call.

Note: the coverage check names change (ubuntu / coverage 1/3 … + merge) — update
any pinned branch-protection required checks.

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

* fix(ci): include hidden files when uploading the coverage blob (#2394)

The coverage shards write their blob to gitnexus/.vitest-reports/ (a dotdir).
actions/upload-artifact excludes hidden files by default, so the coverage-blob-*
artifacts uploaded empty — the merge job then downloaded 0 artifacts and
vitest --mergeReports failed with ENOENT scandir '.vitest-reports'. Set
include-hidden-files: true on the blob upload so the blobs actually ship.

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

* fix(ci): group shard-plan GITHUB_OUTPUT writes to satisfy shellcheck SC2129 (#2394)

Adding the coverage shard outputs (cov_shards/cov_total) made the shard-plan gen
step write four individual `>> "$GITHUB_OUTPUT"` redirects, which shellcheck
(run by the actionlint check) flags as SC2129. Group the echoes into a single
`{ …; } >> "$GITHUB_OUTPUT"` block.

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

* perf(test): cost-balanced shard sequencer to cut CPU contention (#2394)

vitest's default --shard hashes file paths and splits by file COUNT, which
clustered the spawn-heavy suites onto one runner (Windows platform shard 1 ran
~4x the others). Add a custom sequence.sequencer that overrides only shard()
and balances by estimated WORK instead:

- specWeight() weights the fileParallelism:false spawn-heavy suites (cli-e2e,
  lbug-db — already isolated to run sequentially) far above the parallel default
  files, plus file size as a cheap finer signal. Deterministic per checkout.
- assignShards() does greedy longest-processing-time bin-packing (heaviest file
  into the currently-lightest shard). The partition stays complete and disjoint
  — verified: on the 74-file cross-platform set the three shards weigh
  7611/7610/8064 (the sequential-heavy files spread ~7/7/8) with zero overlap and
  no file dropped, vs the hash split's count-only balance.

sort() is left to the base sequencer so project groupOrder / duration-cache
ordering is untouched. Pure logic split into shard-balance.ts with a unit test
locking the disjoint+complete, balance, and determinism properties.

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

* fix(ci): install + cache FTS up front on the coverage (and cross-platform) shards (#2394)

coverage 3/3 failed on extension-binary-real.test.ts: it uses the file-path FTS
gate (requireFtsResourceOrSkip), which resolves ~/.lbdb/extension at MODULE LOAD
and cannot self-install the way the load-path gate (skipUnlessFtsAvailable, U8)
does. The coverage job had no FTS cache and relied on an installer test running
first in the shard — the balancing sequencer reshuffled the shards and dropped
extension-binary-real into a shard with no installer, so FTS was absent.

Remove the ordering dependency: add scripts/ensure-fts.ts (init a throwaway lbug
db, loadFTSExtension with policy:auto → LOAD-first, INSTALL on miss) and run it
up front on every coverage AND cross-platform shard, after restoring the per-OS
FTS cache. The coverage job now shares that same cache key (it previously had
none — this is the "share the cached FTS with coverage" the failure pointed at).
Cold cache installs once; warm cache is a no-network load. Verified locally:
ensure-fts installs FTS into a fresh HOME and is a no-op when already present.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:09:11 +01:00
Gergő Magyar
fbffa96554
fix(lbug/mcp): exact symbol content + 0-based line storage with 1-based MCP display (#2377, #2379) (#2380)
* fix(lbug): store exact symbol content snippets

* fix(ingestion): emit 0-based line numbers for COBOL/JCL/scope/markdown nodes

COBOL/JCL processors, the scope-graph emitter, and the markdown Section
emitter stored 1-based startLine/endLine, unlike every tree-sitter node
(0-based). The exact-content slice (#2379) then dropped each symbol's
declaration line for those languages. Convert to 0-based at the graph-node
emission boundary via toZeroBasedLine — leaving parser-internal .line values,
L${line} node/edge IDs, and containment checks untouched.

Refs #2377, #2379

* refactor(lbug): single source of truth for symbol-content labels

Extract SYMBOL_NODE_LABELS so the exact-content label set can't drift the way
the inline copy did in #2379. csv-generator derives EXACT_SYMBOL_CONTENT_LABELS
from it; manifest-extractor's near-identical allowlist is left behavior-unchanged
(intentional subset, #2325-test-locked) with a documented cross-reference.

Refs #2379

* test(ingestion): cover 0-based emitter output and pin exact-content slicing

- csv-pipeline: replace the blank-buffer fixture (a +/-1 shift silently passed)
  with directly-adjacent neighbors; add one-line-symbol and Section (+/-2 fallback)
  cases.
- cobol resolver: assert COBOL Module and JCL job/step emit 0-based startLine.
- markdown CRLF: update Section startLine/endLine expectations to 0-based.

Refs #2377, #2379

* feat(mcp): present 1-based line numbers in context/query/impact tools

GraphNode startLine/endLine are stored 0-based (tree-sitter rows), which
surprised users querying them (they don't line up with editors/sed). Add
toDisplayLine and apply it at the context/query/impact response boundaries so
line numbers are editor/sed-aligned. Raw cypher stays 0-based (documented in the
schema resource); BasicBlock/PDG statement lines (already 1-based) and internal
join params are left untouched.

Refs #2377

* test(mcp): assert 1-based tool exposure with raw cypher staying 0-based

context() reports startLine+1 (editor/sed aligned); a raw cypher RETURN of the
same node keeps the stored 0-based value. Guards against double-conversion and
leaking the display shift into raw results.

Refs #2377

* fix(mcp): stop query() double-converting BM25 line numbers

bm25Search applied toDisplayLine to its result rows, and query()'s
aggregation loop applied it again, so BM25-matched symbols reported
lines shifted +2 (stored 0-based 41 read as 43, not 42) while
semantic-matched symbols were correct. bm25Search is called only from
query(); return raw 0-based rows and let the single aggregation-loop
conversion handle both retrievers.

Adds a query() BM25 regression test asserting stored 41 -> 42 (would
be 43 if double-converted), which the prior mcp-line-display test —
covering only context()+cypher — never exercised. (#2380, #2377)

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

* fix(mcp): use ?? not || so first-line symbols keep their line number

`sym.startLine || sym[4]` treated a legitimate 0-based startLine of 0
as absent, so context()/query() dropped startLine/endLine for every
symbol on line 1 of its file — every COBOL Module (toZeroBasedLine(1)
= 0) and markdown h1. `??` only falls through to the positional
fallback on null/undefined, preserving a real 0. This also repairs the
rename definition-edit path, which consumes context()'s value.

Adds a context() first-line (startLine:0 -> 1) assertion. (#2380, #2377)

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

* fix(mcp): make group/cross-repo trace line numbers 1-based consistently

A group/cross-repo trace presented 1-based endpoints (via
resolveSymbolForGroup) but 0-based hops (tagHops copies port.trace
output verbatim), so one response mixed bases. Wrap the trace port
adapter (traceForGroup) to convert hop lines to 1-based too, matching
the endpoints. Single-repo trace dispatches directly (not through this
port) and stays 0-based — full single-repo parity is a tracked
follow-up. core/group stays display-agnostic (no mcp import).

Extends the cross-trace e2e test to assert hops share the endpoints'
base (checkout 10 -> 11, getUsers 1 -> 2). (#2380)

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

* fix(mcp): present explain/pdg_query anchor line 1-based

resolveBlockAnchor converted its ambiguous-candidate lines to 1-based
but left the resolved-target anchor raw 0-based, so the same tool
reported two bases depending on whether the target was ambiguous.
Convert the display anchor to 1-based via toDisplayLine. The BasicBlock
join param (symStart: sym.startLine + 1) is untouched — it targets the
1-based BasicBlock id space, not display.

Asserts the resolved anchor is 1-based (targetFn stored 10 -> 11). (#2380)

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

* fix(mcp): bump schema + PDG result versions for the line-number change

The 0-based storage flip for COBOL/JCL/markdown/scope (#2377/#2379)
changed on-disk line semantics, and the PDG result startLine is now
1-based (#2380). Neither shipped a version bump, so an incremental
re-analyze would preserve old 1-based rows (mixed-base index rendered
one line too high) and PDG consumers got no signal.

- INCREMENTAL_SCHEMA_VERSION 5 -> 6 (forces a one-time full re-analyze)
- PDG_RESULT_VERSION 1 -> 2 (result-shape discriminator)

Updates the version-pinning tests, the pdgResultVersion result type,
and the tools.ts PDG output-contract doc. (#2380)

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

* test(group): guard manifest label list against SYMBOL_NODE_LABELS drift

manifest-extractor's CUSTOM_CONTRACT_RESOLVE_QUERY hand-lists the
contract-resolvable labels as a deliberate subset of the shared
SYMBOL_NODE_LABELS, guarded only by a comment — the same drift class
(#2379) the shared-set refactor eliminated elsewhere. Derive the
query's label set and assert it is a strict subset whose difference is
exactly {Namespace, Variable, Module}, so adding a symbol label without
a conscious manifest decision fails. Query string stays literal
(#2325-test-locked). (#2380)

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

* docs(mcp): document which tools present 1-based vs 0-based line numbers

The schema-resource note listed only context/query/impact as 1-based.
After the trace/anchor fixes it now enumerates the full set —
context, query, impact, group/cross-repo trace, and explain/pdg_query
anchors are 1-based; raw Cypher and single-repo trace stay 0-based
(full single-repo-trace parity is a tracked follow-up); BasicBlock/PDG
statement lines are separately 1-based. (#2377, #2380)

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

* test(mcp): pin impact() line-value display (close the coverage gap)

The prior mcp-line-display test only asserted context() + raw cypher,
which is why the query() double-conversion (#2380) shipped green. Adds
an impact() line-value assertion via the ambiguous-candidate path (the
only impact response that surfaces a per-candidate line): two same-name
symbols force ambiguity and the candidate at stored 0-based 41 must
read 42. (#2380)

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

* test(mcp): fix stale rename #2283 mock after 1-based context display

rename resolves its symbol via context(), which now presents startLine
1-based (#2377), then subtracts 1 to recover the 0-based file index.
The #2283 mock stored startLine:1 but put `oldName` on the file's line
0, so after the 1-based shift the definition edit no longer matched and
the write-failure path never fired — the test read 'success' instead of
'partial'. Align the mock content to its stored line (oldName on
0-based line 1). Pre-existing failure surfaced once ubuntu/coverage
completed on this branch. (#2380)

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

* test(mcp): consolidate line-display tests into one shared DB block

The query()/BM25 case had spun up a second full LadybugDB + FTS setup;
fold it into the single existing block (adding FTS + the Zqxwvbm seed
there) so the file builds one DB, not two. Trims per-file setup cost —
relevant to the Windows platform-sensitive suite's under-load 15-minute
timeout. Same five assertions, all green. (#2380)

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

---------

Co-authored-by: kigland <shuaizhicheng336@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:16:45 +01:00
Livio Gamassia
d546fa3cce
fix(storage): rename index metadata to gitnexus.json with dual-write compatibility (#2363) 2026-07-03 19:32:59 +01:00
Gergő Magyar
e148bc089a
fix(group): replace LadybugDB-incompatible multi-label Cypher (#2325) (#2327)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(group): use labels(n) IN allowlist instead of LadybugDB-incompatible multi-label Cypher (#2325)

manifest-extractor and http-route-extractor built Cypher with the openCypher
label disjunction `MATCH (n:A|B|C)`, which LadybugDB's parser rejects. The
error was swallowed by try/catch, so manifest contracts silently fell back to
synthetic UIDs with empty filePath and http-route cross-file handler
resolution silently returned null.

Replace all 7 queries with `MATCH (n) WHERE labels(n) IN [...]`. LadybugDB
returns labels(n) as a single string, so this is an exact allowlist — a 1:1
behavior-preserving syntax translation (validated against LadybugDB 0.17.1).
Export the two http-route query constants so integration tests can run the
exact production strings against a real DB, and add per-branch real-DB
regression coverage (the bug shipped because no test exercised these queries).

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

* fix(group): import CypherExecutor from contract-extractor in #2325 test

The new manifest regression test imported `CypherExecutor` from
`group/types.js`, which does not export it — the type is defined only in
`group/contract-extractor.js` (as all production extractors import it).
This was a real TS2305 under `tsc -p tsconfig.test.json`, masked from CI
because the default tsconfig excludes `test/` and `import type` is erased
at runtime. Split the import so the type resolves from its real module.

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

* test(group): run #2325 native-LadybugDB tests in the lbug-db project

Per TESTING.md, every test that opens a real `@ladybugdb/core` handle must
be registered in the sequential `lbug-db` Vitest project (and excluded from
`default`) to avoid native-mmap file-lock conflicts across parallel forks on
Windows. The two new group integration tests use `withTestLbugDB`/pool-adapter
but were in neither list, so they ran under the parallel `default` project.
Add both to `lbug-db.include` and `default.exclude`, matching every sibling.

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

* refactor(group): export custom-contract resolve query for #2325 test

The #2325 integration test hand-copied the 21-label `custom`-branch
resolve query into a local `LABELS_CUSTOM_QUERY` constant, so editing the
production allowlist would silently desync the canary. Promote the query to
an exported `CUSTOM_CONTRACT_RESOLVE_QUERY` (mirroring http-route-extractor's
exported query strings) and import it in the test, so the canary always runs
the exact production query. Behavior unchanged — same query string.

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

* test(group): de-brittle the #2325 custom-query label assertion

The unit test asserted a fixed 7-label ordered substring of the 21-label
custom-branch allowlist, coupling it to label order and no-space formatting —
a harmless reorder would have broken it. Replace with order/spacing-tolerant
membership checks for a spread of individual labels, keeping the unconditional
`not.toContain('Function|Method')` guard as the real regression check.

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

* test(group): correct #2325 http-route docstring + add real-trigger canary

The http-route test claimed `MATCH (n:Function|Method|CodeElement)` "which
LadybugDB rejects" — but that 3-label disjunction actually PARSES. Verified
against the real parser, the genuine #2325 trigger is a *reserved-keyword*
label in the disjunction: `Macro` and `Union` both are, and only the manifest
custom branch (21-label list) and the lib branch (missing `Package` table)
actually threw. The http-route conversion to `labels(n) IN [...]` was a
consistency change, not a parser fix.

Correct the misleading docstring and add a rejection canary pinned to the real
cause (`MATCH (n:Function|Macro|Union)` rejects), so a future query that
reintroduces a reserved-keyword disjunction is caught.

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

* test(group): cover the thrift package-strip path against a real LadybugDB

The thrift-only branch of resolveSymbol strips a `package.` prefix from the
service name (`com.example.AuthService` -> `AuthService`) before the
Class/Interface lookup — previously exercised only with a mocked executor.
Add a service-contract integration case (no method, so it takes the
package-strip path, not the grpc-identical method path) that resolves the real
`cls:AuthService`. Without the strip the lookup matches nothing and falls back
to a synthetic uid, so this is a non-vacuous guard for the strip.

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

* fix(group): drop vestigial 'Package' label from lib contract lookup

The `lib` branch allowlisted `labels(n) IN ['Package','Module']`, but there is
no `Package` node table (see NODE_TABLES) — the entry only ever matched
nothing. Restrict to `['Module']`, the label libraries actually resolve to.
Behavior-neutral: the lib integration case still resolves its Module symbol.

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

* docs(group): update PIPELINE label-scoped queries to labels(n) IN form

The resolveSymbol label-scoping bullets still showed the banned
`MATCH (n:A|B)` disjunction; a contributor copying them would reintroduce
#2325. Rewrite them in the actual `labels(n) IN [...]` form, note the real
trigger (LadybugDB rejects a disjunction naming a reserved keyword such as
`Macro`/`Union`), and reflect the lib allowlist as `['Module']` after dropping
the vestigial `Package` label.

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

* docs(group): correct #2325 root-cause comments in the extractors

The production comments claimed LadybugDB rejects the `MATCH (n:A|B)`
disjunction "outright". Verified against the real parser, it rejects only when
a label is a reserved keyword (`Macro`, `Union`) or names a missing node
table. So only the manifest `custom` branch (reserved keywords in its 21-label
list) and the `lib` branch (missing `Package` table) actually threw; the
http-route/grpc/thrift/topic disjunctions parse fine and were converted to
`labels(n) IN [...]` for consistency and future-proofing, not because they were
broken. Rewrite the comments to say so accurately. No behavior change.

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

* test(group): make #2325 test prose name the real reserved-keyword trigger

The manifest test docstring/title and the unit-test comment said LadybugDB
rejects the `MATCH (n:A|B)` disjunction generally. It rejects only when a label
is a reserved keyword (`Macro`/`Union`) or a missing table. Reword the docstring
(custom + lib branches threw; others parsed), retitle the rejection canary to
"its list names reserved keywords Macro/Union", and correct the unit-test
comment. The rejection canary still passes — the custom 21-label list does
contain Macro/Union. No behavior change.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:08:43 +01:00
Sparsh
028bd11053
fix(group): cache read-only bridge handle to fix Windows @group reopen (#2274) (#2313)
* fix(group): cache read-only bridge handle to fix Windows @group reopen (#2274)

A long-lived MCP server opened bridge.lbug read-only, queried, and closed
it on every @group trace/impact call. On Windows the in-process reopen of the
same file fails (the OS handle is not fully released before the next open races
in), so repeated @group calls broke. #2269 fixed Linux/macOS by skipping
CHECKPOINT on read-only handles; Windows stayed broken.

Instead of fighting LadybugDB's Windows close/reopen timing: cache one
read-only handle per groupDir and reuse it across calls (open-once-per-process
already works on Windows). getCachedBridgeReadOnly:
  - reuses a single handle keyed by resolved groupDir,
  - invalidates on mtime change (external writer / re-sync),
  - invalidates explicitly before same-process writes (writeBridge),
  - guards concurrent first-open with an in-flight promise (no handle leak),
  - closes all handles on process exit.

closeBridgeDb now no-ops for the cached handle (cache owns its lifetime);
uncached/writable handles are unaffected. ensureBridgeReady uses the cache.

The in-process write->read reopen of the same bridge.lbug file remains a known
LadybugDB Windows limitation, so the existing reopen tests stay win32-skipped.
A new cache-aware itCacheReopen gate applies to the 3 new tests whose setup
requires write-then-read in the same process (same class as itLbugReopen). The
cache itself exercises read->read reuse and is unaffected.

* fix(group): harden bridge RO-handle cache for concurrency, lifetime & Windows (#2313 review)

Addresses the tri-review + Copilot findings on the read-only bridge-handle cache:

- P1 (F2): serialize queryBridge per cached handle via a per-handle FIFO lock
  (the conn-lock.ts chain mechanic, keyed per cache entry, not the global lock).
  Two concurrent @group callers sharing one lbug.Connection can no longer
  dispatch two queries at once (the heap-corruption hazard). Uncached/writable
  handles skip the lock at zero cost.
- P1 (F3): refcount lease — getCachedBridgeReadOnly acquires, closeBridgeDb
  releases (no caller change). The native close is deferred until in-flight
  readers drain (refs===0) and runs exactly once (closeStarted guard).
  invalidateBridgeCache and the mtime-evict path share one evict/close path.
- Windows: bounded drain in evictBridgeEntry — a concurrent group_sync waits
  (<= WINDOWS_DRAIN_TIMEOUT_MS) for readers to release before the atomic rename
  on win32 so it stays clean; POSIX remains fully non-blocking; single-threaded
  sync still closes-before-rename on all platforms.
- P0 (F1/F6): gate the mtime cache test with itCacheReopen (win32-skipped) and
  drop the manual invalidate so writeBridge self-invalidation is under test;
  add an external-writer (fsp.utimes) reopen case.
- Windows coverage (F9): new cross-process integration test seeds bridge.lbug
  in a separate tsx process, so read->read handle reuse is proven on win32 CI
  (not skipped). Plus concurrent cold-open dedupe coverage.
- P2/P3: scope the Windows NOTE to read->read (F4); JSDoc the closeBridgeDb
  release/close contract (F5); drop the if-branch in the B2 probe (F7); revert
  incidental Prettier churn in cross-impact.ts (F14); fix the stale describe
  header (F15); document the beforeExit/signal and ENOENT-mtime behavior
  (F11/F13).

tsc clean; group unit + integration suites green.

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

* test(group): run the B2 rename-clash probe on win32 via cross-process seed (#2313 review)

Moves the B2 "external rename while a cached RO handle is held" probe out of the
unit suite (where it was win32-skipped, because its in-process writeBridge->RO-open
is the unfixed Windows reopen) into the cross-process integration test, where a
separate-process seed makes the RO open clean. The probe now RUNS ON WIN32 CI and
empirically answers whether an open RO handle blocks an external atomic rename over
bridge.lbug — the assumption under writeBridge's invalidate-before-rename and the
win32 drain.

Hardened (per adversarial review) so a win32 RED is the real steady-state share-mode
signal, not an artifact:
- use production retryRename (not bare fsp.rename) so transient EBUSY/EPERM from the
  Windows AV/indexer scanning the fresh temp file is absorbed; a RED then means the
  rename is blocked even after retries (FILE_SHARE_DELETE absent -> invalidate-before-
  rename is load-bearing).
- stage the byte-identical replacement BEFORE opening the RO handle, so no second OS
  handle touches bridge.lbug while LadybugDB holds it (avoids a FILE_SHARE_READ red for
  the wrong question).
- drop the post-rename query (handle survival is covered by the reuse test); the probe's
  sole verdict is whether the rename is blocked.

Removes the old win32-skipped unit B2 (a strict subset of the new probe).

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

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:49:47 +01:00
Gergő Magyar
1a03c8527a
feat(group): cross-repo call trace using PDG (#2269)
* refactor(group): extract shared resolveBridgeNeighbors from cross-impact

Lift the uid-filtered consumer<->provider ContractLink join (direction +
queryBridge + row normalization + confidence sort) out of runGroupImpact's
inline Phase-2 block into an exported resolveBridgeNeighbors helper. Behavior
is unchanged for impact; the helper becomes the single shared bridge join so
the upcoming cross-repo trace path never forks its own copy of the neighbor
Cypher. Empty uid sets short-circuit without a DB round-trip.

Adds direct coverage (real bridge via writeBridge/openBridgeDbReadOnly) for
both directions plus the empty-set and unknown-uid edges.

* feat(group): cross-repo trace stitching (groupTrace + runGroupTrace)

Add GroupService.groupTrace and the pure runGroupTrace engine that stitches
per-repo CALLS/HAS_METHOD trace segments across one ContractLink boundary in
the group bridge:

  from --(local trace)--> consumer --(ContractLink)--> provider --(local trace)--> to

- Resolves from/to across all members (symbol node id == bridge symbolUid);
  same-repo endpoints delegate to a single local trace with no crossing.
- Single boundary crossing (MAX_SUPPORTED_CROSS_DEPTH); deeper crossDepth is
  clamped with a note, mirroring cross-impact.
- Discriminated GroupTraceResult union (ok|not_found|ambiguous|error) with
  per-hop repo tags, a typed crossings[] entry, and centralized degraded-state
  note constants (TRACE_NOTES). No .
- Trace-specific pair query (keeps BOTH crossing endpoints) lives in this
  module; the uid-filtered neighbor join (resolveBridgeNeighbors) is reused
  where it fits. ensureBridgeReady exported for reuse.
- New GroupToolPort methods (trace/resolveSymbol/pdgFlows) are optional so
  existing port mocks keep type-checking; runGroupTrace guards on presence.

PDG enrichment is wired as an opt-in hook (enrichSegment) — the port method is
stubbed until U4. Covered by unit tests over a real bridge + mocked port.

* feat(group): route trace tool to groupTrace on @group syntax

Wire the cross-repo trace through the existing @group dispatch:
- callTool routes trace with an @-prefixed repo to callToolAtGroupRepo, which
  forwards from/to/uid/file/maxDepth/includeTests plus the experimental
  pdg/crossDepth flags to GroupService.groupTrace. Member path in @group/path
  is advisory for trace (resolution is whole-group).
- Port gains trace/resolveSymbol/pdgFlows adapters. resolveSymbolForGroup wraps
  the shared resolveSymbolCandidates so groupTrace can locate the member repo
  and recover each endpoint node id (== bridge symbolUid). pdgFlowsForGroup is
  a degraded stub here (call-level only); U4 implements the REACHING_DEF walk.
- trace tool schema documents the @group entry point, pdg, and crossDepth.

Single-repo trace is untouched. Covered by dispatch-routing tests (@group ->
groupTrace, non-group stays local) and tool-schema assertions.

* feat(group): opt-in PDG data-flow enrichment for cross-repo trace

Implement _pdgFlowsForGroupImpl: the real REACHING_DEF anchor walk that backs
the port pdgFlows adapter (replacing the U3 call-level stub). When pdg:true and
the segment repo has a flows PDG layer, the boundary-adjacent segments carry
their intra-procedural def->use hops:

- Anchors by the boundary symbol UID (precise; avoids the by-name ambiguity the
  resolveBlockAnchor path can hit), then reuses the same span-anchored,
  bind-param-only flows query as pdg_query (BasicBlock id-prefix + [start+1,
  end+1] line window; no rel-property index, so the anchor IS the bound).
- Stays intra-procedural: data flow never crosses the repo boundary.
- pdgStampForMode probe: false -> available:false (degrade with note); the
  trace stays ok. Any query failure is swallowed (enrichment is auxiliary).

Covered by runGroupTrace enrichment tests: dataFlow attached on opt-in,
degraded note when no layer, and no pdgFlows call when pdg is omitted.

* test(group): evaluation-first cross-repo trace e2e (two real indexes)

End-to-end gate for the cross-repo trace: stands up two real LadybugDB indexes
(consumer 'frontend' + provider 'backend'), a real ContractLink bridge, and a
real LocalBackend with both repos registered, then drives the public
callTool('trace', { repo: '@grp', pdg: true }) and asserts:
  - the stitched checkout -> callUsers -(CONTRACT_LINK)-> handleUsers -> getUsers
    path, each hop tagged with its member repo
  - real REACHING_DEF data-flow enrichment of the consumer segment (userId)
  - a degraded 'No PDG layer in app/backend' note (provider has no PDG layer)
  - single-repo trace against one member is unchanged (no crossings)

Hand-persists the minimal real graph (deterministic; a full two-repo analyze is
heavier than this gate needs) and exercises real Cypher across
resolveSymbolCandidates, _traceImpl, the bridge pair query, and
_pdgFlowsForGroupImpl. Windows-skipped (describeReopen) and registered in the
cross-platform native-lbug set.

Scoped to a single @group call: opening bridge.lbug read-only a SECOND time in
one process currently fails (shared bridge open/close lifecycle, also affects
impact @group) — the pdg-omitted/clamp variants are unit-covered.

* docs(group): document cross-repo trace + PDG enrichment

ARCHITECTURE.md: trace is now group-aware; describe the @group cross-repo
stitch over a single ContractLink boundary (CONTRACT_LINK hop, crossings[],
crossDepth clamp), the opt-in experimental PDG REACHING_DEF enrichment of
boundary-adjacent segments, the symbolUid-grain join between the two stores,
and the deferred full cross-program (SDG-like) data flow. PIPELINE.md: add the
cross-trace consumer of the bridge with its pair-query rationale.

Does not touch gitnexus/CHANGELOG.md (release-owned).

* fix(review): apply autofix feedback

Apply safe_auto findings from ce-code-review (run 20260622-094243):
- local-backend.ts: drop (r: any) in _pdgFlowsForGroupImpl row map; coerce
  hop line via Number() so a nullish LadybugDB cell can't surface NaN.
- tools.ts: advertise the forwarded  param in the trace schema and add
  crossDepth maximum:10 (schema now matches what groupTrace reads).
- cross-trace.ts: parallelize per-member resolveSymbol/resolveRepo with
  order-preserving Promise.all (matches groupContext/groupQuery); add a note
  when pdg:true is passed to a same-repo trace (PDG only enriches at a
  cross-repo boundary).
- tests: remove  / tighten  (no-any rule).

Residual gated_auto/manual findings (unbounded crossing query + loop,
whole-file PDG widening on absent span, error-vs-no_path masking, top-level
try/catch parity, helper dedupe, branch-coverage gaps) are recorded in the run
artifact for the PR body.

* fix(group): skip CHECKPOINT on read-only bridge close so it can reopen

Root cause of the in-process bridge.lbug reopen failure (which broke repeated
@group impact/trace calls in a long-lived MCP server): closeBridgeDb issued
CHECKPOINT on EVERY handle, including read-only ones. A CHECKPOINT on a
read-only connection has nothing to flush but leaves a WAL/shadow lock artifact
that makes the next read-only open of the same path fail (openBridgeDbReadOnly
returns null -> 'Could not open bridge.lbug read-only'). Reproduced: open ->
query -> closeBridgeDb -> open again returned null only when the close ran
CHECKPOINT; a non-checkpoint close reopened fine, and the raw native
open/close cycle was never the problem.

Fix: tag read-only handles (BridgeHandle._readOnly, set by openBridgeDbReadOnly)
and skip CHECKPOINT for them in closeBridgeDb. Writable handles are unchanged
(they still flush before close). This is the shared bridge-db close path, so
impact @group benefits identically.

- Regression test in bridge-db.test.ts: open/query/close/open/query/open in one
  process now succeeds.
- Re-enabled the second @group call in cross-trace-e2e.test.ts (was scoped to a
  single call for this very limitation).

* fix(group): bring bridge-db close to parity with the core adapter safeClose

The bridge open/close cycle was less robust than the main graph DB's: closeBridgeDb
closed the connection/database but skipped the post-close steps the core adapter's
safeClose performs, so a rapid in-process reopen could race the OS handle release
(Windows) or an orphaned WAL sidecar. That gap is why the close-then-reopen tests
had to skip Windows.

closeBridgeDb now mirrors safeClose after closing the handle:
- waitForWindowsHandleRelease(dbPath): probe the file (+ .wal) until the residual
  Windows lock clears, so the next open does not race (warns if the budget is
  exhausted, matching the core adapter).
- finalizeLbugSidecarsAfterClose(dbPath): quarantine an orphaned WAL (shadow
  missing) so the next open replays a consistent file.

Both helpers are the same ones safeClose uses (Windows-proven via the core adapter
CI), and the bridge read open already retries transient locks. Combined with the
read-only CHECKPOINT skip, the bridge reopen is now robust on every platform, so
the close-then-reopen tests run on all platforms (Windows CI exercises them via the
cross-platform subset). No write-path behavior change; Linux/macOS unaffected.

* fix(group): bound cross-repo crossing fan-out (LIMIT + segment memoization)

Address the top review residual: the bridge crossing query was unbounded and the
crossing-selection loop could run an O(2*N) sequential trace-BFS over every
ContractLink between a repo pair.

- CY_CROSSINGS_BETWEEN now ORDERs BY confidence DESC and LIMITs to
  MAX_CROSSINGS_TO_TRY + 1; listCrossingsBetween slices to the cap and reports
  truncation. Exceeding the cap surfaces a note (no silent truncation), keeping
  the highest-confidence crossings. Aligns with the repo's anchored+LIMIT-bounded
  query discipline (LadybugDB has no rel-property index).
- The home-repo segment (from -> consumer) depends only on the consumer uid and
  the target-repo segment (provider -> to) only on the provider uid, so each is
  memoized by that uid. Many crossings sharing a consumer/provider (one client
  call linked to several providers) now cost one trace per distinct endpoint
  instead of one per crossing. A consumer whose segment already failed is skipped
  for every later crossing that shares it.

Test: two links sharing a consumer (first provider unreachable, second reachable)
assert the from->consumer segment is traced exactly once and the second crossing
wins.

* fix(group): restore Windows skip for bridge reopen tests; drop ineffective close-side probe

The previous commit flipped the bridge close-then-reopen tests to run on Windows,
betting that a close-side waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose
probe (mirroring the core adapter safeClose) would make the in-process reopen work
there. Windows CI proved otherwise: 4 writeBridge->openBridgeDbReadOnly tests fail
('expected null not to be null' — the read open returns null). The writable-close ->
read-open handoff plus writeBridge's atomic sidecar rename does not release the OS
file handle before the read open races, and the existing open-side LBUG_OPEN_RETRY
only retries lock-pattern errors, not the post-rename sidecar database-id mismatch.
macOS passes; the core adapter's own reopen also passes — this is bridge+Windows
specific.

- Revert itLbugReopen to the Windows skip (the pre-existing, correct state).
- Remove the close-side probe + finalize from closeBridgeDb: it did NOT close the
  Windows gap, and reviewers flagged it for hot-path latency (finalize ran on every
  close, all platforms) and safeClose duplication.
- KEEP the load-bearing fix — skipping CHECKPOINT on read-only handles — which fixed
  the reproduced Linux/macOS in-process reopen artifact (the real bug).

Net: Linux/macOS repeated @group impact/trace works in-process; Windows in-process
bridge reopen remains a documented limitation (unchanged from before this PR).

* fix(group): surface degraded members + cap truncation; honest crossDepth schema

Address the cross-engine-corroborated tri-review findings (Codex + Claude):
- resolveAcrossMembers / runGroupTrace now track member repos that could NOT be
  queried (resolveRepo or resolveSymbol threw) and, when the result is not_found,
  attach a degraded-member note. A transient/corrupt member DB is no longer
  silently reported as a clean 'symbol absent' not_found. (Codex B1+B3 + ce-reliability.)
- The cross-repo not_found now carries a programmatic truncated:true flag (and a
  clearer suggestion) when the MAX_CROSSINGS_TO_TRY cap was hit, so a consumer can
  distinguish 'no path' from 'cap may have hidden a connecting ContractLink'.
  (Codex B3 + ce-adversarial + ce-api-contract.)
- trace tool schema: crossDepth maximum 10 -> 1 to match the implementation's
  single-hop clamp (the schema previously advertised an unsupported 2-10 range).
  (ce-api-contract, conf 100.)

Test: a member whose resolveSymbol throws yields not_found WITH a degraded note
naming the unreachable repo (if-free responder map).

* docs(group): clarify trace @group/memberPath is advisory (resolves all members)

Tri-review (Codex ce, conf 100) caught a doc/impl inconsistency: ARCHITECTURE.md
lumped trace with query/context/impact as honoring @group/memberPath member
scoping, but cross-repo trace resolves from/to across ALL members (the member
path is advisory). Clarify the behavior and point to from_uid/to_uid for
disambiguating same-named symbols across members.

* feat(group): file-level boundary fallback so cross-repo trace works on HTTP contracts

Benchmark (bench/cross-repo-trace/) running the REAL pipeline (runFullAnalysis
--pdg -> real syncGroup -> trace @group) found that cross-repo trace returned
not_found for real HTTP links even though sync built the correct ContractLinks:
HTTP (and other source-scan) contracts hardcode symbolUid:'' (http-route-extractor),
and both cross-trace AND cross-impact join crossings by Contract.symbolUid, which
never matches an empty uid. (Pre-existing — impact @group has the same gap.)

Fix: when a crossing's symbolUid is empty, fall back to the contract's FILE — if
the user's from/to resolves into the contract file, that endpoint anchors the
boundary. CY_CROSSINGS_BETWEEN now returns consumer/provider filePath; a crossing
is kept if it can be anchored by uid OR file on each side; a fileBoundaryFallback
note flags that the boundary is file-level, not symbol-precise. This makes the
common 'trace from=<calling fn> to=<handler fn>' case work end-to-end (verified:
fetchUsers -> listUsers stitches with a CONTRACT_LINK hop + PDG enrichment, 2/2).

Limits (documented in the bench README + the note): anonymous handlers have no
named target; when several contracts share files the file fallback may attach the
wrong contractId to a correct path. The proper upstream fix is to populate
symbolUid in the HTTP extraction (benefits impact too) — the bench is its gate.

Adds a unit test pinning the empty-symbolUid file-fallback stitch.

* fix(group): resolve HTTP contract symbolUid by containment (fixes cross-repo trace + impact)

Addresses the root cause behind the cross-repo trace file-fallback: HTTP
contracts hardcoded symbolUid:'' (http-route-extractor), so both cross-trace and
cross-impact — which join crossings on Contract.symbolUid — could not traverse
HTTP links. (Also found: the pre-existing graph-assisted resolution queried the
wrong edge, CONTAINS instead of DEFINES, so it never resolved a uid either.)

Now the extractor resolves each detection to a real symbol:
- HttpDetection carries the call-site line (node.ts sets it on every express/
  fetch/axios/jquery/nest detection; express also captures the handler arg).
- resolveDetectionSymbol resolves the named handler first, else the innermost
  Function/Method whose line span encloses the call (consumer = the function
  containing the fetch; provider = the named/inline handler), over the correct
  File-[DEFINES]->symbol edge. Base-tolerant (0- vs 1-based startLine).
- Wired into both source-scan and graph-assisted provider/consumer paths.

Verified end-to-end (bench/cross-repo-trace): all 4 contracts now carry real
uids, trace is symbol-precise (GET pair -> http::GET, POST -> http::POST, no
file-fallback note), and impact @group fans out (cross_repo_hits 0 -> 1). The
cross-trace file-level fallback remains as the secondary path for truly
anonymous handlers. Adds 2 containment unit tests; 738 group/integration pass.

Languages other than JS/TS still resolve providers by handler name; their
consumers fall through to the file fallback until their plugins set the line.

* fix(group): extend HTTP symbolUid containment to all languages + nested methods

Completes the symbolUid resolution across every bundled HTTP plugin: Python, Go,
PHP, Kotlin and Java now set the call-site line on their consumer (and Feign/
named) detections, so their HTTP contracts resolve to the containing function
the same way Node/TS already did.

Also generalizes the containment query: it now matches Function/Method/CodeElement
by filePath (UNION ALL) instead of File-[DEFINES]->symbol. The DEFINES edge only
reaches a file's TOP-LEVEL symbols, so methods nested in classes (Java/Kotlin —
File defines the class, the class defines the method) were invisible; matching by
filePath reaches them. Verified against a real index (LadybugDB supports the
UNION); JS/TS still fully symbol-precise (bench 2/2), 709 group tests pass.

Residual is now only the inherent case — a fully anonymous handler with no named
callee — which keeps the cross-trace file-level fallback.

* feat(group): destination trace — follow a consumer to an anonymous handler

Handles the one inherent residual: an anonymous route handler
(`router.get('/x', (req,res) => …)`) has no symbol node at all (the file holds
only a Const + PDG BasicBlocks), so it can never be named as a trace `to`.

Adds a DESTINATION TRACE: omit to/to_uid/to_file on an @group trace and
`trace from=<consumer>` follows the consumer's outgoing HTTP call across the
bridge and reports where it lands — by route + file:line, with a notes[] entry
flagging the handler as anonymous. Implemented as a new branch in runGroupTrace
(p.destination) backed by CY_CROSSINGS_FROM (all ContractLinks leaving the
consumer repo) + stitchToDestination; the provider endpoint is labelled
'<METHOD /path handler>' when its symbolName is a generic token/file basename.

The MCP routing already omitted an absent `to`, so only the schema docs changed.
parseTraceParams now treats a missing `to` as a destination trace instead of an
error. Verified end-to-end: anonymous fixture reports
'app/frontend:fetchUsers -> app/backend:<http::GET::/api/users handler>'; named
fixture lands at the real function. Adds 2 unit tests; 915 group tests pass.

* fix(group): tri-review fixes for cross-repo trace + symbolUid resolution

Two-engine tri-review (Claude swarm+ce + Codex GPT-5.5 swarm+ce+adversarial)
surfaced these; cross-engine-corroborated unless noted.

Correctness (P1, all four lanes): destination trace reported the WRONG endpoint
— an empty-uid consumer made trace(from->from) trivially succeed, so the highest-
confidence same-file crossing won regardless of which call `from` makes.
stitchToDestination now collects ALL connecting crossings, prefers symbol-precise
hits, and returns `ambiguous` (with candidates) when it cannot disambiguate.

Correctness (P1, Codex): resolveDetectionSymbol early-returned null when
d.line==null, blocking NAME resolution for named providers that set no line
(Spring/Go/etc.). Name resolution now runs first; only containment needs a line.

Correctness (P2): resolveContainingSymbol OR-ed `line` and `line-1`, which could
mis-pick a one-line sibling. It now probes the base-correct `line-1` first and
falls back to `line` only if nothing matches.

Correctness (Codex): anonymous Express handlers emitted name:'handler' and could
attach to an unrelated fn literally named `handler`. node.ts now emits name:null
for non-identifier handlers (containment-only).

Robustness: drop the first-symbol-in-file pickSymbolUid guess from the graph
consumer/provider paths (a wrong uid would win the contractId merge); remove the
dead CONTAINS_QUERY fallback (CONTAINS is File->Folder, never a symbol) + the now
-unused pickSymbolUid/handlerName; seed destination notes with degraded-member
notes so a successful trace still surfaces them; providerLabel takes providerUid
so a resolved fn named `handler` is not mislabeled anonymous, and only true file
basenames (known extensions) — not any dotted name — count as anonymous.

API contract: a single-repo trace with no `to` now returns an actionable error
(destination trace is @group-only) instead of "symbol 'undefined' not found".

Maintainability/tests: narrow asLocalTrace per-field (drop as-unknown-as); fix the
PR's lone as-any (vi.mocked); if-free e2e teardown; qualify the bench README.

Adds ambiguous-destination, anonymous-handler-no-false-name, and single-repo-no-to
tests; redirects graph mocks CONTAINS->UNION ALL. 918 group/integration pass.

* fix(group): carry degraded-member notes through SUCCESSFUL group traces

A reviewer (koriyoshi2041, PR #2269) correctly flagged that degraded-member
resolution was surfaced only on not_found, not on a successful ok result. Group
trace resolves names across ALL members, so an ok is 'unique among the members
we could query' — if a member that threw during resolveSymbol also holds from/to,
the real answer could be ambiguous. The destination path already seeded the note
(prior commit); this extends it to the same-repo and cross-repo success paths by
seeding the dispatch notes with degradedNotes([...fromRes.degraded, ...toRes.degraded]).

Adds a regression test: reg-be throws while a same-repo trace succeeds in reg-fe;
the ok result now carries the 'could not be queried' degraded note (app/backend).

* test(bench): cover all implemented cross-repo trace cases in one runner

Replace the single named-handler script with a self-contained verify.mjs that
generates each fixture inline and exercises every implemented end-to-end case
against the real analyze -> sync -> trace/impact pipeline, asserting PASS/FAIL
(exit non-zero on failure). 10 checks across 4 scenarios:
- named handlers: 4/4 symbolUid resolved; symbol-precise GET vs POST crossing
  selection; destination trace lands at the named handler.
- anonymous handler: empty symbolUid; destination trace reports it by route with
  the anonymous note.
- impact @group fan-out (cross_repo_hits >= 1).
- multi-language (Python Flask + requests): link built, cross-repo trace stitches,
  and the file-level boundary fallback is exercised when the provider has no uid.

Ambiguous-destination and degraded-member paths need synthetic inputs the real
analyzer cannot produce, so they stay in the unit suite (documented in the README
+ script header). Removes verify-named.mjs + fixtures-named/ (folded inline).

* test(group): pin destination degraded-success + precise-tier ambiguity

Adds the two regression guards koriyoshi2041 requested on PR #2269 after the
degraded-on-success fix:
- destination trace success with a degraded member: reg-fe resolves from and
  follows the link to an anonymous handler while reg-be throws; the ok result
  carries the anonymous endpoint AND the 'could not be queried' degraded note, so
  the no-to path stays aligned with explicit to traces.
- multiple PRECISE destination hits: one from reaches two consumers with resolved
  uids linked to different routes; the result is ambiguous (role: to) with both
  route candidates. Distinct from the existing file-level ambiguous test, this
  pins the stronger precise tier against a future change silently picking the
  highest-confidence destination.

Both already pass against current behavior; 716 group tests pass.
2026-06-23 07:54:13 +01:00
azizur100389
ff0124e067
feat(cpp): parse CUDA source extensions (#2213)
* feat(cpp): parse CUDA source extensions

* test(cpp): characterize CUDA parser limitations

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-16 07:32:53 +01:00
WENJIE HUANG
32b5c0e3fc
feat: add IncludeExtractor for C++ cross-repo include tracking (group) (#1156)
* feat: add IncludeExtractor for C++ cross-repo include tracking (group)

* fix: address CodeQL warnings on include-extractor

- Remove unused HEADER_GLOB constant in include-extractor.ts
- Use fs.mkdtempSync for secure temp dir creation in tests
  (CodeQL: 'Insecure temporary file')

* fix(group): close missing ); in manifest-extractor include branch

The 'include' branch in ManifestExtractor.resolveSymbol was missing
the closing ); for the executor() call, causing a syntax error that
broke ESLint, Prettier, and the full test CI on all platforms.

Reported by Claude PR review on #1156.

* chore: drop test/global-setup.ts + test/vitest.d.ts

Upstream removed these in commit 3f0c74fe (ladybugdb 0.16.0 upgrade).
Commit 3f5d21c5 accidentally restored them during a rebase dance.

* style(group): reformat VALID_CONTRACT_TYPES array to satisfy prettier

Adding 'include' pushed the array over prettier's 100-char limit,
so prettier prefers multi-line. Apply the reformat to unbreak
ci-quality/format job.

* fix(include-extractor): address PR #1156 Claude review findings #3-#7

Claude Deep Review raised 7 findings on the IncludeExtractor. #1/#2
(BLOCKERs) were fixed earlier. This commit closes the remaining five.

#3 HIGH  case-sensitive FS -> provider contract-id collision
  Document the deliberate case-folding trade-off on normalizeIncludePath
  (matches C/C++ convention on Windows/macOS; collapses Foo.h & foo.h on
  Linux). Add a unit test pinning the behavior.

#4 HIGH  suffixResolve short-suffix match silently drops cross-repo include
  When a local file ends with the same basename as an external include
  (e.g. local internal/api.h vs. #include "ext/api.h"), suffixResolve
  returned a bogus local hit and suppressed the cross-repo consumer.
  Replace the suffixResolve lookup inside include-extractor with a
  strict isLocalInclude() that only accepts full-path hits via
  SuffixIndex.get / getInsensitive. Callers of suffixResolve elsewhere
  are unaffected. Add 3 unit tests covering the regression.

#5 MEDIUM regex fallback matched #include inside /* ... */
  Strip block comments before running the fallback regex scan.
  Add a unit test.

#6 MEDIUM meta.source was hard-coded to 'tree_sitter'
  Track the actual extraction path with an extractionSource local and
  write it into meta.source so downstream audits can distinguish
  tree-sitter parses from regex fallbacks. Add 2 unit tests.

#7 MEDIUM missing end-to-end coverage
  Add test/integration/group/include-extractor-sync.test.ts with 3
  cases exercising extractor -> syncGroup -> CrossLink (mocked
  contracts, mixed-case/backslash normalization, real temp repos).

Tests: 21 unit + 3 integration, all green.

* fix(lbug): robust Windows lock acquisition for CI integration tests

LadybugDB's `new Database()` raises `Could not set lock on file` from
local_file_system.cpp synchronously inside the constructor — before any
query is issued, so `withLbugDb`'s query-time retry never sees it. On
Windows CI this surfaces as flaky integration tests due to AV-scanner
holds, libuv handle-release lag, and stale `.wal` sidecars from aborted
prior runs.

This change closes the gap at *open time*:

- `openLbugConnection` now wraps `new lbug.Database()` in a bounded
  busy-retry (5x100ms back-off) inside `lbug-config.ts`. Errors that
  exhaust the budget are tagged via `LBUG_OPEN_RETRY_EXHAUSTED` so
  `withLbugDb`'s outer 3x retry skips re-retrying a freshly-exhausted
  path (eliminates the 3x5=15-attempt / ~6s tail latency).
- For recognized test fixtures only (immediate-parent dir matches a
  known prefix AND resolves under `os.tmpdir()`), one final stale-
  sidecar sweep removes `.wal`/`.lock` and retries once. Production
  paths never enter this branch.
- `safeClose` on Windows runs a bounded `fs.open` probe to absorb
  native handle-release lag; logs a warning if the probe exhausts so
  operators can spot AV interference.
- `isDbBusyError` is now defined in `lbug-config.ts` as the single
  source of truth, re-exported from `lbug-adapter.ts` for compatibility.
- New tests cover open-time retry (happy/retry/exhaust/non-busy/tag),
  stale-sidecar sweep (test-fixture-only, production-rejection,
  preserves-original-error), `isTestFixturePath` direct unit suite
  (accept/reject/traversal/nested/trailing-sep), and
  `waitForWindowsHandleRelease` (openable/ENOENT/no-leak).
- The two new test files are added to vitest's existing serialized
  `lbug-db` project (already `fileParallelism: false`).

Closes the chronic Windows CI flake on lbug-touching integration tests
while preserving the existing single-writable-Database-per-process
LadybugDB contract. No public API surface changed.

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

* refactor(lbug): drop isDbBusyError re-export, import from lbug-config directly

The re-export from lbug-adapter.ts was a transitional convenience — with
the matcher now living in lbug-config.ts, having two import paths for the
same symbol invites future drift. Updated the two real consumers
(lbug-lock-retry.test.ts, lbug-open-retry.test.ts) to import from
lbug-config directly, removed the re-export equality test (now vacuous),
and refreshed the explanatory comment so it no longer references a
re-export pattern that doesn't exist.

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

* fix(lbug): silence benign LadybugDB v0.16.1 schema-init lock warnings on Windows

doInitLbug logs "⚠️ Schema creation warning: ... Could not set lock on
file" on every CREATE NODE TABLE call after the first init on a given
dbPath, on Windows. The lock is internal to LadybugDB v0.16.1 and is
resolved before the table is created — same tolerance pattern as the
existing "already exists" filter. Genuine cross-process lock contention
still surfaces on the next operation through withLbugDb's retry, so
filtering at the schema-init catch only suppresses noise, not signal.

Also extend the safeClose Windows handle-release probe to cover the
.wal sidecar (the previous Database's WAL handle was the slowest to
release, surfacing as the schema-query lock contention) and switch the
probe back to 'r+' so it actually detects exclusive locks.

Test loop in lbug-close-handle-release.test.ts simplified to 10 plain
iterations now that the underlying noise is filtered upstream.

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

* chore(lbug): isDbBusyError review fixes

- Drop redundant `could not set lock` term — already subsumed by `lock`.
- Document the intentionally-broad matcher: graph-DB lock-shaped errors
  ("deadlock", "unlock failed", "lock contention", "could not open lock
  file") are all treated as transient. If a non-transient surfaces,
  tighten the matcher rather than raise the retry budget.
- Add positive test cases covering those lock-shaped strings so the
  intent is visible and a future tightening would deliberately break
  these.
- Fix the open-retry back-off comment: max sleep is 100+200+300+400 =
  1000ms (no sleep after the final attempt), not 1.5s.

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

* fix(group): address PR #1156 follow-up review findings

Addresses two blockers and two mediums from the deep review.

BLOCKER 1: Windows CI ENOTEMPTY in sync.test.ts
  After this PR added writeBridge() to syncGroup, the existing test
  "writes registry to groupDir when skipWrite is false" fails on
  windows-latest. LadybugDB's checkpoint thread briefly outlives
  closeBridgeDb, holding a Win32 lock on bridge.lbug; the test's
  fs.rmSync then fails with ENOTEMPTY. Switched the test cleanup to
  cleanupTempDir from test/helpers/test-db.ts which already tolerates
  EBUSY/EPERM/EACCES/ENOTEMPTY with bounded retries — same pattern
  used elsewhere for LadybugDB-touching tests.

BLOCKER 2: Graph provider absolute-path bug
  extractProvidersGraph queried File.filePath from the LadybugDB graph
  but never stripped the repo root, so provider contract IDs ended up
  as include::/abs/path/foo.h while consumers emitted include::foo.h.
  These never matched through runExactMatch — silently producing 0
  cross-links for any indexed C++ repo (the primary use case).
  Now passes repoPath into extractProvidersGraph and applies
  path.relative(); rows that resolve outside repoPath (stale absolute
  paths from another machine, system headers somehow indexed) are
  dropped instead of polluting the registry.

MEDIUM: `../` relative includes produce spurious noise
  `#include "../foo.h"` is almost always intra-repo, but the suffix
  index can never match a `..`-prefixed path so it became a consumer
  contract no provider could satisfy. Now skipped before matching;
  covers both forward-slash and backslash forms.

MEDIUM: writeBridge error in sync.ts propagates uncaught
  contracts.json is the canonical source of truth and was just written
  successfully when writeBridge runs. A bridge-only failure (disk full,
  schema error, permission denied) shouldn't mask the registry. Wrapped
  writeBridge in try/catch with a logger.warn surfacing the path and
  recovery instructions.

Tests added:
  - extractProvidersGraph repo-relative ID generation (stub Cypher
    executor returns absolute paths)
  - extractProvidersGraph drops rows whose path resolves outside repo
  - `../foo.h` forward-slash skip
  - `..\foo.h` backslash-form skip

Skipped findings:
  - canExtract() removal (#5, low): canExtract is part of the
    ContractExtractor interface; every other extractor implements the
    same `return true` shape. Removing it from IncludeExtractor would
    break the interface contract — keeping for consistency.

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

* fix(group): close PR #1156 Codex adversarial findings

Two HIGH findings from the Codex adversarial review on
feat/group-include-extractor:

1. Default-on extraction silently changes existing groups (BLOCKER)
   DEFAULT_DETECT.includes was true, so any pre-existing group.yaml
   that omits the new field would gain a wave of include::* contracts
   on the next sync after upgrade. Flipped to false (opt-in). The
   integration test already declares includes: true explicitly so it
   survives unchanged; the unit extractor tests bypass parseGroupConfig
   entirely; the sync test uses extractorOverride. Only config-parser
   needed regression tests covering omitted/explicit/false variants.

2. IncludeExtractor scans outside the indexed file universe (BLOCKER)
   The extractor was running glob('**/*', { ignore: STANDARD_IGNORES })
   twice with a hand-rolled 9-pattern list, no .gitignore/.gitnexusignore
   honoring, and no max-file-size cap. That meant File:<path> contracts
   could appear for files ingestion would never index, producing
   cross-links group impact cannot fan out to (silent false-negatives).
   Refactored to a single discoverIndexableFiles() helper that mirrors
   walkRepositoryPaths exactly: createIgnoreFilter + getMaxFileSizeBytes,
   one discovery pass shared by provider and consumer paths. Dropped
   STANDARD_IGNORES and SOURCE_GLOB entirely.

   third_party and 3rdparty (the C/C++ vendored-deps conventions) were
   in the local ignore list but not in the canonical DEFAULT_IGNORE_LIST
   used by ingestion. Folded both into the canonical set rather than
   keep a parallel list — the whole point of the Codex finding is that
   two file-discovery implementations drift. Single source of truth.

Tests: 5 new regression tests for the discovery alignment (.gitignore,
.gitnexusignore, max-file-size on both provider and consumer paths)
plus 4 for the opt-in default. All 30 include-extractor tests + the
494-test group suite + ignore-service tests pass.

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

* fix(review): apply autofix feedback

ce-code-review surfaced 6 safe_auto findings on commit a9936a9b:

- T1 (testing, P2): the sync.ts:174 gate was untested with includes:false.
  Added a sync-level test mirroring the existing thrift-off pattern at
  sync.test.ts:545, asserting zero include contracts when the gate is
  disabled in a real syncGroup call.

- T3 (testing, P3): third_party and 3rdparty entries in DEFAULT_IGNORE_LIST
  had no regression test. Added both to ignore-service.test.ts's
  dependency-directories it.each block.

- M1 (maintainability, P3): discoverIndexableFiles JSDoc lacked a
  fork-warning relative to walkRepositoryPaths. Added a MAINTENANCE
  note explaining why the duplication is tolerated and the contract
  the two implementations must keep.

- M2 (maintainability, P3): thrift-extractor still hand-rolls its
  ignore array with no signal that DEFAULT_IGNORE_LIST additions
  silently do not apply there. Added TODO(#1156-followup) comments
  above both call sites.

- M3 (maintainability, P3): SOURCE_EXTENSIONS duplicated the four
  HEADER_EXTENSIONS entries with no expressed subset relationship.
  Spread HEADER_EXTENSIONS into SOURCE_EXTENSIONS so future header-
  extension additions propagate.

- C1+T4 (correctness+testing, P3, cross-reviewer corroborated):
  discoverIndexableFiles swallowed all fs.stat errors silently,
  including EACCES/EMFILE/EIO. Narrowed the catch to ENOENT (the
  documented benign glob/stat race) and added a logger.warn for
  any other code so operators can spot permission/resource issues.

All 629 tests pass; typecheck + prettier clean.

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

* fix(group): use retryRename in writeContractRegistry to absorb Windows EPERM

`storage.ts:62` used raw `fsp.rename` for the contracts.json atomic swap.
On Windows, AV scanners and concurrent renames briefly hold the
destination handle between rename calls, surfacing as EPERM/EBUSY.
The `insecure-tempfile.test.ts > concurrent writes do not collide`
test was flaking with `EPERM: operation not permitted, rename` on
windows-latest CI.

`bridge-db.ts` already has a battle-tested `retryRename(src, dst, 3)`
helper used at six call sites for exactly this pattern. Reusing it
here keeps the Windows-rename policy single-source-of-truth across
the group package.

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

* fix(group): drop macro-style #include from consumer contracts

Tree-sitter's `(_) @import.source` wildcard matches the identifier node
of `#include PLATFORM_HEADER`, so the cleaned value `PLATFORM_HEADER`
slipped past the system-header / `..` filters and was emitted as a
permanently orphaned consumer contract (no file is named after a macro
identifier, so no provider can ever match). Add a shape guard that
skips cleaned values lacking both a path separator and an extension
dot, plus regression tests for single and multi-macro files.

Also document `IncludeExtractor.canExtract()` as unused by sync.ts
(gated via `config.detect.includes` instead) and kept solely for
ContractExtractor interface uniformity.

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

---------

Co-authored-by: HuangWenjie <zhoudeng.hwj@alibaba-inc.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:31:59 +01:00
ivkond
00966630c4
feat: cross-repo impact analysis (#794) — @repo MCP routing + group resources (#984) 2026-04-20 11:55:07 +01:00
ivkond
255e3e79eb fix(group): address 4 HIGH-priority issues from PR #626 review
1. Path traversal via group name — add validateGroupName() with regex
   [a-zA-Z0-9][a-zA-Z0-9_-]*, called in getGroupDir (defense in depth)

2. gRPC proto regex can't handle nested braces — replace serviceRe with
   extractServiceBlocks() brace-depth counter (init depth=1, skip
   malformed protos)

3. Service boundary detector directory exclusions — add EXCLUDED_DIRS
   set (vendor, target, build, dist, __pycache__, .venv, venv, .tox,
   .mypy_cache, .gradle, .mvn, out, bin) replacing inline node_modules

4. Double-close of LadybugDB pools — remove blanket closeLbug() from
   cli/group.ts; sync.ts per-id cleanup is sufficient

Tests: 22 new tests across 5 files. Full suite: 4706 passed, 0 failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:33 +03:00
ivkond
4fed097abb feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture
Wire extractors into the sync pipeline with service boundary detection.
GroupService provides high-level API for all group operations.

- Sync pipeline: orchestrates extraction (HTTP, gRPC, topics) with
  service boundary assignment and exact matching
- GroupService: groupList, groupSync, groupContracts, groupQuery,
  groupStatus (groupImpact deferred to cross-repo follow-up PR)
- CLI: group create/add/remove/list/sync/contracts/query/status
- MCP tools: group_list, group_sync, group_contracts, group_query,
  group_status
- Monorepo fixture: 3 services (auth/orders/gateway) connected via
  gRPC + Kafka + HTTP — all intra-repo cross-links discovered
- Documentation: CLI commands and MCP tools added to both READMEs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:31 +03:00