* 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>
* feat(schema): derive a fingerprint from the DDL this build creates
`SCHEMA_FINGERPRINT` is a sha256 digest of the node and relation DDL that
`runSchemaCreationQueries` actually executes, in the same shape as the existing
`taintModelVersion` stamp (hex, sliced to 12).
It exists because `INCREMENTAL_SCHEMA_VERSION` is hand-picked and has to
*predict* whether an on-disk database matches this build's DDL. That number has
collided with `main` eight times, twice exactly — and an exact clash is the
quiet one, because the reuse gate is a strict `===`.
`EMBEDDING_SCHEMA` is deliberately excluded: its `FLOAT[N]` width comes from
`GITNEXUS_EMBEDDING_DIMS` at module load, so folding it in would make the
digest a function of the environment rather than of code, and two runs of the
same build under different env would thrash full rebuilds.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(storage): record the DDL fingerprint in RepoMeta
`RepoMeta.schemaFingerprint` stores the digest of the DDL an index's tables
were actually created from. It is the derived companion to `schemaVersion`,
not its replacement: both are compared, and both must match.
Absent means mismatch, deliberately. Grandfathering a missing fingerprint
would let an incremental top-up stamp a fresh one onto a database whose DDL
was never verified, permanently certifying exactly the wrong-shaped index the
field exists to catch. The cost is one full rebuild per pre-existing index.
The version ladder gains a note that its "re-check against origin/main before
merge" ritual now only guards *semantic* bumps. v25, v26, v30, v31 and v34 all
changed emitted ids, edges or wire formats while leaving the DDL byte-identical,
and the fingerprint cannot see any of them — but DDL collisions no longer need
renumbering.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(analyze): gate index reuse on the DDL fingerprint, not just the version (#2798)
`INCREMENTAL_SCHEMA_VERSION` is a hand-incremented integer that has to predict
a derived fact: whether the on-disk DDL matches the code's DDL. It has collided
with `main` eight times, and twice the collision was *exact*.
An exact clash is the silent one. Two builds stamp the same number over
different DDL, the `===` reuse gate reads the index as current, every
`CREATE ... TABLE` is then skipped as "already exists" (suppressed in
`runSchemaCreationQueries`), and the edges whose endpoint pair the live database
cannot hold are dropped by `fallbackRelationshipInserts`' bare `catch`. The
result is a wrong graph, with no error anywhere.
Reuse now requires the version AND the DDL fingerprint to match, in both the
pre-pipeline force-rebuild guard and the `isIncremental` predicate, and the
fingerprint is stamped alongside the version at the end of a run.
Both conditions are necessary. The fingerprint does not replace the integer:
most entries in the version ladder change emitted ids, edges or wire formats
while the DDL stays byte-identical, and a fingerprint-only gate would stop
forcing rebuilds for all of them. What it does buy is that two branches picking
the same number no longer need renumbering.
The new branch sits above the `alreadyUpToDate` fast path for the same reason
the version guard does — a clean tree at an unchanged commit would otherwise
early-return before either check ran.
Closes#2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(analyze): pin the DDL fingerprint gate and its two failure cases
`schema-fingerprint.test.ts` pins the properties the gate rests on: the digest
covers exactly the node and relation DDL that gets executed (recomputed from
the exported lists, so adding a table or a FROM/TO pair without the fingerprint
moving is impossible), it excludes the environment-derived embedding DDL, and
it moves when any covered string moves.
The two `incremental-orchestration` cases exercise the production path rather
than modelling it: an index carrying the *current* version with a foreign
fingerprint, and one with no fingerprint at all. Both were run against the
pre-fix tree first and both failed there with `alreadyUpToDate === true` —
the fast path swallowing the mismatch, which is the #2798 symptom exactly.
`call-summary-schema-version.test.ts` widens its gate model to two equalities.
The second argument defaults to the current fingerprint so all 33 existing
version cases read unchanged, and a new case covers the collision, the legacy
absence, and the semantic bump the fingerprint cannot see.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(review-skill): point the schema-constant check at the fingerprint, not the deleted integer
All four `gitnexus-review` SKILL.md mirrors told reviewers to verify
`INCREMENTAL_SCHEMA_VERSION` "was bumped or regenerated". That constant no longer
exists, so the instruction sent every future reviewer looking for something they
could not find — and, worse, past its replacement.
The check for graph DDL is now derived: `SCHEMA_FINGERPRINT` moves on its own, so
the question is whether the diff changed a string in `NODE_SCHEMA_QUERIES` /
`REL_SCHEMA_QUERIES`, and whether a newly added DDL array was folded into the
fingerprint at all — the one way the derived gate can still be bypassed.
What did NOT change is called out explicitly: the parse-store `SCHEMA_BUMP` and
the bench fingerprint sets are still hand-maintained and still need the
re-check-against-base ritual, and semantic changes that leave the DDL untouched
fall outside the fingerprint entirely — those rely on the analyzer runner-identity
receipt.
Found by the review swarm's docs lane. The original plan for #2798 claimed no
documentation mentioned the constant; that sweep covered five root docs and never
looked at `.claude/skills/**` or the three mirrors.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(migration): record the one-time rebuild the fingerprint switch costs
Replacing `schemaVersion` with `schemaFingerprint` means every index written by
an earlier GitNexus carries no fingerprint, reads as a mismatch, and is rebuilt
once. That is deliberate — grandfathering absence would stamp a fresh fingerprint
onto a database whose DDL was never verified — but until now it was undocumented,
so a user's first post-upgrade analyze would announce a full re-analyze with
nothing to explain it.
MIGRATION.md already sets the precedent: PR #2363's meta.json → gitnexus.json
rename was equally automatic and equally in need of an entry. This follows that
shape, and is explicit about the parts that are easy to undersell:
- the cost is per INDEX, and branch-scoped slots (#2106) each pay separately;
on a large repository a full re-analyze is substantial, not a blip;
- rollback is safe — an older binary sees no `schemaVersion` and forces its own
rebuild, which is a cost, never a stale graph;
- alternating between an old and a new binary rebuilds on every switch, because
the end-of-run meta is written as a fresh literal so neither field survives the
other's run.
The retired ladder's per-version rationale is pointed at in git history rather
than reproduced: `git show 561f913a3:.../repo-manager.ts`. That commit is an
ancestor of origin/main, so the pointer survives this branch being squash-merged.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(identity): cover workspace-linked packages in the analyzer dependency digest
`dependencyNames` enumerated `dependencies`, `optionalDependencies` and
`peerDependencies` only. `gitnexus-shared` is declared as a devDependency
(`file:../gitnexus-shared`), and in a source-mode run the build root is the
gitnexus package tree, which does not contain that sibling. So a change to
gitnexus-shared moved neither `build.digest` nor `dependencyRuntime.digest`.
That gap matters more since #2798 deleted `INCREMENTAL_SCHEMA_VERSION`. A
DDL-affecting edit there is still caught by `SCHEMA_FINGERPRINT`, but a
SEMANTIC-only edit — a new `REL_TYPES` member, say, where the relation table
carries a bare `type STRING` column so no CREATE statement moves — was covered by
nothing at all. Roughly thirty of the retired ladder's entries were exactly that
change class, and the runner-identity receipt is what now carries them.
Only checkout-local specifiers are added: `file:`, `link:`, `workspace:`,
`portal:` and npm's bare local-path shorthands. Pulling in every devDependency
was rejected — vitest, eslint and typescript would enter the digest and force a
full re-analyze on unrelated tool bumps, which is worse than the hole.
Scanning the linked sibling for the first time exposed a latent throw:
`collectArtifacts` honoured `PRUNED_RUNTIME_DIRECTORIES` only for a real
directory, so a SYMLINKED `node_modules` fell through to the payload branch and
died with "Analyzer identity input is not a file". Worktree-style dev layouts and
pnpm shared stores hit this immediately — verified in this worktree, where
`gitnexus-shared/node_modules` is such a symlink. Pruning it loses nothing:
packages beneath are still reached through `resolveDependencyPackageRoot`.
Verified: a real `analyze` in this worktree succeeds with `packageCount` 259;
editing the linked package's source moves the digest, bumping an installed
registry devDependency does not, and removing the link moves it.
`DEPENDENCY_RUNTIME_CANONICALIZATION` is deliberately not bumped — freshness
compares digests, not the label, and the input-set change already moves them.
Follow-up worth having: no fixture in the suite declares `devDependencies`, so
this has no regression test yet.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(analyze)!: delete INCREMENTAL_SCHEMA_VERSION, gate reuse on the DDL fingerprint alone
The integer and its ~180-line version ladder are gone, along with
`RepoMeta.schemaVersion`. Index reuse is now decided solely by
`SCHEMA_FINGERPRINT`; a mismatch — including the absent stamp every pre-existing
index carries — warns and forces a full re-analyze, which wipes and recreates the
database so the tables are built from the current DDL.
Deleting the integer is safe because it was already redundant: the
runner-identity guard deep-compares the whole schema-v4 receipt, including a
digest over the build tree, and forces a rebuild on ANY analyzer delta. Verified
empirically — a comment-only edit to logger.ts, with the fingerprint byte
identical, produced "runner identity changed ... forcing a full rebuild".
The fingerprint is not thereby redundant. It fires where that guard cannot: a
DDL-affecting change in `gitnexus-shared`, which is a workspace-linked
devDependency and so sat outside both digests until the companion commit closed
that gap.
Review findings folded in, each correcting a line this rewrite itself introduced
and never published:
- B1: two assertions matched a log string the rewrite had renamed; both tests
failed. They now assert what production emits.
- B2: the pre-existing downgrade test perturbed `schemaVersion: 7`, a field this
change deletes, so the spread carried a valid fingerprint, every guard passed,
and the run legitimately took the fast path. It perturbs the fingerprint now,
restoring the only integration coverage of the gate-above-the-fast-path
ordering invariant.
- N5: duplicate `schemaFingerprint` keys silently collapsed two assertions into
one (TS1117).
- N6: the absent-stamp message told non-git repositories their index was "built
by an older GitNexus version" — on every run, about an index this exact build
had just written. Non-git repos never record a fingerprint, and now the message
says so.
- N9: the on-disk stamp is shape-checked before being echoed, so a crafted
gitnexus.json cannot push ANSI escapes through the CLI log.
- N7: a test case that re-computed the same digest expression with its operands
swapped, mislabelled as a randomness check on a module-level const.
- N10: comments claiming the digest "cannot collide" (it is 48 bits), pointing at
a vector-column gate that does not exist, and asserting storage/ is free of a
core/ dependency two lines below a core/ value import.
None of these were caught by `tsc -p tsconfig.json`, which covers src only, nor
by eslint, where no-dupe-keys is off. `tsconfig.test.json` reports all three test
defects and is not currently wired into CI.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(schema): pin that the fingerprint covers every DDL statement init executes
`SCHEMA_QUERIES` is the list `runSchemaCreationQueries` iterates — the DDL that
actually runs. The fingerprint hashes only two of its three members, and until
now no test imported `SCHEMA_QUERIES` at all, so nothing tied the two together.
A fourth member appended to that array — the one literally named for what init
executes — would have been invisible to the gate. Every existing test would still
pass, because they all recompute the digest from the same two arrays the
fingerprint already uses. An index whose gate passed would then run `initLbug`
over the old database, where `runSchemaCreationQueries` suppresses "already
exists", so the new table would never be created and its edges would be dropped
by `fallbackRelationshipInserts`' bare catch. A wrong graph, no error — exactly
the failure #2798 exists to end.
The check is a pure predicate over (executed, fingerprinted, documented
exclusions) rather than a positional `toEqual`, so `EMBEDDING_SCHEMA` is named as
an exclusion with its reason — its FLOAT[N] width is environment-derived — rather
than sitting in a list where a future reader might "fix" it by folding it in. It
asserts both directions and is order-insensitive, leaving ordering to the digest
assertion that already pins it.
The negative case is pinned in CI rather than checked by hand once: the same
predicate over a synthetic fourth member must report it. If a refactor ever makes
the predicate vacuous, that case fails even though the positive one would not.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(analyze): name the invariant the version deletion now rests on
Deleting `INCREMENTAL_SCHEMA_VERSION` moved a load-bearing guarantee into an
implicit one. Roughly thirty of the retired ladder's entries changed no DDL at
all — node ids, wire formats, resolution tiers — and the fingerprint is
structurally incapable of firing on any of them. Their only remaining cover is
the analyzer runner-identity receipt, and nothing in the suite said so.
This adds a table over the real `analyzerRunnerIdentitiesEqual` with a
well-formed schema-v4 receipt: byte-identical reuses; an entrypoint-only
difference reuses (CLI vs analyze worker); a moved build digest with unchanged
DDL forces — that case IS the invariant, commented as such; and a dependency
change, an ABI change, undefined, null, a schema-v3 legacy receipt, a missing
build section and a non-sha256 digest all fail closed.
The deleted `expect(INCREMENTAL_SCHEMA_VERSION).toBe(35)` pin is also worth
naming: it failed CI on every bump by design, which is what made an author stop
and think. Nothing replaced it. This does not restore that — a digest has no
literal to pin — but it does make the mechanism that took over the job visible to
the next person who reads the file.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(spring): pin CLASS_SCHEMA's membership in the fingerprinted DDL set
When `INCREMENTAL_SCHEMA_VERSION` went away, its sibling in
basicblock-callee-ids-schema.test.ts got a replacement assertion tying
BASICBLOCK_SCHEMA to the fingerprint's input set. This file's
`>= 23` floor was deleted with nothing put in its place.
The file still asserts CLASS_SCHEMA's CONTENT — that the `frameworkAnnotations`
column exists — but not that CLASS_SCHEMA is part of what the digest covers, and
the second is what makes an index built before that column carry a different
fingerprint and get rebuilt. Mirrors the sibling so the two read the same way.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(identity): stop a symlinked directory from aborting the whole analyze
`collectArtifacts` fused two orthogonal facts into one condition: that four
directory names never carry runtime payload, and that a symlink where a real
directory was assumed falls through to the payload branch, where
`snapshotReadableFile` stats the target, sees a directory, and throws
"Analyzer identity input is not a file".
The second was only fixed for those four names. Every other symlinked directory
in a scanned package root still aborted the run — `dist -> build`, a vendored
grammar link, anything inside a linked sibling checkout. Newly reachable,
because making workspace-linked packages scannable pointed the scanner at a live
checkout instead of an immutable registry tarball for the first time.
Split along the actual seam: prune on the NAME alone, and give symlinks their own
branch in the type dispatch, ahead of the payload branch.
Link text is recorded rather than followed. Following was rejected on three
grounds, each checked in source: the traversal is a stack with no visited set, so
a self-referential link would recurse to `runtimeDepth` — which throws, trading
one hard abort for another; `snapshotDirectory` rejects a symlink outright, so
the directory guard could not accept one without a realpath rewrite of its
canonical-path identity; and a link into an already-scanned tree double-counts
against `runtimeEntries`/`runtimeBytes`, which also throw. The cost is stated in
code: a link out of the package contributes its text, not its target's content.
Links resolving to a regular file keep the existing content digest.
The new `'unfollowed-symlink'` kind is threaded through every consumer, including
the cache validator — which re-probes with `mode: 'link'`, since the readable-file
probe resolves the target and would return null for exactly this kind, silently
failing every warm validation.
No canonicalization or cache-schema bump. Digest content changes only for trees
that previously crashed: a delta scan over all 258 scanned roots of this install
found no regular file bearing a pruned name and no symlink failing to resolve to
a file, so `dependencyRuntime.digest` is byte-identical here.
Six of the eight new tests fail against the unfixed tree with the exact production
error; all eight pass after.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(analyze): give the reuse gate a real seam and sanitize logs at the funnel
Cleanup pass over the #2798 branch. Net -183 lines.
The gate had no extracted predicate, so its own test asserted it by regex-matching
run-analyze.ts SOURCE TEXT. That pinned production formatting: one pattern froze
three back-to-back single-name imports from './lbug/schema.js', so merging them —
the obvious tidy-up — failed a test named "still imports the DDL digest itself".
`schemaFingerprintMismatch` and `isSchemaFingerprintShaped` now live in
core/lbug/schema.ts beside the constant. Not in run-analyze.ts next to
`pdgModeMismatch`, because storage/ must stay off the analyze pipeline and
mcp/resources.ts is a plausible second consumer — the same reasoning that puts
`cjkSegmentationModeMismatch` in core/search/. The regex block is gone; the test
calls the predicate. The three imports are merged.
ANSI sanitation moved from one field to the funnel. The per-field guard's own
comment stated the general hazard — gitnexus.json is parsed with no runtime shape
validation and the notice reaches console.log — while two sibling guards twelve
lines away echoed `runnerIdentity.schemaVersion` and `cjkSegmentation` from that
same file raw into the same log. `log()` now strips C0/C1 controls, covering all
seven guard messages and any written later.
Also:
- Deleted a duplicate integration test. After the downgrade test was repointed at
`schemaFingerprint` it became the same scenario as the new one, differing only
by an extra log assertion — which is now folded into the survivor. Saves a
fixture and two full pipeline runs per CI pass.
- Replaced a 3-parameter set-difference helper with one set equality. Its doc was
false at one call site (arguments semantically swapped) and it needed a fourth
test purely to prove itself non-vacuous; set equality cannot go vacuous.
- Removed ~115 lines of runner-identity table that duplicated
analyzer-identity.test.ts. The three genuinely uncovered cases moved there, and
the #2798 invariant — build digest moved while the DDL did not — now asserts
against a REAL analyzer-build-tree edit rather than a hand-built literal, which
is strictly stronger than what it replaces.
- MIGRATION.md quoted a log line the code cannot emit; it was written before the
placeholder changed.
- Restored the rationale on the `capabilities` docstring, which a previous pass
replaced with its consequence — leaving a maintainer reading "duplicated by
hand" as a wart to fix by importing, which is what the original forbade.
- Marked the `isIncremental` conjunct as belt-and-braces: `!options.force`
short-circuits before it, so it cannot decide anything.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(analyze): force a rebuild when the vector column width changes
`CodeEmbedding.embedding` is declared `FLOAT[EMBEDDING_DIMS]`, resolved from
`GITNEXUS_EMBEDDING_DIMS` at module load. Nothing gated it. Flip the variable on
a same-commit clean tree and no guard fired at all: `alreadyUpToDate` returned
over a `FLOAT[384]` table while the process embedded at 768. The only reaction
anywhere discards the embedding CACHE and re-embeds — into a column whose type it
never revisits.
This predates #2798; `INCREMENTAL_SCHEMA_VERSION` never covered dims either. It
surfaced because the fingerprint work had to reason about why `EMBEDDING_SCHEMA`
must stay OUT of the digest: its width is environment-derived, so folding it in
would make the same build disagree with itself and thrash rebuilds. That
exclusion is correct, and it leaves the width needing its own guard.
Modelled on `cjkSegmentation`, the closest sibling: an env-resolved scalar
stamped at write time and compared by a small exported predicate that forces on
mismatch. `embeddingDimsMismatch` sits in core/lbug/schema.ts beside
`EMBEDDING_DIMS`, so the query side can adopt it without importing the analyze
pipeline — mcp/local/local-backend.ts already warns on a cjkSegmentation
disagreement and has the identical claim here, since the query path embeds at the
live width against a table of unknown width with no validation at all today.
ABSENCE IS NOT A MISMATCH, deliberately. Forcing on it would be dead code:
`embeddingDims` and `schemaFingerprint` ship together, and a missing fingerprint
already forces exactly one rebuild — which is where this stamp lands. Absence
also carries no signal here, unlike the fingerprint: a missing fingerprint means
"DDL this build cannot vouch for" and ships WITH a DDL change, whereas a missing
dims stamp means only "written before the field existed", and that run's table
agreed with that run's width. Drift requires the env to change, which absence
says nothing about. The `cjkSegmentation` trick of folding absence into the
default was unavailable — there is no width that is safe to assume for an
existing table — so the stamp is instead written unconditionally, giving absence
exactly one meaning. Malformed values are not grandfathered: null, '384', NaN and
objects all read as a mismatch and err toward a rebuild.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(mcp): warn when the served index's vector width differs from the query embedder's
The analyze side now forces a rebuild when the vector column width changes. The
query side had no equivalent: a serving process embeds a query at its own width
and searches a table whose width was fixed when the index was built. Disagree and
the user gets wrong or missing semantic results with nothing explaining why.
Mirrors the cjkSegmentation drift warning immediately above it — same warnings[]
array, same per-query recomputation, agent-visible in the tool response, and it
warns rather than refuses. A width mismatch degrades the semantic lane only;
keyword results are unaffected, so `partial` is deliberately not set.
Compares against `getEmbeddingDims()` — the width the query embedder actually
produces — NOT schema.ts's `EMBEDDING_DIMS`. The two diverge exactly when
GITNEXUS_EMBEDDING_DIMS is set on a server that embeds LOCALLY: the query path
ignores that variable and embeds at 384, so comparing against the env-derived
constant would report drift on a lane that works fine. The recorded width is what
the vector CAST actually binds.
`embeddingDimsMismatch` is imported from core/lbug/schema.js rather than
restated, so "absent is not a mismatch" cannot drift between the analyze and
query sides. That predicate was placed in schema.ts precisely so this consumer
could reach it without importing the analyze pipeline.
Two gates keep it quiet when it would be noise: it fires only for a repo where
this process actually produced a query vector, so an index analyzed without
--embeddings (or a server whose embedder is unavailable) never carries it. An
untrusted recorded value — meta.json is schema-less JSON — is reported as "an
unrecognized width" rather than echoed.
`loadMeta` is hoisted out of the neighbouring try so both diagnostics share one
read and an invalid GITNEXUS_FTS_CJK_SEGMENTATION cannot take this one down with
it.
Refs #2798
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(identity): detect an npm-linked dev dependency the specifier cannot see
`isLocallyLinkedSpecifier` admits a devDependency whose SPECIFIER is
checkout-local. `npm link <pkg>` leaves the specifier a registry range while the
node_modules entry symlinks to a checkout — locally linked, invisible to a
specifier check, so a semantic-only edit there still moves neither digest.
The obvious placement is unaffordable, measured rather than assumed: probing
every dev-only name inside collectRuntimePackages costs 1998 resolutions, not
the ~8 it looks like, because dependencyNames runs for every package in the BFS
and published tarballs retain their devDependencies. Persisted path guards go
2221 -> 11050 (+398%), and every guard is re-probed on each warm validation —
the path `status` takes.
Scoped to the root package instead. The declared-intent half is untouched and
still enumerated everywhere: it alone can emit the `<missing>` edge for a
declared link whose checkout is absent, where resolution returns null and cannot
distinguish that from an uninstalled dev tool. The new resolved-location half
runs only when `parent.root === packageRoot`, resolves through the existing
resolver so its path guards are recorded, and admits a name iff the realpath'd
root carries no node_modules segment.
Bounded against mis-fire by EXPANSION. "Not under node_modules" is a proxy for
"checkout-local"; under a relocated pnpm virtual store every dev dep passes it
and the whole dev tree folds into the receipt — against limits that THROW, so a
legitimate install would abort. Measured here: uncapped, that shape takes
259 -> 347 packages and 2250 -> 3786 guards. The cap admits at most four and
DROPS THE WHOLE CHANNEL on overflow rather than an arbitrary prefix, because the
abort comes from the transitive payload of whichever trees get folded in — four
of a mis-fired thirteen is still unbounded, and a sorted-prefix receipt would be
arbitrary. Overflow falls back to the specifier-only receipt that ships today.
Cost on this install: 259 packages unchanged, 13 dev names resolved, guards
2221 -> 2250 (+29, +1.3%). Verified against the real implementation, not just a
replay: validation guards 16295 -> 16324, packageCount and artifactCount
unchanged, and `dependencyRuntime.digest` byte-identical — so this forces no
re-analysis for anyone.
Each test fails on the defect it targets: disabling the channel kills the
npm-link and cap cases; dropping the root-only scope makes the differential
guard-count case fail at 2.8x guards; removing the specifier half kills the
`<missing>` case.
Refs #2798
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>
* fix(embeddings): retry unparseable 200 responses and survive partial embedding failures (#2790)
A long-running embedding job against an OpenAI-compatible endpoint could lose
hours of work to a single transient glitch, then refuse to recover on the next
run. Four defects compounded:
1. An HTTP 200 carrying a truncated or non-JSON body was never retried.
`classifyOutcome` treats any 2xx as success, and the `resp.json()` parse ran
after `resilientFetch` had already returned, so the parse failure surfaced as
a terminal error. Measured: a 503 got 3 attempts, a garbage 200 got 1.
The parse and the response-shape check now run inside the `fetchImpl`
callback, so a bad body is classified as a retryable failure and gets the
same backoff as a 5xx. This also stops a garbage 200 from calling the circuit
breaker's `recordSuccess()`, which previously erased accumulated failures and
meant an endpoint alternating 5xx and garbage-200 could never trip it.
2. One failed `embedBatch` sub-batch aborted the entire pipeline. Failures are
now tolerated: the sub-batch's node ids are collected and all of their
embedding rows are deleted, so those nodes hold zero rows and are re-embedded
later. Deleting rather than keeping partial rows is deliberate — chunk arrays
are flat over a 16-node batch and sliced by 8, so a node's chunks can straddle
a sub-batch boundary, and surviving rows carry the current content hash. The
hash maps collapse per-chunk rows last-row-wins, so a partially embedded node
would read as fresh forever and never regenerate its missing chunks.
A run that fails 5 sub-batches in a row still aborts, and rethrows the first
error of the streak rather than the last: after 3 failures the circuit breaker
opens, so later errors degrade into "circuit open, retry in 30s" while the
first still names the real defect.
3. The Phase 5 `embeddingCount === 0` fail-fast could not tell "wrote nothing"
from "could not ask" — the count query's catch was silent. The count is now
tri-state and only a known zero after real work is fatal. A non-numeric count
previously bypassed the gate entirely, because `Number()` returns NaN and
`NaN === 0` is false, and then serialized as `embeddings: null`. An unverified
count no longer certifies `capabilities.vectorSearch.status`.
4. `saveEmbeddingCheckpoint` wrote a completion-shaped meta: it advanced
`lastCommit`, wrote the new `fileHashes` and cleared `incrementalInProgress`.
The first checkpoint window fires before a single embedding exists, and on a
full rebuild the graph is still in a staging database that a crash discards.
The next run then diffed against the advanced hashes, saw no changes and
preserved the old graph — the "skipping wipe" symptom in the report. It now
re-reads meta and replaces only the checkpoint, matching what the server
endpoint already did.
A partially failed run keeps its checkpoint with the failed ids in
`pendingNodeIds`, so the next plain `analyze` regenerates them through the
existing resume path. Clearing it would have been silent data loss: a plain run
derives `shouldGenerateEmbeddings: false` once embeddings exist, so the pipeline
would never have run again. The old crash-and-abort self-healed only by accident,
via the checkpoint its crash left behind. `gitnexus status` reports the index
incomplete until the nodes recover, and `--drop-embeddings` still abandons them.
`POST /api/embed` is the pipeline's other caller and was discarding the result,
reporting "Embeddings complete" for a partial run. It now persists the pending
ids and reports the run as failed with the underlying endpoint error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(embeddings): abort a run whose sub-batch failure ratio is too high (#2790)
The consecutive-failure ceiling only catches a total outage, because any
successful sub-batch resets it. An endpoint under load shedding that alternates
success and failure never trips it, so the run walks the whole corpus, deletes
every failed node's rows and exits 0 having dropped a large fraction of the
index. The retained checkpoint made that visible in `gitnexus status`, but a run
that drops a quarter of the corpus should tell the operator to fix their
endpoint, not leave them to notice a status flag.
Adds a cumulative guard: abort once more than 25% of attempted sub-batches have
failed, evaluated as the run progresses and gated behind a floor of 20 attempted
sub-batches. The shape follows Resilience4j's circuit breaker (failure rate plus
a minimum-sample floor) because it is the only one of the surveyed designs that
answers the small-repo case — a three node repo can fail one sub-batch and never
accumulate enough sample for a ratio to mean anything. The rate sits below a live
traffic breaker's 50% because a batch indexer's job is to index the whole corpus
rather than serve degraded traffic, and above Hadoop's single-digit
`failures.maxpercent` because tolerating transient hiccups is the point of the
change this follows.
The guard reuses the existing break-then-cleanup path, so the failed batch's
DELETE still runs before the rethrow, and it wraps the retained first-error-of-
streak rather than inventing a new one, so the message names both the ratio and
the underlying endpoint failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(server): record the embedding count after /api/embed so the next analyze cannot wipe it
`POST /api/embed` generated embeddings and wrote them to the database but never
wrote `stats.embeddings` into meta.json. Its checkpoint writer replaced only
`embeddingCheckpoint`, and the finalize write folded in nothing else.
So a repo embedded purely through the server kept whatever count the last CLI
`analyze` stamped, which is 0 for a repo analyzed without embeddings. The next
CLI run read `existingEmbeddingCount = 0`, `deriveEmbeddingMode` returned
`shouldLoadCache: false`, and `gitnexus analyze --force` wiped the database with
no cache load. Every server generated embedding was silently destroyed, with no
warning — the user just lost semantic search.
The route now measures the live count with the same query the CLI uses and folds
it into both meta writes. The measurement is tri-state and deliberately never
falls back to 0: an unverified count is written as absent rather than as zero,
because a wrong-low value is exactly what arms the wipe. It is taken after
`flushWAL()` and inside `withLbugDb`, so it describes durable rows and the
connection is still open. A partial run records its honest count too, alongside
the retained checkpoint, so the next CLI run preserves the partial index instead
of discarding it.
Found while working #2790; not part of that issue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(embeddings): retry short 200 bodies and stop laundering body-phase timeouts
Two gaps in the #2790 retry fix, both found by review.
A 200 carrying `{"data": []}` or fewer vectors than inputs passed the
in-`fetchImpl` shape check, because `every(isEmbeddingItem)` is vacuously true
for an empty array. `resilientFetch` then classified it `success` and called
`recordSuccess()`, erasing the outage signal, and the cardinality check in
`httpEmbed` threw terminally one attempt later. That is exactly the pair of
properties #2790 was filed about, still broken for this body shape — and worse
than before the fix, since the pipeline now tolerates the error by deleting
those nodes' rows instead of aborting loudly. The count check moves inside the
retried callback; the outer one stays as a backstop.
The `.json()` catch also swallowed every rejection, not just parse errors.
`AbortSignal.any([caller, timeout])` is wired to the body stream, so a stalled
body rejects with a DOMException — which, wrapped in a plain Error, defeated
`classifyOutcome`'s terminal-network test. Measured: the same TimeoutError got
3 attempts and "unparseable response" when raised during the body read, but 1
attempt and "timed out after 180000ms" when raised by fetch itself, and three
such sub-batches opened the process-global breaker that `recordNeutral()`
exists to protect. Abort-like DOMExceptions are now re-raised unchanged.
The dimension check stays outside the loop deliberately: it validates against
`config.dimensions ?? DEFAULT_DIMS`, not the request-dimensions argument, and a
width mismatch is a configuration error where retrying only triples latency and
books failures against a healthy endpoint.
Adds the negative assertion the review found missing: response body text must
never reach the user-facing error string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(embeddings): scale the sub-batch failure-ratio floor to the run
The cumulative guard needed 20 attempted sub-batches before a failure rate could
abort anything — roughly 160 chunks, or ~80 embeddable nodes at the default
subBatchSize of 8. A 50-node repo whose endpoint sheds every other sub-batch
fails half of them and still exits 0: the ratio guard is below its floor, and
every intervening success resets the consecutive ceiling.
The floor was a good choice for a first run over a small repo, where one failure
out of one sub-batch is 100% and means nothing. The defect is that every resume
run has that shape by construction — its node set is only the pending ids — so
the guard was structurally off in the one run whose entire purpose is retrying
against the endpoint that already failed.
The floor is now sized to the run: clamp(ceil(totalNodes / 16), 5, 20). The
lower bound keeps the case the flat floor protected; the upper bound preserves
today's behavior above 320 nodes and avoids a proportional-only floor perversely
weakening the guard at scale, where a sixteenth of a 20k-node repo would be 1250
sub-batches of damage before a rate could fire. Resilience4j can use a constant
minimumNumberOfCalls because a breaker sits on an unbounded call stream; a batch
indexer has a finite budget, so a constant can exceed the whole run.
The ratio is still evaluated only inside the catch. That is already its local
maximum — both counters have just incremented — so sampling more often would
only ever observe lower ratios.
Also: a failing cleanup DELETE no longer swallows the abort, which was
discarding the retained first-error-of-the-streak that names the real endpoint
fault; `ceilingError` is renamed `abortError` since it carries the ratio abort
too; and three `{ error }` log keys become `{ err }` (#2114 — an arbitrary key
serializes to `{}`, losing message and stack).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(analyze): one tri-state embedding counter, and stop partial runs wedging later runs
The tri-state count doctrine this branch introduced was applied at two of its
three CLI sites, and the two implementations that were meant to mirror each
other had already drifted.
`measurePersistedEmbeddingCount` moves to `core/embedding-count.ts` — beside
`embedding-mode.ts`, with the same no-native-imports property, and outside
`core/embeddings/` so the lazy-embeddings convention (#2370) still holds. All
three call sites now share it.
- The mid-run `onCheckpoint` counter ran the query bare. A throw there — DB
busy, connection closed, read-only, the VECTOR DML lock (#2623) — rejected
the callback out of `runEmbeddingPipeline` and killed the analyze before
Phase 5 could apply the tri-state that exists for exactly this case. A
non-numeric cell wrote `stats.embeddings: null` to disk mid-run.
- Phase 5 used `?? 0` while the server used `?? Number.NaN`, under a comment
asserting both measured the field the same way. `Number.isFinite(0)` is
true, so a no-row answer became a *measured* zero and hard-failed a run
whose embeddings had all persisted.
- The unknown-count fallback read `existingMeta`, assigned once at run start,
so it republished the pre-run figure over the fresher count the terminal
checkpoint had already written. With a prior count of 0 that armed the wipe
chain: hasExisting false, shouldLoadCache false, and the next --force
discards live embeddings. It now re-reads the latest on-disk meta, and an
unverifiable count retains a recovery marker instead of clearing it.
A completed-but-partial run also planted a landmine. Its checkpoint is stamped
with the run's embedding identity, so a later plain `gitnexus analyze` from a
hook, a CI job, or a shell without GITNEXUS_EMBEDDING_URL resolved provider
'local' and threw before any phase ran — after an exit-0 run, where previously
only a visible crash left that state. `--force` did not help: the resume gate
inspected only `--drop-embeddings`.
`RepoMeta.embeddingCheckpoint` gains `kind` to tell the two situations apart.
An 'interrupted' marker (or one with no kind, so markers already on disk keep
the stricter path) still fails closed — its nodes may be half-written, and
resuming under a foreign model would mix vector spaces. A 'partial' marker
names nodes the pipeline already deleted to zero rows, so nothing is at risk: an
identity mismatch drops the pending set with a warning and continues. `--force`
now discards a checkpoint, and `attempts` bounds the retry at
EMBEDDING_RESUME_MAX_ATTEMPTS (3, matching the HTTP embedder's and the WAL
driver's existing per-operation budgets) so a node the endpoint deterministically
rejects converges instead of keeping the repo incomplete forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(server): close the SSE stream on terminal job status, not a progress phase
A tolerated partial run reached SSE clients as a clean success — a regression in
this branch's own claim that /api/embed reports a partial run as failed.
The pipeline emits `phase:'ready'` unconditionally before returning, including
when it dropped nodes. The route mapped that to `'complete'`, and
`mountSSEProgress` treated a terminal-looking *progress phase* as terminal:
write the event, `res.end()`, `unsubscribe()`. The route's own
`updateJob({status:'failed'})` then fired into a stream with no listener, and
the web app had already shown "ready". Before this branch the pipeline threw,
which produced `phase:'error'` and did reach the client. Pollers on
GET /api/embed/:jobId were unaffected, so the two consumers disagreed.
Terminality is a property of the job, so the relay now asks the job. Remapping
`ready` alone would have left the trap armed: the `error -> 'failed'` mapping
has the identical shape and would emit `event: failed` with `error: undefined`
before the catch block fills the message in. `ready` is additionally remapped to
`finalizing` so a poller no longer sees `status:'analyzing'` next to
`progress.phase:'complete'`. The single-terminal-event property (#2264) is
preserved on both the clean and partial paths, and /api/analyze is unaffected —
its terminal progress phase is 'done', never 'complete'.
`AnalyzeJob` gains an optional `partial` payload so a client can tell a partial
run from a total failure without a new status member; it is absent on every
other job, so existing payloads stay byte-identical. Consuming it in
gitnexus-web is left to that app's owner — today it renders both as the same
red retry chip.
`resolveEmbedRunOutcome` moves to `embed-run-outcome.ts` and `mountSSEProgress`
to `sse-progress.ts`, both free of Express/LadybugDB/MCP imports, and the local
count copy is replaced by the shared `core/embedding-count.ts`. Reaching three
pure functions previously meant importing the whole server: measured at ~20s
against a 30s test timeout, with one observed timeout failure. That file is now
1.6s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: document the partial embedding index and its recovery
A run can now finish exit 0 with a partial embedding index, which neither
operator doc described.
GUARDRAILS' "Embeddings vanished after analyze" Sign keys its trigger on
`stats.embeddings` being 0 and lists "the only ways to end up at zero". A
partial run stamps an honest non-zero count and sets `embeddingCheckpoint`, so
the operator's actual symptom is `incompleteReasons:
["embedding-checkpoint-pending"]` — a state that Sign cannot match. Adds a Sign
for it and drops the exhaustive framing from the existing one.
RUNBOOK gains the recovery path: a plain `gitnexus analyze` is correct and needs
no flag, because a retained checkpoint forces generation for the pending nodes
regardless of flags. Also corrects two stale claims — that `stats.embeddings` is
always freshly measured (it can carry forward when the count query cannot
answer, which is why `capabilities.vectorSearch.status` is the certified read),
and that later analyzes must always pass `--embeddings` or lose their vectors,
which contradicts Non-negotiable 5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(embeddings): one owner for the checkpoint record and the abort predicate
Cleanup pass over the #2790 review fixes. No behavior change except where
noted; the two exceptions are both cases where the code was lying to the
operator or to the other half of itself.
The previous pass extracted `core/embedding-count.ts` because two hand-copied
bodies of "measure the embedding count" had drifted inside a single change. It
then created a second pair of hand-copied publishers — of
`RepoMeta.embeddingCheckpoint` — and those had drifted too: the CLI armed the
attempt counter only after clearing its identity gate, the server derived it
from the resumed marker alone. Only one of the two READERS implemented `kind`
at all, so a 'partial' marker written by `gitnexus analyze` and resumed through
POST /api/embed still hit the permanent wedge `kind` exists to remove.
`core/embedding-checkpoint.ts` now owns the record: `checkpointKind` (the one
home for absent-means-interrupted), the three minters, `nextAttemptCount`, and
`decideEmbeddingResume`, which both gates route through. Five mint sites and
two resume gates become one implementation each.
`resilient-fetch.ts` exports `isTerminalNetworkError` and `classifyOutcome`
calls it, replacing a caller-side copy of the same DOMException test whose
docstring promised it "mirrors classifyOutcome exactly" — an invariant enforced
by prose, where a divergence silently reverts body-phase timeouts to being
retried three times and charged to the shared breaker.
The ratio-guard floor now divides by the run's actual `subBatchSize` instead of
a constant 16 that assumed the default of 8. At `subBatchSize: 32` the old
formula demanded more sub-batches than the run contains, leaving the guard
structurally off — the exact failure the scaled floor was introduced to fix,
and sub-batch size is tuned mainly for the flaky endpoints it protects.
Two operator-facing corrections:
- The count-recovery marker was stamped `kind: 'partial'` with an empty
pending set, so `gitnexus status` reported "N node(s) lost their embeddings"
where N is zero. It gets its own kind and its own incomplete reason.
- `decideEmbeddingResume` initially keyed its skip-the-identity-gate branch on
an empty pending set, assuming that meant the count-recovery marker. It does
not: `onCheckpoint` mints an 'interrupted' marker with no pending nodes
after every post-window save. That silently cleared an interrupted marker
under a foreign provider instead of failing closed. Keyed on `kind` now,
with a regression test.
Also: `isTerminalJobStatus` adopted at the seven sites that still hand-copied
it, including the one gating the single-terminal-event emit; `mountSSEProgress`
re-export dropped and `server-sse-payload.test.ts` repointed at the extracted
module, which takes it from 24.60s to 0.408s — the test that motivated the
extraction was still paying the cost it was meant to remove; the count-mismatch
message and the SSE test harness deduplicated; per-batch error strings made
lazy (~75k needless `new URL()` per large run); `retryable: true` dropped as a
field that can never be false; ~110 lines of restated rationale reduced to
pointers at their canonical home.
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>
* fix(schema): declare the full scope-resolution relation cross product (#2792)
`RELATION_SCHEMA` was hand-listed, and every prior fix added only the
FROM/TO pair named in a crash report — `Const→Method` in #2769, the
Swift/Rust member pairs before it. So `analyze` kept aborting at
`assertDeclaredPair` on the next codebase whose edges happened to land on
a different pair; #2792 reports `Class→Variable` on Java.
Audit the surface instead of the symptom. `buildGraphNodeLookup` skips
any node whose label is not in `isLinkableLabel`, so the lookup holds
only linkable-labelled nodes — and both endpoints of every graph-bridge
edge resolve through that lookup. The emittable surface is therefore
exactly:
FROM LINKABLE_LABELS + File (the module-level caller fallback)
TO LINKABLE_LABELS + CALL_TARGET_TYPES
`isCallerAnchorLabel` is a strict subset of linkable and contributes
nothing on top. `CALL_TARGET_TYPES` contributes `Delegate`, which
`tryEmitEdgeWithExplicitTargetId` can emit without going through the
lookup at all.
Generate that 14x14 block into the DDL rather than listing it: 223 -> 322
declared pairs, and no future pair from these sets can be missing by
construction. The containment/inheritance/DI/route/cluster/PDG pairs stay
hand-declared — no single predicate describes them.
Both label sets live in the ingestion layer, which `core/lbug` must not
import, so schema.ts carries twin lists. test/unit/schema-pair-coverage.ts
derives the requirement from the originals and fails CI when either set
grows without the pairs landing here — the piecemeal loop this fix ends.
Measured before widening: at 322 pairs the cost is inside noise
(1.09s vs 1.12s per 300 anchored queries on a 32-table DB), but the full
32x32 cross product is ~1.8x on untyped-endpoint anchored queries. The
audited subset is the right scope, not "declare everything".
INCREMENTAL_SCHEMA_VERSION 34 -> 35: LadybugDB fixes endpoint pairs when
the rel table is created, so a pre-v35 database physically cannot store
these edges.
Closes#2792
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(schema): declare the non-bridge structural pairs COBOL and Vue emit
The generated scope-resolution block closed the half of RELATION_SCHEMA a
label predicate can describe. The hand-declared half was still stale: with
#2791's Function->Variable fix applied, `analyze` continued to abort on this
repo's own test/fixtures/lang-resolution with
Relationship label pair Module→Property is not declared
A full sweep (assertDeclaredPair patched to log-and-skip, run over the whole
fixture corpus) found 13 undeclared pairs over 106 edges. This branch already
covered 3 of them via the cross product; the remaining 10 come from emitters
outside the graph bridge:
- cobol-processor.ts mints Module / Namespace / Record / Property /
CodeElement and wires them with CONTAINS, CALLS and ACCESSES (9 pairs)
- vue-sfc-extractor.ts emits BINDS_EVENT_HANDLER from a handler Function to
the child component's File, the only edge whose target is a File (1 pair)
CodeElement, Namespace, Record and File are in neither scope-bridge label set,
so neither the generated block nor schema-pair-coverage.test.ts can reach them.
Adds test/integration/structural-pair-coverage.test.ts, which derives the
requirement from a corpus instead of a predicate: it runs the real pipeline
over the non-bridge fixtures and requires every FROM/TO pair they produce to
be declared. Mutation-checked — dropping `FROM Function TO File` fails it with
exactly Function|File.
Verified: cobol-app, vue-basic and php-transitive-traits now index instead of
aborting; the full lang-resolution corpus completes at 10,876 nodes / 18,517
edges; scrypster/muninndb at 0b7a4272 (the #2789 repro) completes at 20,069
nodes / 71,580 edges, matching #2791 exactly, so this supersedes that PR.
* refactor(test): simplify the structural pair coverage guard
Cleanup pass over the previous commit. No behaviour change to the schema.
- reuse `FIXTURES` and `runPipelineFromRepo` from resolvers/helpers.ts instead
of re-deriving the fixture root and importing pipeline.js directly
- gate on `distWorkerExists()` like every other integration test that passes
`workerUrlForTest`, so a missing dist skips rather than fails
- run the three fixtures with `it.concurrent.each`; they share nothing and the
cost is almost all worker spawn plus grammar load, which overlaps well
(tests phase 21-24s -> 5.6s measured)
- replace the sentinel-in-a-Set filter with a plain `.filter()` chain, matching
the sibling unit test, and move the declared/table lookups off the per-edge
path onto the deduped set
- move the pure string pin out of the integration tier into
schema-pair-coverage.test.ts, where the identical construct already lives, so
it needs no build and survives fixture deletion
- trim the schema and test prose that restated the code, and correct the
BINDS_EVENT_HANDLER attribution: it is emitted by
languages/vue/scope-resolver.ts, not vue-sfc-extractor.ts
- amend the v35 comment to mention the 10 structural pairs it now also stamps
Still mutation-checked: dropping `FROM Function TO File` now fails both the
integration sweep and the unit pin with exactly Function|File. 89 tests green.
* fix(schema): generate the attachment pair surface and close four analyze aborts
Review of the generated scope-bridge cross product found four `analyze`
hard-aborts still live at head, each reproduced end-to-end on the default
user path (`analyze --index-only --skip-git`):
Method→Annotation Spring `@Bean` + `@ConditionalOnMissingBean` (Java + Kotlin)
Method→File Vue Options-API `methods:` handler bound to a child event
Namespace→Record COBOL `DECLARATIVES` / `USE AFTER STANDARD ERROR ON <file>`
Class→Tool `@mcp.tool()` applied to a class
All four are pre-existing on main, and both existing guards were structurally
blind to them: the unit guard derives from LINKABLE_LABELS ∪ CALL_TARGET_TYPES
(none of Annotation/Tool/Record/File-as-target is a member) and the corpus
guard ran three fixtures that exercise none of these emitters. All 16 tests
passed while all four crashes were live.
The PR's model — "bridge endpoint × structural endpoint" — does not fit:
Namespace→Record is structural on both sides. The property that does hold is
that the ANCHOR is a lookup result, not a literal at the emit site, so the
emitter cannot constrain its label. That gives a second closed-form rule:
DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS
DEFINITION_ANCHOR_LABELS is derived from NODE_TABLES by subtraction, so a new
node table joins automatically. 332 → 450 declared pairs.
Sized against a committed harness (gitnexus/bench/schema-pairs), real
@ladybugdb/core, identical data: 450 costs 0.93–1.05× of 332 on untyped-endpoint
anchored queries — inside noise — versus 1.22–1.43× at 641 and 2.03–2.34× at
1024. The harness reproduces the known #2792 cliff, which is what makes the 450
figure trustworthy.
Also in this change:
- Delete the 161 hand-declared pairs the rules already generate (233 → 72).
The declared set is byte-identical at 450; those lines were load-bearing
shadow, because the generator suppresses anything already declared
structurally, so narrowing a rule later would silently keep pairs alive.
A new guard fails CI if a hand-declared pair is ever re-added inside a rule.
- Import LINKABLE_LABELS / CALL_TARGET_TYPES instead of hand-copying them.
The twins' stated justification ("the ingestion layer must not be imported
here") is false: csv-generator.ts and lbug-adapter.ts, siblings in the same
directory, already do, and no rule in AGENTS.md / ARCHITECTURE.md /
CONTRIBUTING.md / GUARDRAILS.md states otherwise.
- Resolve `resolveStreamGraphEmit` after the guards that rebind `options.force`,
not at function entry. It gates on `force`, and every freshness guard runs
~360 lines later, so the v34→v35 bump would have pushed every existing index
down the non-streamed emit path — losing the #2680 memory streaming added for
the #2649 kernel-scale OOM, for exactly the population most likely to be
memory-constrained.
- `UndeclaredRelationPairError` now carries the relationship type, both node ids
and the source file, with a matching CLI branch. The old message named only
the abstract label pair, which a user could not act on. Found through the
cause chain, since pipeline-phases/runner.ts rewraps every phase failure.
- Share one classifier (`relPairKeyFor`) across the router, both emit sinks and
the corpus guard, which previously hand-mirrored the router's skip rule; one
cause-chain walker in lib/utils.ts; one exported pair-matching regex.
- Corpus guard: four new fixtures reproducing the aborts, per-fixture sentinel
pairs so a fixture that stops emitting fails loudly instead of passing
vacuously on an empty graph.
The per-edge path stays allocation-free: the failure context is passed
positionally and the message is built only inside the throw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX
* test(bench): re-baseline the COBOL capture fingerprint for the new fixture
`bench/scope-capture` globs `lang-resolution/cobol-*`, so the
`cobol-declaratives` fixture added in 81daf370e (to reproduce the
`Namespace→Record` analyze abort) joined that corpus and shifted the
fingerprint — 14 → 15 files.
Verified corpus-only, not a capture change: with that one fixture moved
aside the fingerprint is byte-identical to the prior baseline
(d45bb091…), and 81daf370e touches no COBOL capture code. The new value
reproduces CI's reported hash exactly. Scaling 0.677 < 1.5 budget.
`bench/scope-capture/measure.mjs --check` → PASS (15 languages).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(cpp): index qualified namespace members once per pipeline run (#2788)
`resolveCppQualifiedNamespaceMember` walked every parsed file — rebuilding a
per-file `scopesById` map each time — once per qualified `ns::member()` call
site, so the scope-resolution emit phase cost O(callsites x scopes). On a
1,473-file C++ repo that was 25.3 min of a 33-min analyze, with 75% of total
self-time in this one function. Its inner `findMemberInNamespaceTransitive`
compounded it: each recursion step filtered `scopesById.values()` by parent,
O(scopes^2) per file on its own.
This is the same bug #1990 fixed in the sibling ADL path (`pickCppAdlCandidates`
-> `AdlCandidateIndex`), so it gets the same fix: a `QualifiedNsMemberIndex`
(receiver simple name -> member simple name -> callable defs) built lazily once
per `parsedFiles` identity and reset by `clearCppInlineNamespaces`, which runs
from `cppScopeResolver.loadResolutionConfig` at the start of every pass. Per
call site the work drops to two Map lookups.
Ordering is preserved exactly — file-major, `parsed.scopes` declaration order,
a namespace's own `ownedDefs` before its inline-namespace children, depth-first
— because the caller takes `allHits[0]` for the single-hit case and
`narrowOverloadCandidates` is first-wins. Non-inline nested namespaces are
still not descended into, and same-name hits across inline children still
report `'ambiguous'` (#1564).
Measured with `PROF_SCOPE_RESOLUTION=1 analyze --force --index-only` on a
synthetic corpus (`namespace ns_i { inline namespace v1 { ... } }` plus 20
`ns_j::fn()` call sites per file):
| files | emit before | emit after |
|-------|-------------|------------|
| 100 | 153ms | 16ms |
| 200 | 704ms | 24ms |
| 400 | 3,293ms | 42ms |
| 800 | 16,898ms | 78ms |
Before, doubling the file count quadrupled emit; now it doubles. At 800 files
total scope resolution goes 17.2s -> 394ms.
Output is unchanged, verified rather than assumed: a full graph dump (sorted
nodes + relationships) from a baseline build at the parent commit and from this
one are byte-identical on all 134 `cpp-*` fixtures merged into a single repo
(1573 nodes / 1997 relationships) and on the 400-file synthetic corpus.
`test/integration/resolvers/cpp.test.ts` passes 334/334.
#1990 shipped its ADL fix without a scaling gate, which is how the bug class
came straight back here, so this adds one: `bench/cpp-qualified-ns` measures
`(t_large/t_small)/(1600/400)` — 0.93-1.21 indexed versus 3.45 for the old
per-call-site scan — alongside a fingerprint over every
`receiver::member -> outcome` the corpus resolves, and CI runs it with
`--check`. `test/unit/cpp-qualified-ns-index.test.ts` covers the cache
invalidation the index introduces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cpp): address tri-review findings on the qualified-namespace index (#2788)
Multi-engine review of this PR (Claude swarm + ce-code-review, Codex
gpt-5.6-sol swarm + ce + adversarial) returned two P1s and five smaller
findings. All are fixed here.
P1 — the index defeated the pipeline's post-language memory release.
`scope-resolution/pipeline/phase.ts` evicts each language's files and then
calls `forceGc()`, on a stated premise that "This language's ParsedFiles are
now unreachable", sizing C/C++ at ~17-20GB on the Linux kernel. The
module-level `let qualifiedNsIndexSource` falsified that: it pinned the whole
`parsedFiles` array, and the index held defs reaching into those files'
scopes, until the *next* C++ pass cleared it — which in a single analyze never
comes. C++ is 7th of 16 in SCOPE_RESOLVERS, so the set survived nine later
language passes plus emit. Replaced with a
`WeakMap<readonly ParsedFile[], QualifiedNsMemberIndex>`, the pattern already
used by `moduleScopeIndexByPass` in `cpp/file-local-linkage.ts`.
`clearCppInlineNamespaces` still swaps in a fresh WeakMap, because the index
has a second input (`inlineNamespaceScopeIds`) the key cannot observe.
Measured with `--expose-gc`: 61.2MB retained after the caller drops the array
before, 0.1MB after.
The ADL twin (`adl.ts`) has the same pattern, so the hazard predates this PR —
but `pickCppAdlCandidates` returns early before `ensureAdlIndex` on
`noAdlSites`/empty `argInfoBySite`, so it rarely arms, whereas a qualified
`ns::member()` index arms on almost every C++ workspace. Moving the ADL twin
to a WeakMap is left as a follow-up.
P1 — the new bench could not see the regression class it exists to gate.
`callSites()` drew every receiver from `ns_${...}`, so the receiver lookup
never missed; production is the opposite, since Case 1.5 in
`receiver-bound-calls.ts` is reached by every plain-identifier receiver call
and misses on most. A rescan reintroduced only on the receiver-bucket-absent
path scored 1.279 and PASSED the old bench. The corpus now mirrors production
(~1 in 5 receivers name a declared namespace) and adds a namespace reopened
across files, a same-name inline nest, a member declared at both namespace and
inline-child level, and call sites carrying a real `Callsite` so
`narrowOverloadCandidates`/`cppConversionRank`/
`isOverloadAmbiguousAfterNormalization` are inside the fingerprinted surface at
all. That same rescan now measures 4.538 and FAILS; defeating the dedup now
fails the fingerprint arm where it previously passed byte-identical. The
fingerprint moved once, deliberately, for the corpus expansion — recorded in
`_rebaseline_2788_review`, explicitly not precedent.
Also fixed:
- Unbounded recursion aborted analyze. `collectNamespaceMembers` recursed per
inline child with no bound and threw an uncontained `RangeError` at inline
depth 8000 (`phase.ts`'s try has a `finally`, no `catch`), and a receiver
*miss* paid full recursion where the deleted walker skipped on a name
mismatch. An explicit work-stack alone would only have converted that into
an OOM at depth 6000, because the eager table was quadratic in memory too:
for a depth-D chain it legitimately holds D(D+1)/2 entries, since `v2::foo()`
is a valid receiver at every level. Replaced with a lazily-queried node graph
(per-scope own-member buckets plus direct child links, resolved on demand and
memoized per receiver+member). Build is now linear; depth 100000 costs 133ms
where 8000 previously threw.
- "#1990 shipped without a scaling gate" was false. #1990 did ship
`test/integration/cpp-adl-benchmark.test.ts` (f1b843838). Corrected in the
bench header and the CI step comment. The accurate point is narrower and
stronger: that bench asserts `callsResolved === 0`, so it never drives the
qualified-receiver path, and it is `skipIf(!GITNEXUS_BENCH)` while the only
step setting that variable lists neither C++ bench — so it has never run in
CI. Wiring it in is a follow-up.
- "Ordering is load-bearing" was not a live property: `allHits[0]` is only
reached at length 1, and both tail branches return `'ambiguous'`. Order is
still preserved for byte-identity with the pre-#2788 walker; the comment now
says that instead, and the test named for ordering is renamed to the
parent/inline-child visibility it actually asserts.
- "Same contract as `ensureAdlIndex`" overstated parity — the sibling ships a
`validateAdlSeqCoverage` guard because it reads
`seqByNodeId.get(...) ?? 0`, which can silently collapse candidates. This
index has no analogous defaulting read, so no guard is added; the comment now
says why.
- Two coverage gaps closed, both mutation-verified: cross-file merge of one
namespace reopened in two files (no existing test covered it — confirmed by
making each file clobber the previous and watching only the new test fail),
and the same-name inline nest whose dedup, when defeated, flips a resolved
def to `'ambiguous'`.
- `resolveCppQualifiedNamespaceMember`'s JSDoc now names both production call
sites, including the callsite-less `resolveAdlCandidates` path.
- Memoized candidate buckets are frozen, so a future in-place sort in
`overload-narrowing.ts` throws instead of silently corrupting later
resolutions now that the array is shared across call sites.
- `_scaling_note`'s "measured 0.93-1.21" band did not reproduce; it is now the
honestly observed 1.28-1.45, with the small arm widened to ~14ms (halves the
spread) and a triage line saying a scaling failure is a timing signal to
re-run, unlike the deterministic fingerprint arm.
Verification: 840,000 differential probes against the pre-#2788 walker
extracted from base, 0 mismatches, plus 48,000 candidate-order comparisons,
0 mismatches. cpp resolver integration suite 334/334. Unit suite 10/10.
tsc, eslint, prettier clean. Bench --check PASS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bench): escape the NUL separator so measure.mjs stays a text file
The fingerprint key separator was written as a literal NUL byte instead of the
`\u0000` escape. Git classifies any file containing a NUL as binary, so the
whole bench showed as `Bin` with no diff on GitHub and could not be reviewed —
the same defect this branch already fixed once before the tri-review.
Escaping it is byte-for-byte equivalent at runtime (both produce U+0000), so
the committed fingerprint is unchanged and `--check` still passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cpp): quality cleanups from a four-angle review of the #2788 series
Reuse, simplification, efficiency and altitude passes over the diff. No
resolution behaviour changes: the bench fingerprint is unchanged and the C++
resolver integration suite still passes in full.
Efficiency
- The `Object.freeze` added in the review-fix commit to close an aliasing
residual costs 4.6x on the narrowing path — V8 moves frozen arrays to
PACKED_FROZEN_ELEMENTS, off the fast path for the `.filter`/`.map`/`.some`
runs `narrowOverloadCandidates` does at every multi-candidate call site.
The hazard it guards is already a compile error (both the memo and the
parameter are `readonly SymbolDefinition[]`), so it is now a dev-only
tripwire. Gated on `isSemanticModelValidatorEnabled()` — the repo's opt-IN
form used by `phase.ts` and `validate-bindings-immutability.ts` — not
`adl.ts`'s opt-out `NODE_ENV !== 'production'`, which would keep paying the
cost in CLI runs where `NODE_ENV` is unset. Large bench arm 100.3 -> 70.6ms.
- The index build scanned every scope in every file twice; pass 1 now collects
`[scope, node]` pairs for pass 2 to iterate. 800k scopes 14.40 -> 8.21ms.
- `simpleNameOf` uses `lastIndexOf('.')` + `slice` instead of
`split('.').pop()` (83 -> 24 ns/call), semantics verified byte-equivalent
over 12 edge cases including `undefined`, `''`, `'a.'`, `'.b'` and `'a..b'`.
- The per-call `hookCtx` literal is hoisted to a module const.
- The bench's fingerprint pass resolved 960k call sites to produce 5,800
distinct outcomes; it now dedups on the key it already builds. Bench wall
time 2.4 -> 1.9s, fingerprint byte-identical.
Reuse
- `bucketOwnMembers` used an inlined `Function | Method | Constructor` compare;
it now calls the canonical `isOverloadableCallable`. `graph-bridge/ids.ts`
already carries a note that inlining this list recreated twin-list drift once.
Altitude
- `adl.ts` had the same retention defect this series just fixed next door: a
module-level `let adlIndex` + `let adlIndexSource` strongly pinning the whole
`parsedFiles` array until the next C++ pass, which in a single analyze never
comes. Converted to the same `WeakMap` shape. Measured with `--expose-gc`:
89.11MB retained after the caller drops the array before, 0.17MB after. Six
file-local helpers now take the index as a parameter; no exported signature
changed.
- The index's freshness depended on `clearCppInlineNamespaces()` being called
from another file, guarded only by a warning paragraph. An epoch bumped in
both `populateCppInlineNamespaceScopes` and the clear is now stored with the
memo, so a missed clear degrades to a rebuild instead of a stale answer —
confirmed by driving inline state mid-pass without the clear. Roughly line
neutral, since it replaces most of the paragraph.
- `test/integration/cpp-adl-benchmark.test.ts` is wired into the
`GITNEXUS_BENCH` step. It is `skipIf`-gated and was absent from that step's
explicit file list, so #1990's ADL emit-scaling guard had never executed in
CI. It passes; ~50s added to a 25-minute job. `cpp-pipeline-benchmark.test.ts`
is deliberately NOT wired: it costs 115s for guards covering generic
per-language pipeline scaling that nothing here touches.
Simplification
- Deleted a comment referencing a `inlineChildrenByParent` map that only ever
existed inside this branch's own first commit, so "the legacy map" pointed a
reader at code that never shipped.
- Dropped three unreachable `undefined` guards (`strict: false`, no
`noUncheckedIndexedAccess`), keeping the load-bearing `visited` check.
- Compressed the `rootsByReceiver` doc from 17 lines to 7 — it was the longest
comment in the file and guarded the least consequential property — the
`validateAdlSeqCoverage` paragraph from 7 lines to 3, and turned three
restatements of the uncaught-throw and dedup arguments into pointers.
- Test fixtures: dropped the dead `'Module'` union arm, added a one-line `ns()`
builder for the nine hand-written scope literals, and moved the file to
`test/unit/scope-resolution/cpp/` where every other C++ scope-resolution unit
test lives. 403 -> 337 lines, same 10 tests, and the cross-file mutation check
still fails exactly one test.
Not done, and why: merging this index into `AdlCandidateIndex` (they key on
different names with different inline-transparency depth — a refactor with
correctness risk, not a cleanup); `ScopeTree.getChildren` (trades in-memory
`parsed.scopes` for store hits on a hot path); a shared `cpp/` util for the five
pre-existing `simpleName` copies; sharing fixtures across the bench/test
boundary (no precedent in this repo); and an exact-count arm for the bench,
which is a gate redesign worth its own change.
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>
* fix(swift): preprocess indented conditional directives so class bodies survive parsing
* fix(swift): make conditional-directive blanking comment-, string- and brace-aware (#2771)
Addresses the review findings on PR #2771. The transform fired
unconditionally, which turned valid Swift into parse errors while missing the
most common shape it was written for.
- The blank/keep decision now consults `blockCommentDepth`, so ` #endif */` —
the result of commenting out a conditional block — keeps its comment
terminator. Previously `hasError` went raw=false -> preprocessed=true and the
rest of the file was swallowed.
- The decision keys on the scanner's brace depth instead of indentation. A
column-0 `#if` inside a class body is blanked (6 of 7 body shapes previously
still lost the enclosing declaration) and an indented file-scope directive is
not — matching what the doc comment already claimed. Bare-CR line endings,
NBSP/ideographic indentation and a leading BOM are recognized too.
- A group is blanked only when every branch is brace-balanced. An `#if`/`#else`
that splits a declaration header leaves one unmatched `{` once both branches
survive, which collapsed five top-level nodes into one and gave unrelated
types fabricated `NetworkClient.` qualified names. Such a group now degrades
to the pre-fix behavior.
- Multiline strings honour `\"""` escapes, and a plain `"""` closes even when a
`#` follows it, so the scanner no longer wedges in string state and silently
stops blanking for the rest of the file.
- The pound run is counted once per position and skipped. It was quadratic:
10.6s for one 64k-`#` line, well inside the 512 KB walker limit.
- Extended regex literals (`#/.../#`) no longer open a phantom block comment.
- Directive-free files return early, matching `stripUeMacros`.
Worker parity: `emitSwiftScopeCaptures` and `emitCppScopeCaptures` re-apply
their provider's `preprocessSource` on the parse-cache-miss path — Dart already
did this — and the embedding parse in `ensureAndParse` applies the hook as
well. Before this the worker and the scope-capture/embedding halves analyzed
different programs, turning a consistent degradation into cold-run/warm-run
non-determinism. A new parity test pins the equivalence for every provider that
defines the hook.
SCHEMA_BUMP 37 -> 38: this changes parse semantics, the chunk key hashes raw
on-disk bytes, and `preprocessSource` runs after the key is computed — so a
same-package-version warm cache would replay pre-fix Swift results verbatim,
including across `--force`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(ingestion): apply preprocessSource once in the scope bridge (#2771)
Follow-up cleanup on the review fixes. The previous commit re-applied each
provider's `preprocessSource` inside `emitSwiftScopeCaptures` and
`emitCppScopeCaptures`, mirroring what Dart already did — three copies of the
same rule, and a contract that asked every future emitter to remember it.
`extractParsedFile` is the single funnel every `emitScopeCaptures` caller
passes through (parse worker, scope-resolution run, Vue script extraction), and
it already receives the provider. Applying the hook there on the cache-miss
path covers all three languages and every future one, names no language in
shared code, and drops Dart's unconditional transform on the cache-hit path.
Verified the three emitters use `sourceText` for nothing but the parse, so the
substitution is output-identical — which the parity test asserts directly.
Also from the cleanup pass:
- the parity test derives its language list from the provider registry, so a
new provider adopting the hook fails until it adds a fixture
- `ensureAndParse` resolves the provider from the language it already computed,
instead of a second extension table (`getProviderForFile`)
- the preprocessor returns `sourceText` unchanged when no group was blanked,
which is the common case for files whose only directives are top-level
- `split(/(\r\n|\n|\r)/)` replaces the hand-rolled line splitter, and the
per-group brace bookkeeping is two scalars instead of an array
- the hint regex is derived from the line regex so the two cannot drift
- unit assertions compare the WHOLE preprocessed file against the expected
blanking, replacing per-line spot checks; the pipeline tests share one
`runFixture` helper and `getNodesForFile` in the resolver test helpers
- `LanguageProvider.preprocessSource` documents the real call sites and says
plainly that the set is not closed — `populateRangeBindings` still hands
language helpers raw text
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* 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>
* fix(ci): stop CI Report dying silently when the tests job fails
The "Build report" step in ci-report.yml runs under
`bash --noprofile --norc -e -o pipefail`. It located its inputs with
UNIT_SUMMARY=$(find "$DIR/test-reports" -name ... 2>/dev/null | head -1)
`coverage-merge` in ci-tests.yml is `needs: tests` with no `if: always()`,
so any failing shard skips it and the `test-reports` artifact is never
uploaded. `find` then runs against a directory that does not exist and
exits 1; `-o pipefail` carries that status through `| head -1`, the
command substitution hands it to the assignment, and `-e` kills the step.
The death is invisible: `2>/dev/null` discards find's error and the whole
report is built into `$GITHUB_OUTPUT`, so the step logs nothing and just
reports "Process completed with exit code 1". "Comment on PR" is then
skipped, so the CI Report workflow fails and posts nothing on exactly the
PRs whose tests failed — when the report is most useful. The
"Coverage data unavailable" fallback already existed for this case but
was unreachable, because the script died ~160 lines before it.
Route the four lookups through a `find_first` helper that returns empty
when the root is absent. Verified by extracting the step body and running
it against both artifact layouts: with `test-reports` present the output
is byte-identical to the previous script (1335 bytes), and with it absent
the step now exits 0 and emits the coverage-unavailable report instead of
exiting 1 with an empty $GITHUB_OUTPUT.
Observed on 32 of the last 100 failed runs; correlation with the tests
job's conclusion was 6/6 failure and 4/4 success in the sampled runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): let the prebuild assertion report a missing .node
`Build prebuild` deletes `$pkgdir/prebuilds` before running prebuildify,
so a run that emits nothing without failing leaves `find` searching a path
that no longer exists. Under the step's `shell: bash` (`-e -o pipefail`)
that `find` exits 1 and kills the step before the `test -n "$out"` guard
below it — the guard written to explain exactly this case never runs, and
the job dies with a bare "Process completed with exit code 1".
Same shape as the `ci-report.yml` fix in this PR: a lookup that exits
non-zero on an absent root pre-empts the fallback beneath it. `|| true`
hands the empty result to the guard, which still fails the build, now with
`::error::prebuildify produced no .node`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* feat(scope-resolution): make MAX_CALLABLE_VALUE_TARGETS configurable via env
The branch's original commit was a whole-file snapshot taken at a stale base
and never touched the constant, so the env read was missing and the branch's
own test failed. Implemented here, matching the sibling
GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT knob.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(callable-value-flow): add env override tests
* docs(callable-value-flow): document GITNEXUS_MAX_CALLABLE_VALUE_TARGETS env
Adds a Troubleshooting subsection to README.md and a commented entry to
gitnexus/.env.example for the new per-callable-site dispatch-target cap
(default 32), following the maintainer's review request to document the
knob alongside its implementation.
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(schema): declare Swift member-containment pairs in CONTAINS DDL
* fix(schema): declare remaining Rust impl/trait and JS/TS object-literal HAS_METHOD pairs; guard streamed emit sinks against undeclared pairs (PR #2769 review)
* refactor(schema): share one declared-pairs constant across router and sinks
DECLARED_REL_PAIRS was being computed independently in three places
(csv-generator.ts, graph-emit-sink.ts, pdg-emit-sink.ts) from the same
static RELATION_SCHEMA parse. Export the existing constant from
csv-generator.ts (already imported by both sinks) instead.
assertDeclaredPair now takes the pre-built pairKey rather than the two
labels, since every caller (RelPairRouter.route, both sinks' addRelationship)
needs that same key immediately after for its own Map/stream lookup on the
per-streamed-edge hot path — avoids rebuilding the template string twice
per edge.
Also drops two schema.test.ts assertions that duplicated coverage already
in the more narrowly-named regression tests below them, and trims the v32
ladder comment to point at assertDeclaredPair's docstring instead of
re-explaining the same failure mechanism.
* fix(schema): use replaceAll for the pair-arrow error message (CodeQL)
.replace(str, ...) only touches the first match; CodeQL flags that as
incomplete string escaping regardless of the caller's invariant that
pairKey contains exactly one '|'. replaceAll is equivalent here and
silences the alert.
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* feat(scope-resolution): make MAX_PROPERTY_DISPATCH_FANOUT configurable via env
* test(property-dispatch): add env override tests
* docs(scope-resolution): document GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT in README and .env.example
Add a dedicated troubleshooting subsection and .env.example entry for the
GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT environment variable, matching the
format used by the sibling GITNEXUS_MAX_CALLABLE_VALUE_TARGETS knob.
Closes maintainer request: "Could you please document this in the readme
plus the .env.example?"
---------
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
* fix(jvm): bound same-package sibling injection
* docs(jvm): document sibling injection cap
* fix(jvm): mark truncated sibling sets incomplete and bound the merge
Review follow-ups on the sibling injection cap (#2732):
- The cap silently produced a third visibility state. Before it, a file was
either fully visible (package under 500 files) or fully incomplete; once
`injectedIds.size` hit the cap, real siblings were dropped while
`isVisibilityIncomplete` still returned `false`. That flag gates wildcard
attribution in seven Spring passes (`bean-candidates.ts:199` and the java/
kotlin bean-metadata, conditionals, config-bindings and DI resolvers), so
201-500-file packages — exactly this cap's population — resolved wildcard
annotations against a truncated sibling set with no log signal. Truncation
now marks the file incomplete and analyze warns once with the affected file
count.
- The cap only bounded `bindingAugmentations`; the two `typeBindings` merges
below it still absorbed every sibling, so a class excluded from the binding
set could still steer receiver/variable type inference through
`scope.typeBindings`. Both halves now use the same bounded sibling set, and
the merge iterates that set directly rather than filtering a full rescan, so
the cap bounds the work as well as the result.
- Path segments are split once per bucket instead of on every pairwise
proximity comparison — that comparison runs O(files²) per package.
- `JvmPackageFact` was re-declared locally instead of imported from
`package-facts.js`, where the canonical declaration still serves both
languages' facades and capture side-channels. Nothing kept the copies in
sync. Restored the import.
- README/.env.example: `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift the
fixed 500-file package skip (including at `0`), and truncation disables
wildcard attribution for the affected files. Both are now stated.
* test(jvm): restore the language-facade coverage and pin the cap's behaviour
The cap rewrite replaced the per-language harness with generic fixtures,
dropping the Java/Kotlin capture-side-channel and facade coverage (package fact
extraction, the 500-file skip, fail-closed on a file that produced no
ParsedFile) and leaving a proximity fixture whose candidates were already in
order — so it could not tell a working sort from plain truncation of the input.
Restores that harness and adds cap-specific cases on top, driven through the
shared JVM factory. The fixture interleaves near and distant siblings, so the
retained set is only reachable by a working proximity sort. Covers: the exact
capped set, truncation marking the file visibility-incomplete, type bindings
bounded by the same sibling set, the unbounded `0` override staying complete,
and the documented default of 200 applying when the variable is unset.
Each new case fails against the pre-fix implementation.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* fix: serialize global registry mutations
* fix: serialize global registry mutations
* fix: keep registry reads lock-free
* test(registry): document cross-platform lock coverage
* fix(registry): isolate the registry lock's namespace, timeout and diagnostics
Review follow-ups on the global registry lock (#2716):
- The lock took `getGlobalDir()` itself, which is byte-identical to a repo's
index slot when that repo is rooted at the user's home directory (a real
dotfiles layout). `runFullAnalysis` holds the per-repo lock across its whole
pipeline and `acquireIndexLock` is not reentrant, so `registerRepo` /
`adoptFlatBranchLabel` self-deadlocked until the wait ceiling and then failed
the analyze. The registry now locks a private `<globalDir>/registry-lock`
namespace no index slot can ever resolve to.
- The lock inherited the index lock's 10-minute default timeout, sized for
multi-minute analyze runs. `gitnexus augment` — documented to cold-start in
under 500ms and shelled out from editor tool-use hooks — reaches it through
`listRegisteredRepos({ validate: true })`. Registry transactions are
sub-second, so they now get their own 5s ceiling.
- Contention was silent: no `log`/`onWaitStart` was wired, and the primitive's
own texts attribute a wait to "another gitnexus analyze", which misnames a
registry holder. A registry-specific line is emitted on wait start instead.
- On timeout the transaction now proceeds unlocked with a warning rather than
throwing. The lost-update race it guards was unguarded before this branch, so
degrading to the old best-effort behaviour beats failing `analyze`/`list`/
`index` outright — none of which wrap these calls in a handler — on a wedged
lock.
- `adoptFlatBranchLabel`'s recursive `fs.rm` no longer runs inside the lock;
only the closing re-read/mutate/write does, mirroring `clean.ts`, which
deletes the branch directory before calling the locked `removeBranchIndex`.
A slow delete no longer blocks every registry operation on the machine.
* test(registry): cover the remaining locked mutators and the colliding layout
Three of the five functions the registry lock wraps had no overlap coverage, so
a future narrowing of the lock would go unnoticed. Adds:
- overlapping `removeBranchIndex` calls on two branches of one entry,
- an overlapping `unregisterRepo` / `registerRepo` pair on distinct repos,
- a registration issued while an index lock is held on the global directory,
which reproduces the home-rooted self-contention the lock namespace fix
addresses.
Each fails without the corresponding fix: the two overlap tests lose an update
when `withRegistryLock` is bypassed, and the collision test sees the wait
announcement and the degraded-write warning once the lock namespace is reverted
to `getGlobalDir()`. The collision test asserts on those log records rather
than on elapsed time, so it stays deterministic on a slow runner.
* perf(registry): keep the validation walk out of the registry lock
`listRegisteredRepos({ validate: true })` held the global lock across its
read-only validation walk — an `fs.access` per entry, slow on a network mount
or a large registry — even though the common case prunes nothing and writes
nothing. That is the same lock `gitnexus augment` takes on every editor tool
call, so unrelated registry work serialized behind a walk that never touched
the file.
The walk now runs unlocked; the lock is taken only when an entry is provably
gone, and the prune is applied to a snapshot re-read inside it, so a
registration that lands during the walk is no longer clobbered by a stale
write. Same shape as the `adoptFlatBranchLabel` split.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* fix(mcp): surface resolved repo/branch/indexed-at in the FTS-degraded warning
Turns the generic "FTS indexes missing" message into a diagnostic
that reveals what this MCP session actually resolved, so a CLI/MCP
mismatch or stale-connection theory is visible in the warning text
itself instead of requiring a separate debugging round-trip (#2767).
* fix(mcp): stop swallowing real FTS query errors behind the missing-index message
queryFTSViaExecutor previously collapsed 'index genuinely missing' and
'a real query/connection error occurred' into the same silent null,
so a real failure could masquerade as the generic FTS-degraded
message with no diagnostic trail — even when it happened on only
some of the per-table queries while others succeeded. Classifies the
failure (mirroring queryFTS's own check for this exact cypher call),
always logs a non-benign error server-side regardless of overall
outcome, and surfaces it (redacted) in the client warning only when
every table failed (#2767).
* fix(mcp): give --repair-fts a dedicated freshness signal for warm readers
--repair-fts intentionally never restamps indexedAt (it doesn't
regenerate the graph), so a long-lived MCP session's pool staleness
check had no explicit signal that a repair happened, only the
incidental file-identity delta. Reuses the existing (forensic-only)
capabilities.fts.status field: repair-fts now stamps just that
sub-field (everything else byte-identical), and ensureInitialized
compares it as a third, independent reinit trigger alongside the
existing stamp/identity checks, seeded at cold init too so a fresh
process's first warm check doesn't false-trigger (#2767).
* test(mcp): warm session picks up an out-of-band --repair-fts rebuild (#2767)
New end-to-end integration test: a real writable LadybugDB session
builds an index WITHOUT FTS, a real LocalBackend observes 'FTS
indexes missing' through the real pool, a separate writable session
performs the exact repair-fts writes (real createSearchFTSIndexes +
the #2767 capability-only meta stamp), and the SAME still-warm
backend re-queries successfully without a restart — closing the one
end-to-end gap no existing test covered.
Running this against the real engine surfaced a second real message
shape for a missing FTS index ("doesn't have an index with name X",
not just "does not exist") that the U2 classifier didn't recognize —
fixed classifyFtsQueryError to match both, with a regression test
pinning the exact observed string.
* fix(review): address code-review findings on the #2767 FTS fix
- Anchor classifyFtsQueryError to the exception class (mirroring
isBenignDropFtsIndexError) instead of a bare substring search, so a
real, differently-classed error that happens to echo the benign
phrase in its body (e.g. an echoed user query) can't be
misclassified as a benign missing-index (adversarial review).
- Re-read the on-disk meta immediately before the --repair-fts
capability stamp write instead of reusing the pre-rebuild snapshot,
so a concurrent writer (e.g. the HTTP server's background embedding
checkpoint job) landing mid-repair isn't silently reverted.
- Surface a client-facing partial-result warning (mirroring the
existing enrichmentDegraded convention) when some FTS tables
succeed but at least one hits a real error, instead of only logging
it server-side.
- Update RepoMeta.capabilities' stale 'no programmatic readers'
docstring now that ensureInitialized reads capabilities.fts.status.
- Widen the warm-session integration test's polling deadline for more
margin over the production 5s staleness-check throttle.
* fix(ci): drop the cold-init loadMeta call ensureInitialized never needed
It stole the mocked loadMeta call an unrelated upstream PDG test
depends on (test/integration/impact-pdg-statement-precise.test.ts
queues a single mockResolvedValueOnce for its own PDG-config read;
the extra call consumed that slot before the PDG code ran, so it
fell through to the mock's null default and epistemic came back
undefined instead of 'pdg-intra-procedural'). Cold init now leaves
lastObservedFtsStatus unseeded — the cost is at most one redundant
initLbug call on the first warm check, which no-ops via a single
fs.stat when nothing actually changed, not a real reopen.
* fix(review): address tri-review findings on the #2767 FTS fix
Fixes two P1s (misleading repair-fts advice on real query errors;
embedding-checkpoint job silently reverting the capabilities.fts stamp
for up to its 30-minute lifetime), five P2/P3s (stale indexedAt in
warnings, extension-unavailable noise, mismatched log severity, a
table-missing vs index-missing conflation confirmed against a live
LadybugDB, and a reinit-watermark latching bug), and the four residual
items already self-disclosed in this PR's description (shared FTS
error classifier, consolidated per-pool observed-state map, a
redactPaths whitespace gap, and an isolated ftsCapsChanged test).
A /simplify pass afterward caught one more real bug: the
extension-unavailable short-circuit only guarded the MCP pool path,
so the CLI-path fix above it started surfacing spurious non-benign
errors for the same expected degraded state the pool path stays
silent on — now both paths agree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(ingestion): join multi-line closure bindings on initializer startLine
Graph-node captures sit on the outer binding wrapper while scope-resolution
anchors on the inner callable; the line-only position join missed when those
split across lines and fail-closed dropped the real CALLS edge (#2735).
* fix(ingestion): unwrap Ruby call+block for multi-line lambda joins
Cover Kotlin/Ruby/Dart multi-line closure CALLS in integration tests, and
dig Ruby's call/block field so do-end bindings join on the block start line.
* style(ingestion): format closure join changes
* fix(ingestion): make closure position join language agnostic
* fix(rust): let the qualified-call filter see inline modules
The negative filter added in #2741 builds its set of known module names from
FILE PATHS, so an inline `mod x { … }` — which appears in no path — was absent
from it. Every module-qualified call into an inline module was therefore
rejected before any candidate channel ran, which is a hole in that optimisation
rather than in the resolution logic it guards.
The per-pass index now unions the file-derived names with inline module names
taken from the scope model: a `mod` declaration binds a `Namespace` def locally
in the declaring scope, and that binding is the only place an inline module's
name exists. Collected in the same walk that already builds the module → scope
map, so it costs no extra pass.
Found while fixing #2742, where a correctly resolved call into `mod inner { … }`
still could not reach its target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(scope-resolution): try the namespace-prefixed node key before the bare one
`resolveDefGraphId` looked up the plain `qualifiedName` key first and only
retried with the `namespacePrefix`-qualified key afterwards. For the defs that
carry a prefix the qualified name is a bare TAIL, so the plain key happily
matched a same-named item at a different namespace depth in the same file and
returned it before the more specific retry was ever reached.
The namespace-prefixed key is strictly the more specific of the two, so it is
now tried first. Where no such node exists the lookup falls through to exactly
the previous order, which keeps the #1982 behaviour this retry was added for.
Without this, a call into `mod inner { fn dispatch }` resolved to the correct
definition and then mapped it onto the crate-root `fn dispatch` node — the
self-loop #2742 describes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742)
Node identity is `<label>:<file>:<qualifiedName>` and carried no module path, so
an inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
file collapsed onto `Function:<file>:dispatch`, first-wins. Resolution already
picked the right definition — the target simply was not representable, so a
correct resolution still rendered as a self-loop and `impact` reported the real
callee as unreached.
The mechanism already existed: `qualifyRustImplTargetByModScope` has walked
`mod_item` ancestors for impl targets since #1982. Generalised to
`qualifyByEnclosingModScope` and applied to free items, so
`mod inner { fn dispatch }` becomes `Function:<file>:inner.dispatch`. Keyed
purely on the `mod_item` node type, exactly as the impl qualifier already was,
so it is a no-op for every language whose grammar has no such node.
Two constraints found by tests rather than by reading, both now encoded:
- The helper normalised `::` to `.` unconditionally. With no enclosing `mod`
that rewrote a top-level `impl a::Inner` from `a::Inner` to `a.Inner` and
moved its node id away from the one the HAS_METHOD owner edge emits,
breaking the #1975 scoped-impl ownership. It now returns raw text untouched
when there are no mod segments, which also makes the change strictly
additive for every id that has no enclosing module.
- Qualification is scoped to items with no enclosing class/impl. A method
already carries its owner's name, and that owner's id is mod-scoped by the
impl qualifier, so qualifying the method again breaks the same byte-for-byte
agreement. Same-named methods on same-named types in sibling modules
therefore still collapse — a narrower residual than the free-item case fixed
here, and one belonging to the owner edge rather than to this path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(storage): bump schema versions for the mod-qualified Rust node ids (#2742)
`INCREMENTAL_SCHEMA_VERSION` 24 -> 25 and `SCHEMA_BUMP` 31 -> 32.
Node ids change for every Rust item inside any `mod` block, and
`#[cfg(test)] mod tests` makes that close to every Rust repository. A pre-v25
index therefore holds ids an incremental top-up cannot reconcile — the old nodes
would simply be stranded — so the reuse gate has to force a full re-analyze. The
qualified name is computed in the parse worker, so a warm parse cache would
likewise replay the old unqualified ids and keep the collapse.
This branch originally claimed v24; #2708 took that number and merged first, so
it is renumbered to v25 here. That is exactly the collision the v29 note in
parse-cache.ts warns about, and re-checking against origin/main at rebase time
rather than at branch time is what caught it. #2708 did not touch `SCHEMA_BUMP`,
so 32 is free.
The version-pin test moves with the bump by design, including the new pre-v25
row in the reuse-gate table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): stop mod-qualifying container ids while their owner edges stay bare (#2745 review)
#2742 re-keyed Rust node ids by the enclosing `mod` chain. The mint moved; the
owner-edge anchor did not. `findEnclosingClassInfo` mints a member's owner id
from the container's BARE `nameNode.text` and only follows a qualified shape when
the provider sets `classExtractor.qualifiedNodeId`, which Rust does not.
So every `struct` / `trait` / `enum` / `impl` declared directly inside a `mod`
got a node id that none of its member edges pointed at. Five lines of idiomatic
Rust were enough:
pub mod engine { pub struct Config { pub retries: usize } }
NODE Struct:src/lib.rs:engine.Config
DANGLING HAS_PROPERTY Struct:src/lib.rs:Config -> Property:src/lib.rs:Config.retries
The rows are discarded by the IGNORE_ERRORS COPY retry, so the struct silently
lost every field. A trait impl inside a `mod` additionally dropped its
METHOD_IMPLEMENTS edge outright.
The same gap put `impl a::Inner` inside a `mod` back on the #1975 rake that
`qualifyByEnclosingModScope`'s own docblock warns about. The impl-target branch
deliberately fires only for an UNSCOPED `type_identifier`; the new gate had no
such restriction and picked up the scoped targets that branch had just excluded,
minting `Impl:<file>:outer.a.Inner` against an anchor still reading
`Impl:<file>:a::Inner`.
The member side was already excluded via `!enclosingClassInfo`. This adds the
owner side, gated on `MEMBER_OWNER_NODE_TYPES` — derived from
`CLASS_CONTAINER_TYPES`, which is already the single source of "this node type
owns member edges" and already carries an INVARIANT note binding it to
`CONTAINER_TYPE_TO_LABEL`. A language adding a container therefore cannot gain a
mismatched id shape here without also failing that invariant. Keyed purely on
tree-sitter node types, so no language name enters shared ingestion.
`union_item` is listed too: its fields are captured as Property but it is not a
recognized owner, so they carry no HAS_PROPERTY edge and cannot dangle — it is
here so a union's id keeps the same shape as the struct beside it.
Containers still collapse across sibling modules, exactly as before this fix.
That residual belongs to the owner edge, and is not worked around here.
Regression tests use the UNFILTERED `findDanglingEdges(result)`. Every other
dangling assertion in `rust.test.ts` passes `['HAS_METHOD']`, which is precisely
why the HAS_PROPERTY breakage shipped with a green suite. They assert the NODE
id rather than only the edge's anchor, because the anchor was already bare while
the bug was live — an edge-only assertion passes in both builds. All four fail
when the new gate clause alone is reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
* fix(rust): resolve modules nested inside an inline mod (#2745 review)
The #2730 self-loop survived one `mod` deeper:
pub mod outer {
pub mod tools { pub fn dispatch() {} }
pub fn dispatch() { tools::dispatch(); }
}
CALLS outer.dispatch -> outer.dispatch <- the #2730 symptom
NODE outer.tools.dispatch <- correct target, unlinked
Two gates were blind to a nested inline module, so the hook refused and the shared
lexical tier bound the call to the enclosing same-name `dispatch`:
`knownModuleNames` was collected by walking `moduleScopeByFile`, which maps a file
to its ROOT `Module` scope only. A `mod` nested inside an inline `mod` binds in the
parent module's scope, so the walk saw depth-1 inline modules and missed every
nested one — `tools` never entered the set and the negative filter rejected the
qualifier before any candidate ran.
`declaresSubmodule` had the same root-only assumption, so even with the name known
the candidate `outer::tools` was never yielded.
Both now read the def index. Names come from every `Namespace` def; inline module
PATHS are derived from the members' `namespacePrefix` rather than from the `mod`
defs, because a `mod` def carries no nesting information of its own — inside
`mod outer { mod tools { … } }` the inner def is `qualifiedName: 'tools'` with NO
`namespacePrefix`, while every def within it is stamped `outer.tools`. A
`Namespace` scope also owns its OWN def rather than its children's, so the scope
tree cannot answer this either: the `mod outer` scope lists `outer`, never `tools`.
Restricted to non-empty prefixes, so this stays a DECLARATION check. Including
file-derived modules would let an undeclared or `cfg`-gated file on disk outrank a
real `use` binding — the regression #2741's review already fixed once. File-backed
submodules therefore keep going through the binding check.
A module with no defs at all is absent from the set, which is harmless: it has no
member for a qualified call to resolve to.
Cost is one pass over an already-resident def index, memoized per resolution pass
on the existing WeakMap — the same order of work as the binding walk it replaces,
and it subsumes it. `isLocalNamespaceBinding` was going to single-source the
duplicated "locally declared submodule" predicate the review flagged; deriving
paths from members removed the second copy outright instead.
Regression fixture covers depth 2 and depth 3, so the fix is depth-agnostic rather
than depth-2 special-cased.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
* fix(rust): let an imported type outrank a same-named module (#2745 review)
Widening the negative filter to inline `mod` names let a type-qualified call
through whenever a module happened to share the type's name. The
crate-root-relative candidate then captured it:
// src/lib.rs
pub mod Buffer { pub fn with_capacity() -> usize { 111 } }
// src/b.rs
use crate::c::Buffer; // the real target lives in c.rs
pub fn call() -> usize { Buffer::with_capacity() }
base: (no CALLS edge — unresolved)
PR: CALLS b::call -> Function:src/lib.rs:Buffer.with_capacity <- fabricated
`ids.ts` states the doctrine this broke: a missing edge is the correct failure
direction for a graph whose consumers include `impact`; a fabricated caller is not.
The base produced the missing edge and the PR produced the fabricated one.
That third candidate is the loosest of the three — a guess at a crate-root-relative
path the caller never wrote, kept for 2015-edition style. In Rust 2018 a bare first
segment resolves in the CALLER's module, so a local binding for that segment
settles the question: it is now skipped when the head names anything non-module in
the caller's own module. Candidates 1 and 2 are untouched, and they run first, so
the legitimate `use crate::tools;` path is unaffected.
The binding lookup goes through `lookupBindingsAt`. A first attempt read
`Scope.bindings` directly and the guard never fired: a `use` binding is finalize
OUTPUT and absent from the scope's own local table, which is exactly the
imported-type case being guarded. Contract I8 in `contract/scope-resolver.ts`
requires that channel anyway.
The regression test asserts the forbidden TARGET rather than an empty edge set, and
separately asserts the module member still exists as a node — otherwise the test
would pass just as well if the call went unresolved for some unrelated reason, or
if the module node disappeared entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
* fix(scope-resolution): try the namespace-prefixed name on the TAGGED keys too (#2745 review)
`resolveDefGraphId` gained a namespace-prefixed retry for the plain qualified key
in this PR, but the five tagged keys above it — template constraints, parameter
types, parameter shape, arity, template arguments — kept composing from the bare
`qualifiedName`.
For a namespace- or `mod`-qualified def those keys are simply dead:
`node-lookup.ts` registers them under the QUALIFIED name (`inner.dispatch#0`)
while this side built `dispatch#0`. The keys exist to separate overloads, so a
mod-scoped overload set was relying on whichever later key happened to catch it.
Verified as a miss rather than a mis-hit before changing anything — an end-to-end
run with a crate-root decoy of the same name and arity binds correctly — so this
is hygiene, not a live bug. Worth doing while the code is open rather than leaving
five keys dead and the behaviour dependent on fallback order.
Both name forms now go through one `lookupTagged` helper, most specific first, so
a sixth tagged key cannot be added with the bare form only. That also removes the
five hand-repeated `qualifiedKey(...)` / `nodeLookup.get(...)` pairs.
Also pins the C++ `EXTENDS` retarget this PR's reorder produces.
`cpp-two-phase-dependent-base-cross-ns-deep` declares a global `Inner` decoy
alongside `ns::a::b::Inner`; the base's `qualifiedName` is a bare `Inner` with the
path on `namespacePrefix`, so only the prefixed key separates them, and only if it
runs first. The improvement was riding unasserted in a Rust-scoped PR.
The captures golden covers every `rust-*` fixture, so the three fixtures added by
this review series drift it; regenerated with UPDATE_GOLDEN=1.
Verified: 785 tests across cpp / csharp / rust resolvers and the
callable-id-lockstep unit test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
* fix(rust): keep a mod declared inside a fn from hoisting above the callable (#2745 review)
`fn wrapper() { mod helper { fn dispatch } }` minted
`Function:<file>:helper.wrapper.dispatch@2:8` — the mod segment composed OUTSIDE
the enclosing-callable prefix, inverting the real nesting.
Nothing dangled: the `@line:col` suffix already makes a function-local callable's
id unique, which is also why the mod segment adds no identity in this position. The
path simply read as a lie about the source. It is now skipped rather than
reordered — interleaving two qualifier passes to fix the order would be real
machinery for a shape whose ids are already unique.
Also folds in the three documentation and structure findings from the same review:
- The 4-clause gate is extracted to a named `qualifiesByEnclosingModScope`, matching
the two conditions directly above it in the same function, which were already
named consts.
- `qualifyByEnclosingModScope`'s docblock documented only the impl-target contract
even though the generalized name has had a second, looser caller since #2742. It
now states both, and says which gate belongs to which — that gap is what let the
#1975 scoped-impl regression through in the first place.
- The "cheap rejection BEFORE any index work" comment was no longer true:
`passIndexFor` walks the def index on its first call in a pass. Corrected rather
than left to mislead the next reader into thinking the filter is free. What it
still buys — skipping the per-site candidate search, the part that scales with
the workspace — is stated instead.
Verified: 279 tests across the Rust resolver suite and the Rust scope-resolution
unit tests. Captures golden regenerated for the extended fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
* test(storage): move main's SCHEMA_BUMP pin to 32 for the mod-qualified ids (#2745 review)
`#2736` added a pin asserting `SCHEMA_BUMP === 31` on main, which arrived on this
branch through the merge of main while `0062a5c2` had already bumped the constant
to 32. Neither side conflicted textually — the pin and the constant live in
different files — so the merge was clean and the test failed instead.
That is the pin working as designed: it exists so a bump cannot ride along
unnoticed, and this is the fifth time a SCHEMA_BUMP collision has been caught by a
guard rather than by review. Updated to 32 with the reason recorded inline.
`INCREMENTAL_SCHEMA_VERSION` needs no second bump: 25 was introduced by this
unmerged branch, so no released index carries it, and its own pin in
`call-summary-schema-version.test.ts` is already consistent.
Verified: 119 tests across the parse-cache, schema-version, incremental-orchestration
and the two identity suites that arrived with the merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
* test(bench): rebaseline the Rust capture fingerprint for the three new fixtures (#2745 review)
CI caught what I missed:
[scope-capture --check] FAIL: rust: capture fingerprint drift
(got 05acbaca..., expected 90fda086...) fixture_count 202
`bench/scope-capture` fingerprints the whole `rust-*` fixture corpus, so the three
fixtures added by this review series drift it. I rebaselined the
`rust-captures-golden` snapshot and stopped there — a new fixture is a call site of
BOTH, and updating only one is how this reached CI red.
This is the same class as PR #2743's headline finding, from the other direction: an
id-shape change makes every synthetic corpus a call site, and the author fixed the
unit-test fixture and missed the bench. Here it is a fixture-count change rather
than an id-shape change, and the review that flagged the #2743 lead as "REFUTED,
bench/ has no Rust node-id corpus" was right about node ids and wrong about the
corpus fingerprint. Noted for the next author in the baseline entry itself.
Verified as pure corpus growth rather than a capture-logic shift: removing ONLY the
three new fixture directories and re-running reproduces the prior fingerprint
exactly (196 fixtures, capture_groups_fp 3432), and restoring them gives the new
one (202, 3556). `emitRustScopeCaptures` is untouched by this series. Scaling 1.022
local / 1.057 CI, well inside the 1.5 budget.
`bench/python-scope` globs `python-*` only and is unaffected; no other bench walks
the Rust corpus. `--check` now PASSes for all 15 languages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scope-resolution): parse def coordinates after file paths
Anchor coordinate parsing to the known file path so coordinate-like path fragments and private symbol names cannot corrupt closure attribution.
* fix(bench): use production definition ids
* fix(resolution): resolve constructor-expression receivers (#2708)
`Service(db).do_work()` emitted no CALLS edge, so the caller was missing
from `impact(direction: "upstream")` and `context()` while the two-step
spelling of the same call (`s = Service(db)` then `s.do_work()`) resolved.
The receiver reaches `resolveCompoundReceiverClass` intact — Case 0 in
`receiver-bound-calls` routes it there because the text contains `(`. The
free-call branch then only knew one shape: a function whose return-type
binding names a class. A class has no return-type binding, so `Service`
resolved to nothing and the member call was dropped.
Handle the constructor shape: in languages that construct without a `new`
keyword (Python, Kotlin, Swift, Scala) a free call naming a class IS a
constructor call, so the expression's type is that class. The existing
return-type path still runs first and wins, keeping this strictly
additive — `new`-keyword languages never reach the new line because their
receiver text keeps the keyword (`new Service(db)`), which matches no
class binding.
Verified on the issue's 4-file repro: `route_inline` now emits
`CALLS → Service.do_work` and `impactedCount` goes 1 → 2.
Note the issue's second ask — degrading `epistemic` to `lower-bound` when
a receiver goes unresolved — is NOT addressed here.
`computeEpistemicBoundary` keys only on the target's own heritage edges
and runs at query time against the index, while unresolved references
live in an in-memory `resolutionOutcomes[]` that is never persisted. That
needs unresolved-receiver counts in the index first, so it is left for a
follow-up.
Tests: new `python-inline-constructor-receiver` fixture plus three
integration cases (inline resolves, two-step still resolves, no
cross-class fan-out). Two of the three fail without the source change.
Full `test/integration/resolvers` suite passes (2928 tests) — the fix is
shared across every language, so no-regression coverage matters more than
the new cases. Python captures golden regenerated: additions only, no
existing digest changed, confirming capture output is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* refactor(resolution): state the construction rule once, cover every spelling (#2708)
The first commit fixed `Service(db).do_work()` by special-casing a bare
class-name callee inside the free-call branch of the compound receiver
resolver. That was the right rule in the wrong place: it covered one
surface syntax out of three, and asserted rather than declared which
languages it applied to.
Probing the same shape across languages showed the bug is wider:
| spelling | languages | dropped before? |
|------------------------|--------------------|-----------------|
| `Service(db).m()` | Python | yes |
| `new Service(db).m()` | JS/TS, Java, C# | yes |
| `Service.new.m()` | Ruby | yes |
| both forms | PHP, Swift, Dart, | no — already |
| | Kotlin | resolved |
So the rule is stated once — "constructing a class yields an instance of
that class" — and the per-language surface syntax is declared through a
new `ScopeResolver.constructionSyntax` hook, matching how this file
already gates language-varying behaviour (`stripReceiverCastExpressions`,
`hoistTypeBindingsToModule`). Shared pipeline code names no language.
- `bare: true` — Python
- `keyword: 'new'` — JS/TS, Java, C#
- `selector: 'new'` — Ruby, including the parenthesis-less `Service.new`
spelling that reaches the chain walker rather than the call branch
Opt-in is per-language for two reasons. Correctness: `bare` would mistype
`stat(&st).field` in C, where a struct and a function may share a name.
Evidence: PHP, Swift, Dart and Kotlin resolve this shape already, so they
stay unwired instead of carrying a declaration that changes nothing —
each verified by diffing analyzer output between builds with and without
the change, not assumed.
The keyword gate also keeps a bare factory call honest: in a `new`
language, `makeOther(db).doWork()` still resolves through the factory's
return type and is never read as constructing a same-named class.
Tests: TypeScript fixture (inline `new`, a plain `.js` file for the
javascript provider, two-step, and the factory guard) and a Ruby fixture
(`Service.new` with and without an argument list, plus two-step). With
the source change stashed, the inline cases fail and the factory/two-step
cases still pass. The Python cases from the first commit are unchanged.
No Kotlin fixture: its cases passed without the change, so they would
document coverage this commit does not provide.
Full `test/integration/resolvers` + `test/unit/scope-resolution`: 4234
passed, 1 skipped. Ruby captures golden regenerated — additions only, no
existing digest changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* fix(resolution): only treat a construction selector as construction on the class itself (#2708)
The `selector: 'new'` rule fired on any receiver whose type was class-like,
which is true both when the receiver IS the class constant (`Factory.new`) and
when it is a value of that class (`factory.new`). `isClassLike(...)` cannot
tell those apart, so an instance receiver took the construction path too and
skipped the member lookup that should have run.
That replaced a CORRECT edge with a wrong one. Measured against the base build
on a class defining an instance method `new` returning a `Product`:
factory = Factory.new; factory.new.run
before this PR: Product#run (correct)
after this PR: Factory#run (wrong)
Track whether resolution currently sits on the class constant or on a value of
that class, and apply the selector rule only to the former. The head of a chain
is a class constant only when it resolved straight to a class binding rather
than through a typeBinding; every hop past it yields a value, so the flag
clears. The `obj.method()` branch derives the same fact from whether `objExpr`
is a bare name resolving to that class.
`Factory.new.run` keeps the behaviour this PR introduced (Factory#run), which
is itself a fix over the base build's Product#run.
KNOWN LIMITATION, now documented on the contract field and asserted by a test
so a future change to it is deliberate: a class-level override
(`def self.new` returning another type) is still read as construction. The
scope model records no staticness per member, so `def new` and `def self.new`
are indistinguishable at this layer; separating them needs the language
provider to record staticness first. An earlier attempt to use
`TypeRef.source` as a proxy was abandoned after tracing showed Ruby records
body-inferred return types as `return-annotation` too, so it does not
discriminate.
Tests: `ruby-construction-selector` fixture pins all three shapes — class
constant, instance receiver, and the documented class-level-override
limitation. Ruby resolver suites: 185 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* fix(resolution): resolve generic construction receivers (#2708)
`new Box<string>().unwrap()` reached the class lookup as `Box<string>`, which
names no class binding, so the member edge was still dropped while the
non-generic spelling resolved. `new Foo<T>()` is ordinary in all three
keyword-wired languages, so the fix covered a materially narrower slice of
real code than intended.
Retry the lookup on the base name via `stripTemplateArguments` — the same
normalization `resolveClassBindingForName` already applies to typed receivers
in the sibling `receiver-bound-calls` pass. The exact-name lookup still runs
first, so a class whose name legitimately contains `<` is unaffected.
Measured on the probe that first showed the gap:
before: | viaGeneric | Class:src/box.ts:Box | (construction edge only)
after: | viaGeneric | Method:src/box.ts:Box.get#0 | (member edge resolved)
Tests: `viaGenericCtor` added to the typescript-inline-constructor-receiver
fixture, asserting both the target file and that the resolved id is `Box`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* fix(resolution): resolve construction in the chain-head position (#2708)
`new Service(db).inner.deep()` emitted only the construction edge. The chain
walker seeds its starting class from the head segment, which arrives as
`new Service(db)` and reduces via `stripCallParens` to `new Service` — no
binding and no class of that name, so the walk was never seeded and every
segment after it resolved to nothing.
Seed the head through the same construction rule the call branch already uses.
A constructed value is an instance, so the class-constant flag from the
previous commit correctly stays false — `new Factory().new` does not get the
selector treatment.
The gap was asymmetric across the languages this PR wires: Python's bare form
strips to a plain `Service` and was already seeded, so only the keyword
languages were affected.
Tests: `viaChainHead` added to the typescript-inline-constructor-receiver
fixture. Note the fixture annotates `readonly inner: Inner` explicitly —
with an unannotated initializer the walk stops at the field, which is
field-type inference and a separate concern from head seeding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* fix(resolution): match the construction keyword by token, not by one space (#2708)
The keyword form was matched with `startsWith(`${keyword} `)`, so only a
single space separated `new` from the type. Any other trivia the source used
— a tab, a line break — failed the match and the member-call edge was lost.
Match the keyword as a whole token followed by one or more whitespace
characters instead. `newService()` still fails the match, which is the point:
it is an ordinary call, not a construction, and must keep resolving through
its own return type.
The keyword is escaped before it enters the pattern. It comes from a language
provider rather than from user input, but a keyword containing a regex
metacharacter would otherwise build a silently wrong pattern.
Tests: tab-separated and newline-separated `new` added to the
typescript-inline-constructor-receiver fixture. Note these cases only survive
because `gitnexus/test/fixtures/` is listed in the repo-root `.prettierignore`
— running prettier from inside `gitnexus/` does not pick that file up and
normalizes the tab away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* fix(resolution): resolve qualified construction callees (#2708)
`new ns.Service().doWork()` emitted only the construction edge. The call
branch splits the callee at its last `.` before construction is considered,
so a qualified type name was routed into `obj.method()` resolution as if
`ns` were a receiver and `Service` a member.
A keyword-marked expression is never a member call, so resolve it as
construction before the split. The callee lookup now also handles a dotted
name: an unambiguous `qualifiedNames` match first, then the trailing simple
name, mirroring how receiver resolution elsewhere in this pass degrades.
Measured:
before: | viaQualified | Class:src/svc.ts:Service | (construction only)
after: | viaQualified | Method:src/svc.ts:Service.doWork#0 |
Bare-form qualified construction (Python `models.User(db).save()`) is NOT
addressed here: that shape currently emits no edges at all, including no
construction edge, so it is a namespace-import resolution gap upstream of
this pass rather than a construction-typing one.
Tests: `viaQualifiedCtor` added to the typescript-inline-constructor-receiver
fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* fix(java): drop the unreachable constructionSyntax declaration (#2708)
Java was wired `{ keyword: 'new' }`, and the PR described it as one of the
languages that needed the fix. Measuring both ways shows it never did: Java
resolves `new Svc().doWork()` identically with and without the change,
because `java/captures.ts` (#2564) already rewrites an
`object_creation_expression` receiver to the constructed type's simple name,
so the raw `new Svc()` text never reaches this resolver.
The decisive evidence is generics: Java resolves `new Box<User>().doWork()`,
which the keyword path could not do before the template-argument fix earlier
in this series — the resolution demonstrably comes from the capture rewrite,
not from here.
Removing the declaration rather than leaving it as defensive configuration:
an unreachable per-language opt-in reads as coverage that does not exist, and
the contract now records why Java is excluded so the omission is not mistaken
for an oversight.
Verified after removal: the Java probe still resolves both the inline and
two-step spellings, and the Java resolver suites pass (252 passed, 1 skipped).
An earlier coordinator measurement in this review claimed Java WAS broken on
base; that comparison was invalid (the "without fix" build had not been
rebuilt). Corrected here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* refactor(resolution): state the selector rule once and derive its option type (#2708)
Two follow-ups from review, no behaviour change (643 resolver tests pass
unchanged before and after):
The `Class.new` selector rule was written out twice — in the `obj.method()`
branch and again in the chain walker — against differently named locals,
while the construction helper's own doc comment claimed the rule was stated
in exactly one place. Both sites ask the identical question, so they now call
one `isConstructionSelectorHop` predicate, and the doc comment says what is
actually true.
`ResolveCompoundReceiverOptions.constructionSyntax` re-declared the contract's
object shape by hand. It was the file's first object-shaped duplicate, and
because the value arrives as a non-literal variable, TypeScript's excess
property check would not fire: a sub-field added to the contract later would
type-check and then be silently ignored here. It is now derived with
`ScopeResolver['constructionSyntax']`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* test(resolution): cover the C# construction path and pin the wiring inventory (#2708)
Three coverage gaps from review, no behaviour change.
C# had no fixture despite being the only keyword-wired language whose
behaviour genuinely depends on the construction rule — measured absent on base
and present on head. `csharp-inline-constructor-receiver` covers the inline
spelling, the two-step spelling, and a static factory that must keep resolving
through its return type rather than being read as construction.
The TypeScript two-step assertion checked only `toContain('Service')`, and the
same fixture defines `LegacyService` — `'LegacyService'.includes('Service')` is
true, so the assertion could not distinguish the two targets. It now pins
`targetFilePath` the way its sibling assertions already do.
Nothing guarded the deliberate opt-in set, so an accidental wiring of a
language that already resolves the shape, or a silent loss of one that needs
it, would pass the whole suite. `construction-syntax-wiring.test.ts` pins the
inventory in both directions: exactly which languages declare
`constructionSyntax` and with which spelling, and that java/php/swift/dart/
kotlin stay unwired.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* chore(storage): bump INCREMENTAL_SCHEMA_VERSION to 23 for the #2708 edge changes
This series changes which CALLS edges are emitted for source whose CONTENT has
not changed — inline constructor receivers that previously emitted nothing now
resolve, and the Ruby selector fix moves one edge back to the member it always
belonged to. That is precisely the class of change the version-history block in
this file requires a bump for, and the reuse gate is a strict equality on the
persisted stamp.
Without it, every existing v22 index passes the gate on the next `analyze` —
or is served by the same-commit "already up to date" fast path — and keeps
returning the pre-fix graph for unchanged files. `impact(direction: "upstream")`
and `context()` would go on omitting the very callers #2708 is about, with no
warning, until something unrelated forced a full re-analyze. The fix would
have shipped without reaching anyone who already had an index.
Precedent is unbroken across the recent resolution PRs: #2723 → v22,
#2699 → v21, #2695 → v20, #2563 → v14, each with its own rationale paragraph.
This adds v23 in the same form.
The pinned assertion in call-summary-schema-version.test.ts moves with it, as
that test documents it is designed to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* chore(bench): re-baseline the fixture-corpus fingerprints for #2708
Both bench harnesses fingerprint an entire fixture corpus by directory prefix
(`bench/python-scope/measure.mjs:38`, `bench/scope-capture/measure.mjs:76`), so
every fixture directory this series adds moves a committed baseline. Neither
script writes the baseline itself — running without `--check` only prints, and
the file is edited deliberately, which is what its own comment asks for.
Regenerated, last in the series so the fixture set was final:
bench/python-scope/baseline-fingerprint.txt 36e29abc… -> f120df92…
bench/scope-capture/baselines.json ruby 070e4e11… -> fea3edf8…
typescript 281e9548… -> cad25be9…
csharp e05dc274… -> 05a85bae…
CI only ever reported the python drift, because the benchmarks job runs the
python step first and aborts there; the cross-language step never ran. Both
were verified locally after the update:
[measure --check] PASS (capture fingerprint + scaling)
[import-target-fingerprint --check] PASS (resolver fingerprint)
[scope-capture --check] PASS (15 languages)
The `csharp` and `ruby` entries moved because of the fixtures added earlier in
this series, not the original ones — a reminder that this baseline moves with
any fixture addition, not just the one that first triggered it.
Captures goldens regenerated alongside (csharp, ruby); both additive only, no
existing digest changed. The python golden did not move: no `python-*` fixture
was added after its last regeneration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP
* Update tests for passesReuseGate function
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(rust): resolve module-qualified calls against the module tree (#2730)
A Rust call written with a path (`tools::dispatch(..)`) was captured with only
its tail identifier, making it indistinguishable from a bare `dispatch(..)`.
The scope-chain walk then resolved the bare name lexically and bound it to
whatever `dispatch` was nearest — which, for the common wrapper idiom
fn dispatch(..) -> ToolOutcome { tools::dispatch(..) }
is the wrapper itself. The graph gained a self-loop, the real cross-module edge
never existed, and `impact` reported the callee as unreached: the issue's
repository showed its central tool dispatcher as `risk: LOW` with 0 affected
processes and both "callers" being `#[cfg(test)]` functions, while still
labelling the result `epistemic: "exact"`.
Resolve paths the way rustc does, over the module tree rather than the
filesystem:
- `mod_item` now emits `@declaration.namespace`, so a Rust module is a named
definition rather than an anonymous scope region. This mirrors the existing
C++ `namespace_definition` capture and lets the shared `tagNamespacePrefixes`
pass stamp members with their enclosing module path — that pass needed no
changes to start working for Rust.
- `module-path.ts` reconstructs the other half of the tree: crate roots are
directories holding `main.rs`/`lib.rs`, and a file's module path is its
location below that root. A definition's module is its file's module plus
any enclosing `mod` blocks.
- `crate::`, `self::` and `super::` are prefix transforms on the calling
module, not reasons to stop resolving.
- The final path segment is looked up as a member of the resolved module,
including members it only re-exports. A `pub use` creates no binding on the
re-exporting module's own scope, so re-exports are followed through that
module's import edges.
Resolution runs ahead of the implicit-`this` and scope-chain tiers, so an
explicit path outranks a lexical shadow, and returns undefined on an unknown
module, a missing member or a tie — leaving the existing chain untouched. The
new `ScopeResolver.resolveQualifiedFreeCall` hook is optional and unset for
every other language, so this is additive.
Fixes the reported case (direct callers 2 -> 3, impacted 2 -> 6, the Agent
module now visible) plus multi-segment paths, `super::` paths and `pub use`
facades, each of which previously produced a wrong edge.
Known limitation, pre-existing and unchanged by this commit: an inline
`mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same file
collapse to one graph node, because node identity is `<file>:<qualifiedName>`
and does not carry the module path. That is a separate defect requiring
module-path-qualified node ids and an incremental-schema migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* test(rust): rebaseline the scope-capture fingerprint for the module-tree captures
`mod_item` now emits `@declaration.namespace` and scoped call sites carry
`@reference.qualified-name`. Both are additive, so every bench fixture holding a
`mod` block or a `Foo::bar()` call gains capture groups, and the corpus grew by
the three `rust-2730-*` fixtures.
Only the Rust fingerprint moves. The other 14 languages are byte-identical,
which is the intended blast radius for a language-local capture change.
Scaling stays linear at 1.043, well inside the 1.5 budget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): carry crate identity in qualified module paths (#2741 review H1)
A module was identified by its path segments below a crate root, so
`crates/alpha/src/tools.rs` and `crates/beta/src/tools.rs` were the same module.
A cargo workspace routinely gives several members the same internal module name
— `util`, `error`, `config`, `types` are near-universal — and that made
qualified resolution do one of two wrong things:
- where only one member defined the called name, the call bound ACROSS crates;
- where both defined it, the lookup saw two candidates, refused, and handed the
site back to the lexical walk that emits the same-name self-loop. The fix for
#2730 therefore switched itself off in exactly the workspace layouts it was
written for, and #2730's own reported reproduction repository is multi-crate.
A module is now `{ crateRoot, segments }` and `sameModule` compares both. Rust
has no implicit cross-crate paths — reaching another crate requires naming it —
so two modules in different crates are never the same module. Anchored paths
(`crate::`, `self::`, `super::`) resolve inside the caller's own crate and
inherit its root.
Covered by a two-member workspace fixture where both crates define
`tools::dispatch` behind a same-name wrapper, plus unit tests for the path
arithmetic itself, including the branches no fixture reaches (a file under no
crate root, a `super::` chain walking above the crate root).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): count only module members when resolving a qualified call (#2741 review H3)
Module membership was inferred from the file path alone, so any callable in the
right file counted as a member of the module. A `fn` nested inside another `fn`
has the same `filePath`, the same bare `qualifiedName` and no owner, making it
indistinguishable from a module-level item:
pub fn dispatch() -> usize { 3 } // the real member
pub fn wrapper() -> usize {
fn dispatch() -> usize { 99 } // counted as a second member
dispatch()
}
Two candidates tie, the lookup refuses, and the call falls back to the lexical
walk that emits the same-name self-loop — so an unrelated local helper anywhere
in a module silently reinstated #2730 for every qualified call into it.
The scope model already draws the line exactly: a module-level item is bound
with `origin: 'local'` in its module's own scope, a function-local item binds in
the enclosing Block, and an `impl`/trait method binds in the Class scope.
Membership is now that binding lookup rather than a path comparison.
Inline-`mod` members bind in their Namespace scope rather than the file's Module
scope, and reaching it would mean walking every child scope — faulting them back
in from disk on the out-of-core path. They keep being identified by the
`namespacePrefix` the shared tagging pass stamps on them, which a file-module
member never carries. The documented residual is a `fn` nested inside a `fn`
inside an inline `mod`, which inherits that prefix; that is strictly smaller than
before and costs a refusal, never a wrong edge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): require a use-binding to name a module, not a type (#2741 review H2)
Import resolution deliberately strips a trailing symbol segment when probing for
a file — "the last segment might be a symbol (function, struct, etc.), not a
module. Strip it and try again" (import-resolvers/rust.ts). So
`use crate::client::ClientBuilder;` also resolves to `client/mod.rs`.
The qualified-call resolver took that at face value and treated the imported
TYPE as the module `client`. Rust impl methods carry a bare `qualifiedName`, so
`ClientBuilder::new()` was then looked up among `client`'s module members and
bound to an unrelated module-level `new` — turning an unresolved site into a
false edge, which the module's own contract calls the worse outcome.
A binding now has to name the module it resolved to. The edge's
`targetExportedName` is the tail of the written path, so comparing it against the
resolved module's own tail separates the cases exactly:
use crate::tools; tail `tools` module ['tools'] accept
use crate::a::b as tools; tail `b` module ['a','b'] accept
use crate::tools::{self, Ctx}; tail `tools` module ['tools'] accept
use crate::client::ClientBuilder; tail `ClientBuilder` module ['client'] reject
Covered by a fixture where `client/mod.rs` deliberately holds both
`impl ClientBuilder { fn new }` and a module-level `fn new`, so a regression
re-binds to the wrong one, plus a control asserting a genuine `client::new()`
module qualifier still resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): give src/bin targets their own crate root (#2741 review)
Cargo auto-discovers a binary target for every `src/bin/<name>.rs`. Each is a
separate crate with its own `crate::` root, and its submodules live under
`src/bin/<name>/`.
Only `main.rs` and `lib.rs` established a crate root, so those entry files were
folded into the surrounding library and given the invented module path
`bin::<name>`. That made `crate::helper()` inside a binary resolve into the
LIBRARY's `helper` — and unlike the other findings in this review, this one
downgraded an edge the lexical walk had previously resolved correctly, so it
made existing output worse rather than merely failing to improve it.
`src/bin/<name>.rs` is now its own crate root (as is the `src/bin/<name>/main.rs`
directory form), so a binary's modules and the library's modules of the same name
are no longer the same module.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): only try a submodule candidate the caller actually declares (#2741 review)
The first candidate module was `callerModule ++ qualifier`, yielded before the
`use` channel and never checked against anything. That let file layout outrank a
real import: with `use crate::b;` in `src/a/mod.rs` and an undeclared — or
`cfg`-gated — `src/a/b.rs` present on disk, `b::f()` bound to the sibling file,
where rustc resolves it to `crate::b`.
A `mod` declaration, inline or file-backed, emits a `Namespace` def bound locally
in the declaring scope, so the candidate is now gated on that binding rather than
assumed. When the caller does not declare the submodule the candidate is skipped
and the `use` and crate-root channels still run, so this only removes guesses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): follow only real re-exports, and refuse on an ambiguous one (#2741 review)
Two problems in the re-export channel.
A private `use` was followed as though it re-exported. `use crate::tools::helper;`
makes `helper` visible INSIDE the module; it does not put it on the module's
public surface, so `facade::helper()` does not compile. Only `pub use` does, and
finalize already distinguishes them — `reexport` for `pub use`, `named` for a
private one. The `alias` kind is now accepted alongside `reexport`, because
`pub use x::y as name` is a re-export that was previously ignored entirely.
The lookup also took the first matching edge in file-iteration order, which is
parse-pool order. Two `cfg`-exclusive facades re-exporting the same name are
indistinguishable at this layer, so picking one baked a coin flip into the graph.
It now refuses on a genuine tie, consistent with how member lookup already
behaves.
The pre-existing limitation that only FILE modules are reachable — a `pub use`
inside an inline `mod facade { … }` has no `moduleScopeByFile` entry — is now
stated in the code. Reaching those would mean walking every child scope and
faulting the scope tree back in from disk, which is the cost that index exists to
avoid; a miss falls through to the unchanged chain rather than guessing.
The regression test deliberately makes the re-exported name globally ambiguous.
Without that, the pre-existing unique-global free-call fallback resolves the call
on its own and the assertion passes whatever this channel does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* perf(rust): stop type-qualified calls paying for module resolution (#2741 review)
The capture carrying `rawQualifiedName` matches every `scoped_identifier`
callee, so this hook was reached by `Vec::new()`, `String::from()`,
`Self::method()` and every other type-qualified call — the overwhelming majority
of `::` calls in real Rust, none of which name a module. Each one ran the full
candidate search before returning undefined, and every candidate that missed then
walked all of `workspaceIndex.moduleScopeByFile`. Total cost grew as
`qualified-call-sites x files`; two independent measurements put per-site cost at
0.117 -> 0.428 ms across 301 -> 1201 files, i.e. linear in workspace size.
Two changes:
- The module index now carries a flat set of every module segment name in the
workspace, and a qualifier whose head matches none of them is rejected before
any candidate work. Measured at 0.02 us per rejected call and flat in file
count (500 -> 8000 files), against a previously linear per-site cost.
- Module scopes are indexed by module identity once per pass rather than
rediscovered by scanning every file per candidate. On the out-of-core scope
index that scan was worse than CPU: `moduleScopeByFile` fetches through
`scopeTree.getScope`, so a full sweep could fault every module scope back in
from disk — the pattern `workspace-index.ts` added `exportedCallableByName`
to avoid. Given the #2649 and #1871 history this mattered before merge.
The captures golden is regenerated for the fixture files added earlier in this
series; `emitRustScopeCaptures` itself is unchanged by this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(storage): bump schema versions so the #2730 fix reaches existing indexes
Neither invalidation constant was bumped, so the fix did not reach the users who
reported the bug.
`INCREMENTAL_SCHEMA_VERSION` 22 -> 23. The incremental write set only covers
CHANGED files, so a top-up against a pre-v23 index keeps the wrong self-loop —
and keeps reporting the callee as unreached — for every unchanged Rust file. The
constant's own doc block states this rule, and the precedent is exact: v11 is the
same file (`rust/query.ts`) gaining a capture that changes CALLS edges, with the
same "force a full re-analyze" contract, and v12 is a second Rust instance.
`SCHEMA_BUMP` 30 -> 31. `@declaration.namespace` and `@reference.qualified-name`
are parse-time captures, so a warm parse cache replays the old capture set
verbatim: `rawQualifiedName` comes back undefined and no Namespace def exists to
hang a module prefix on, turning the entire resolution tier into a no-op on
unchanged files. `PARSE_CACHE_VERSION` folds in the package version, so a tagged
release would have invalidated eventually — but source, dev and CI builds at the
same version would not, and the v29 note already warns that relying on someone
else's bump is how a change ships with no invalidation at all. Re-checked against
origin/main at commit time, as that note instructs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(scope-resolution): let a language opt out of the already-namespaced guard (#2741 review)
`tagNamespacePrefixes` skips a def whose `qualifiedName` already equals, or is
prefixed by, its enclosing namespace path. That is right for C++ and C#, where
the qualified name genuinely carries the namespace.
Rust qualified names never do, so the guard fired on a coincidence: in
`mod a { pub fn a() }` the member's name equals its module's name, the prefix was
skipped, and `moduleOfDef` then reported the member as belonging to the PARENT
module. `crate::a::a()` refused, and the def became indistinguishable from a
crate-root `fn a` for the module matcher.
The guard is now conditional on a `qualifiedNamesCarryNamespace` option that
defaults to the existing behaviour, and Rust opts out. The shared pass stays
language-neutral — the decision lives with the provider that knows what its own
qualified names contain.
C++ and C# resolver suites pass unchanged alongside the Rust ones (600 tests).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* fix(rust): refuse a leading :: path instead of reading it as relative (#2741 review)
A leading `::` anchors at the extern prelude: `::tools::dispatch()` names the
CRATE `tools`, not a module of the current one. The path split filtered the empty
leading segment away, which silently reinterpreted the path as relative and let
it resolve against a local module that happens to share the name.
Extern crates are outside the workspace module tree, so the qualified tier now
refuses and leaves the site to the unchanged chain.
The regression test asserts the tier does not bind into the local `tools` module,
rather than asserting no edge at all: the lexical tier still resolves the bare
tail on its own, and that behaviour is not what this change governs. Asserting an
empty edge list would have been testing a different tier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* refactor(rust): reuse the canonical callable predicate and drop dead re-exports (#2741 review)
`CALLABLE_TYPES` was a local copy of the set behind `isOverloadableCallable` in
`utils/callable-labels.ts`. Two copies of the same set drift: extending the
canonical one with a new callable kind would silently leave qualified calls of
that kind unresolved here, with nothing to catch it. Use the shared predicate.
The trailing `export { moduleOfFile, moduleOfDef }` and
`export type { ScopeResolutionIndexes }` were commented as being "for the
resolver's unit tests". No test imports them: the only importer of this module
anywhere in src or test is `rust/scope-resolver.ts`, which takes just
`resolveRustQualifiedFreeCall`. Both functions are already exported from
`module-path.ts` (where the new unit tests take them from), and
`ScopeResolutionIndexes` is canonically exported from
`model/scope-resolution-indexes.ts`. Removed rather than left as surface that
implies a contract it does not have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* test(rust): rebaseline the scope-capture fingerprint with the correct prior hash
The rebaseline note added with the original fix cited
`Prior 655aed01…`, which was two rebaselines stale — it predates both #2604 and
#2714. The true pre-PR value on the base commit is `7f1240b3…`. CI could not
catch it: the gate compares the live fingerprint against the stored one and never
reads the prose, so the audit chain these notes exist to provide was broken with
nothing to flag it.
The note now carries the correct prior value, and the fingerprint is regenerated
for the fixtures this review series added. Scaling 1.061, well inside the 1.5
budget; fixture_count 196; the other 14 languages remain byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
* test: move the schema-version pin to 23
`call-summary-schema-version.test.ts` asserts the exact value of
`INCREMENTAL_SCHEMA_VERSION` and enumerates which stamped versions the
incremental reuse gate accepts. It moves with every bump by design — that pin is
what stops an id- or edge-changing commit shipping without invalidation.
Updated for the bump to 23, with the pre-v23 case added to the reuse-gate table:
a v22 index predates Rust module-qualified call resolution, so every unchanged
Rust file would keep the same-name self-loop and keep reporting the real callee
as unreached.
Caught by CI rather than locally, because the earlier sweeps in this series
covered `test/integration/resolvers/` and `test/unit/scope-resolution/` only —
the pin lives outside both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Normalize repository-relative paths before applying ignore-package rules so `.gitnexusignore` negation can override hardcoded `parts` exclusions during Windows traversal.
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(js): index CommonJS `exports.foo = function () {}` exports (#2723)
Functions assigned to an `exports` / `module.exports` property were not
indexed at all. On a CommonJS codebase — the dominant pre-ESM Node style
(Express, Firebase Functions) — the graph held every internal helper and
missed the entire public API: `impact({target: 'areVariablesValid'})`
answered `Target not found` for the one symbol whose blast radius mattered.
The gap had two halves, and fixing either alone leaves the feature broken:
1. `tree-sitter-queries.ts` carried `@definition.function` rules for every
declaration form and every variable-binding closure form, but none for
`assignment_expression` — so no `Function` node was created.
2. The scope-resolution queries (`languages/{javascript,typescript}/query.ts`)
likewise had no `@declaration.function` for the shape. Adding only (1)
moves `impact` from "not found" to "found, zero callers", because call
resolution reaches a definition through the scope declaration, not
through the graph node.
Both layers now carry the rule, for `function` / `async function` / arrow /
async arrow / generator right-hand sides, in JavaScript and TypeScript. The
receiver is pinned to `exports` / `module.exports` with `#eq?` predicates:
the general `X.foo = function () {}` shape also covers `Foo.prototype.bar`
and `this.handler`, which are member constructs with their own ownership
questions, and a broader rule would emit ownerless top-level Functions for
them. The declaration binds the bare property name into the module scope,
which is what importers see, so `const { foo } = require('./m')` matches by
name and a namespace `m.foo()` walks the module's defs.
Verified end to end: node emission for every listed form plus TS parity, and
CALLS edges for same-file `exports.foo()`, cross-file namespace `m.foo()`,
and cross-file destructured `require()`. The generator call-resolution case
was confirmed to fail against the pre-fix build before the rule landed.
Fixes#2723
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(js): stop the CJS export rule from shadowing a declared function (#2723)
Review of the previous commit caught a regression it introduced. The CJS
`@declaration.function` rules bind the exported property name into the module
scope — which is the point, since that is what importers resolve against. But
when the file ALSO declares that name lexically:
function dup(v) { return v; }
exports.dup = function (v) { return !v; };
function callIt(v) { return dup(v); }
the module scope ends up holding two declarations named `dup`, the name is
ambiguous, and the resolver drops `callIt -> dup` entirely — an edge that
resolved fine before #2723. Confirmed by rebuilding both states: present at
ff86ccf1e, missing at f302916c. A silently missing caller is worse than the
gap #2723 set out to close; it is the impact-under-reporting class this repo
has been bitten by before.
The emitter now drops the CJS `@declaration.function` in exactly that case.
The lexical declaration already supplies the module-scope name, so importers
still resolve through it and intra-module resolution returns to its pre-#2723
behavior — verified by re-running the probe that found the regression.
Implemented at the established seam: a shared pure helper both capture
emitters import and apply at the existing `@declaration.function` filter,
mirroring `array-callback.ts` (#1876), which solves the same
"drop a spurious declaration emit-side" problem.
The module-scope name set is computed once per file and memoized per program
root in a WeakMap, rather than walked per export — a 1000-export CommonJS
module is precisely the shape #2723 was reported against, and the per-export
walk would be quadratic there. `tree.rootNode` was probed to confirm it
returns a stable object identity, so the memo actually hits; measured scaling
across 250/500/1000/2000 exports is linear.
Only the scope declaration is suppressed. The graph node comes from a
separate query and collapses onto the lexical declaration's node by name, so
no node is lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(js): fold the CJS export RHS forms into one pattern per receiver
Benchmarking the #2723 rules found the only reproducible cost is tree-sitter
query COMPILE, paid once per worker process when the lazy Query singleton is
built. Steady-state per-file emit cost and memory proved to sit below the
measurement noise floor, so there is nothing to win there.
The six CJS scope-query patterns per language (3 right-hand-side forms x 2
receiver forms) collapse to two, folding the RHS forms into an inner leaf
alternation. Measured over 5 runs per build, variance under 1ms:
query compile base before after
JavaScript 42.2ms 50.6ms 45.1ms
TypeScript 116.5ms 136.4ms 123.3ms
That recovers ~65% of the added compile cost in both grammars — about 19ms
per worker process, so ~75ms on a 4-worker analyze — and removes 45 lines of
duplicated query text.
The alternation is deliberately the INNER LEAF form. tree-sitter 0.21.1 has a
known hazard where a top-level `[...]` alternation makes sibling branches
share a single predicate bucket, silently dropping matches with no compile
error (it has bitten this repo twice: #1904, #1912). Here every predicate
sits on a capture OUTSIDE the alternation — `@_cjs.exports` / `@_cjs.module`
are on the left-hand side and bound in every branch — which is the documented
safe shape. Verified rather than assumed: a probe asserts all six receiver x
RHS combinations still bind both `@declaration.function` and
`@declaration.name` in both grammars, and that `exportz.x` / `module.other` /
`Foo.prototype.bar` / `this.handler` / aliased `exports` are still rejected —
26/26 checks, so the predicate bucket is intact.
No behavior change: the graph output on a 600-file corpus is identical
node-for-node and edge-for-edge, and the 142 JS/TS integration tests plus
1299 scope-resolution unit tests are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): index prototype and `this` member assignments as Methods (#2723)
Follow-up on the known limitations listed with the CJS export fix. Three of
them close here; the rest are recorded below with what they actually cost.
`Foo.prototype.bar = function () {}` is the dominant pre-ES6 method form — the
same population as the CJS exports this PR started with — and it was equally
invisible: no node at all, so `impact` could not reach a single prototype
method. `this.handler = function () {}` inside a constructor is its sibling,
and the pre-ES6 form of the closure-valued class field #2693 already models as
a Method.
Both now emit a `Method` with an owner edge:
function Foo() {}
Foo.prototype.bar = function (v) { return v; };
// Method:f.js:Foo.bar, HAS_METHOD Function:f.js:Foo -> Method:f.js:Foo.bar
The label comes from `provider.labelOverride` (Function -> Method) and the
owner from a sibling of `findObjectLiteralBindingInfo` — the helper that
already answers "this Method's owner is named by syntax, not by an enclosing
container" for object-literal methods. No new shared-code seam was invented.
Ownership resolves to what the file actually declares, so the edge points at a
node that exists: `function Foo` gives a `Function` owner, `class Foo` a
`Class` owner, and an owner the file does not declare
(`External.prototype.x = …`) claims NO owner edge rather than one pointing at
a fabricated node. A `this.x = fn` inside a class constructor needs none of
this — parse-worker resolves its owner from the enclosing class first.
Member ids qualify by owner (`Method:f.js:Foo.bar`). Without that, two
constructors in one file that each define `bar` collapse onto a single
`Method:f.js:bar` — the same identity collapse #2699 fixed for function-local
callables. Only the new prototype/`this` path qualifies, so object-literal
method ids are byte-identical to before.
Third fix, the orphan twin: `class Dup {}` plus `exports.Dup = function () {}`
emitted `Class:f:Dup` AND an unreachable `Function:f:Dup`. The scope
declaration for a shadowed CJS export is suppressed (previous commit), so the
node had nothing that could resolve to it; with a `function` of that name the
node collapsed by id anyway, but with a `class` the labels differ so it
lingered. `labelOverride` now returns null for that case and no node is
emitted.
## Still open, with measured cost
- Receiver-typed CALLS to a prototype method (`f.bar()`) do not resolve yet. A
class method resolves because the class owns a scope the resolver attaches
members to; a prototype assignment has no such scope, so this needs the
scope layer to associate members with the constructor's type. Verified as a
control that `new KlassC().meth()` does resolve, so this is specifically the
missing half, not a general gap.
- `exports.fwd = lib.imported` still does not forward to the original
definition — resolution/finalize-layer aliasing, reachable by no query rule.
Note `exports.localFn = localFn` (declare-then-export, by far the more
common idiom) ALREADY resolves and needed no work.
- Aliased `const e = exports; e.foo = fn` and module-top-level `this.x = fn`
remain unindexed. The latter is only an export under CommonJS semantics; in
ESM top-level `this` is undefined, so it needs a CJS gate rather than being
applied to every `.js` file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): index CJS exports assigned through an alias (#2723)
`const e = exports; e.foo = function () {}` exports `foo` exactly as
`exports.foo = fn` does, but no query can express "an identifier that happens
to alias the exports object" — the receiver is only knowable per file.
So the member-assignment rules now match ANY identifier receiver and the
emitters classify. A file's module-scope aliases (`const e = exports`,
`const m = module.exports`) are collected once per program root and memoized
beside the declared-name set, so the answer costs one top-level pass no matter
how many assignments ask.
The widening is only safe because the pruning is exact, and that is the risk
worth stating plainly: without it every `obj.handler = function () {}` in every
JS/TS file would emit a spurious top-level `Function` named `handler`. Both
layers prune:
- graph nodes, in `labelOverride`: an assignment-anchored capture that is not
a recognised shape returns null, so no node is emitted at all;
- scope declarations, in both capture emitters: a receiver that is not the
exports object declares nothing at module scope.
Verified on both sides. `obj.notAnExport`, `self.alsoNot` and
`localThing.nope` produce no node and no declaration, while an aliased export
resolves cross-file through both the namespace and destructured `require()`
forms.
## Cost
Re-benchmarked, because this widens a query the previous commit had just
optimized. Query compile over 3 runs: JavaScript 46.5ms, TypeScript 123.8ms —
+1.4ms and +0.5ms against the optimized state, since dropping the `#eq?`
predicates offsets the added patterns. Steady state on the assignment-heavy
corpus (200 files x 30 member assignments, the worst case for a widened
receiver) stays inside the +-4% noise band established earlier. Heap unchanged.
One measurement artifact worth recording so it is not mistaken for a
regression later: the real-repo TS corpus went 93,586 -> 93,748 captures across
these commits. That is corpus drift, not over-matching — the benchmark walks a
sorted file list and takes the first 400, and this work added a new source file
to that tree. The repo's own TypeScript contains zero occurrences of the
`identifier.property = function` shape, so the widened rule contributes nothing
there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): treat module-level `this.X = fn` as a CommonJS export (#2723)
In CommonJS, module-level `this` IS `module.exports`, so
this.handler = function (data) { … };
at the top of a `.js` file exports `handler` exactly as `exports.handler`
does. It previously produced an ownerless `Method` that no importer could
reach.
The CommonJS gate is the whole point of the change, not a detail. Top-level
`this` is `undefined` in ESM, so the same line exports nothing there — treating
it as an export would mis-index every `.mjs`, every `"type": "module"` package,
and every `.ts` that compiles to ESM. Detection is deliberately asymmetric: an
`import`/`export` statement settles the file as ESM immediately, a `require()`
call or an `exports`/`module` reference marks it CommonJS, and a file carrying
NEITHER signal is left alone — silence is not evidence of CommonJS.
`this` nesting follows the receiver rule the scope queries already encode
(#2701): an arrow does not bind `this`, so a top-level arrow's `this` is still
the module's and passes through the walk, while every other function form binds
its own receiver and stops it — that is an instance member, which keeps the
Method-plus-owner treatment from the previous commit.
Verified across all three cases rather than just the happy path: a CJS file
exports both the `function` and arrow forms and they resolve through a
cross-file destructured `require()`; an ESM file's identical line produces no
export; and a file with no module-system signal produces none either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): forward CJS re-exports to the original definition (#2723)
Last of the known limitations. `exports.fwd = lib.imported` assigns an
EXISTING symbol rather than a function literal, so no definition rule reaches
it and importers of `fwd` resolved to nothing:
const lib = require('./lib');
exports.forwarded = lib.imported; // via a namespace binding
const { second } = require('./lib');
exports.alsoForwarded = second; // via a named binding
Both forms are now synthesized as re-export markers in the same post-query
pass that already decomposes `require()`, reusing the decomposer's existing
vocabulary rather than adding a case to it.
The kind is the whole fix, and it was established by measurement, not by
reading. Emitted first as `named-alias` — the shape the destructured
`require()` form uses — the forwarding still did not resolve: an import
binding is PRIVATE to its module, exactly as in ESM, where `import { X }`
does not re-export X. `reexport-alias` (`export { X as Y } from './m'`) is
what a CJS forwarding assignment actually is, and with it the call resolves
through the forwarding module to the original definition.
`exports.foo = localFn`, where the right-hand side is a locally DECLARED
function, is deliberately not handled here: the module scope already binds
`localFn`, importers already resolve through it (verified before writing any
code), and synthesizing a second binding would re-create the ambiguity the
shadow guard exists to prevent.
JavaScript only, matching where CJS `require()` decomposition already lives —
`typescript/captures.ts` has no require pass at all, since a `.ts` file using
CJS forwarding is vanishingly rare next to the cost of a second
implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(js): document the CommonJS export surface now that #2723 closed it
The known-limitation note still described `exports.X` as unmodeled and listed
the re-export edge as missing. Both are stale: the note now states which forms
declare a module-scope name, which two cases are deliberate non-cases (a
locally declared value needs no second binding; a name the module also declares
lexically is suppressed rather than made ambiguous), and that member
assignments through a receiver are Methods with an owner edge.
`module.exports = fn` — an anonymous default with no name to bind — remains
the one genuine limitation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): index the CommonJS default export `module.exports = fn` (#2723)
The last documented limitation. `module.exports = function () {}` exports the
whole module as a callable, so there is no property to take a name from and
nothing declared it.
Named after the file by `deriveDefaultExportHocName` — the convention this
repo already applies to anonymous default exports — so `index.js` takes its
parent directory. A NAMED function expression keeps its own name instead,
which is more informative than the file.
Two traps, both found by probing rather than reading:
- The widened member-assignment rule already matches this shape, capturing
the LEFT property as the name — the literal `exports`. Left alone it
produced `Function:<file>:exports`, and for a named function expression a
SECOND node beside the real one. The worker now overrides the captured
name for this shape, which is why it takes precedence over `nameNode`.
- `labelOverride` was suppressing the node entirely. That is the widening's
safety net working as designed — an assignment-anchored capture that is
not a recognised shape emits nothing — and this was simply a shape it had
not been taught.
The scope declaration is synthesized in the capture emitter rather than the
query, because a tree-sitter pattern has no access to the file path the
anonymous name derives from. Without it the node would exist with nothing
resolving to it, the half-fixed state this issue already had to correct once.
`exports = fn` is deliberately NOT indexed, and there is a test pinning that:
reassigning the `exports` binding does not export anything in CommonJS, it
only breaks the alias to `module.exports`, so indexing it would invent an
export that does not exist.
## Limit worth knowing
`const m = require('./mod'); m()` resolves only when the local binding name
matches the derived name — a naming coincidence, not a mechanism. Resolving a
renamed binding (`const renamed = require('./mod'); renamed()`) needs the
finalize layer to treat a called namespace binding as the target module's
default export, which is separate work. The node itself is always emitted, so
`impact` / `context` / `rename` reach it either way — which is what #2723
asked for.
Adjacent gap found while measuring, NOT addressed here: ESM
`export default function () {}` (anonymous) is equally unindexed. Same class,
different construct, and widening to it would change behaviour for files this
issue never touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(js): record module.exports = fn and the two remaining default-export gaps
The note still listed `module.exports = fn` as unmodeled. It is indexed now;
what remains is narrower and worth stating precisely: resolving a CALL through
a RENAMED default-export binding needs finalize-layer work, and anonymous ESM
`export default function () {}` is unindexed for the same underlying reason
but is a different construct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(js): close every finding from the #2729 tri-review
A 15-lane review (Claude swarm + ce personas, Codex gpt-5.6-sol swarm + ce +
adversarial) ran the real pipeline against both this branch and its base and
diffed the graphs. On canonical CommonJS shapes the branch was DELETING call
edges that existed at base and FABRICATING edges present in no source. A
fabricated edge is worse than the gap #2723 set out to close: it hands
`impact` a caller that does not exist.
Almost all of it reduced to two root causes.
**1. The exports receiver was identified by TEXT, with no scope lookup.**
The canonical UMD wrapper takes the exports object as a PARAMETER:
(function (exports) { exports.publicApi = function () {}; })(this);
A text match called that a module export, invented a symbol, and — because the
invented name then collided with module scope — deleted the factory's real call
edges. The same blindness made `const helper = require('./helper');
exports.helper = fn` resolve an importer into a DIFFERENT module's function.
Receivers (and aliases) are now rejected where a parameter or enclosing local
shadows them.
**2. The shadow guard reached one of four export forms.**
It was wrong in four distinct ways: it never fired for an aliased receiver
(`root` was not forwarded), for module-level `this`, or for the default export
— each dropping a real edge, and the default-export case merging two functions
onto one node so the inner call resolved to itself. And it fired when it should
NOT have, deleting a genuine export whose name merely collided with a
non-callable variable:
let cache = null;
exports.cache = function (v) { cache = v; return cache; };
There is now one entry point (`cjsExportedName`) covering direct, alias, `this`
and default forms, comparing against CALLABLE declarations only.
Also fixed:
- Prototype owners bound to variables. `var Foo = function () {}` is the
dominant pre-ES6 constructor — the population this work targets — and owner
lookup handled only declarations, so two same-named members collapsed onto
one unqualified node with no owner edges at all.
- TypeScript parity: the default/re-export declaration synthesis lived only in
the JavaScript emitter, so a `.ts` file emitted the node with nothing
declaring it. Extracted to a shared module used by both.
- Module-level `this.X = fn` in ESM or a no-signal file no longer mints an
ownerless `Method`; `.cjs`/`.cts` and `.mjs`/`.mts` are now positive
module-system signals where the file path is available.
- The MCP graph-schema resource documented HAS_METHOD as Class-owned only,
while this work adds Function (constructor) owners.
- Two dead exports removed; an orphaned JSDoc reattached to the function it
describes.
- Tests: the `exports = fn` negative test passed trivially (no JS/TS query
matches a bare-identifier LHS at all, so it would pass with every guard
deleted) — it now carries a positive control in the same fixture. A
bounds-y `.some(...)` assertion was replaced per DoD.md:82. Six regressions
added, each confirmed failing against the pre-fix build.
**Schema constants bumped LAST, deliberately.** `INCREMENTAL_SCHEMA_VERSION`
20->21 and parse-cache `SCHEMA_BUMP` 28->29, with the pin and reuse-gate tests
updated. This change alters what is emitted for source whose content has not
changed, so without the bump an existing index keeps serving the pre-fix graph
for every unchanged CommonJS file — breaching DoD.md:61. Bumping it BEFORE the
correctness fixes would have been worse: it would have propagated the fabricated
and deleted edges to every index on upgrade.
One review finding was withdrawn rather than fixed: a claimed O(n^2) memo
failure did not survive verification. Clean production-shaped measurement
(fresh parse per file, no instrumentation) shows linear scaling — 0.355, 0.221,
0.216, 0.212 ms/declaration at N=500/1000/2000/4000. The earlier
"reproduction" was an artifact of replacing `globalThis.WeakMap` to count
misses, which perturbs the identity semantics under test.
Verified: 1469 tests across 89 files, including the full scope-resolution unit
suite, the JS/TS resolver suites, closure-binding labels, const-function-twin
and the pipeline golden.
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>
* test(scope-resolution): audit the consumers of file-scoped node ids (#2699 part A)
#2699 item 4 — "audit consumers that assume file-scoped ids" — after #2695/#2714
gave function-local CALLABLES position-bearing ids. Tests and findings only; no
production change. That split is deliberate: `impact` reports
`resolveDefGraphId` at CRITICAL with 23 DIRECT dependents across 7 modules
(every language MRO builder, both Spring attachers, C++ member lookup,
tryEmitEdge, emitReferencesViaLookup, buildGraphTargetIndex, emitFreeCallFallback,
emitReceiverBoundCalls, preEmitInheritanceEdges, emitDetectedInterfaceImplementations,
phpEmitUnresolvedReceiverEdges, emitRubyMixinEdges, emitRustTraitImplEdges,
emitDartHeritageEdges), so changing that key chain is its own change, not a
rider on an audit.
A2 — detect_changes: CONCERN RESOLVED, now pinned. The worry was that an id
containing `@row:col` re-keys whenever a declaration MOVES, making every edit
look like symbol churn. It cannot: `local-backend.ts` maps diff hunks to
symbols by LINE-RANGE OVERLAP (`n.startLine`/`n.endLine`) and merely REPORTS
`n.id`. Node identity never participates in the match. New structural test
asserts the WHERE clause never gains `n.id =` or `n.id IN`, keeps the one
legitimate id-shaped predicate (the `BasicBlock:` prefix exclusion, #2082 U7),
and confirms the id is returned rather than matched. Structural in the same
idiom as `detect-changes-worktree.test.ts`, and labelled as not proving runtime
behaviour.
A1 — ANSWERED, and the answer is that #2699 is NOT fully closed by items 1-3.
The fail-closed guard is gated on `isOverloadableCallable`
(Function | Method | Constructor), so a function-local VALUE never reaches it.
Measured on a fixture: a top-level `const handler` and a function-local
`const handler` still produce ONE node, `Const:v.ts:handler`. That is the
residual half of the issue's original complaint. Pinned as a KNOWN LIMIT with
its reason (widening identity to values re-keys ~14,700 build-time nodes to
change ~800 persisted ones — the decision recorded in `parse-worker.ts`), and
deliberately NOT fixed here.
A3 — id-persisting consumers, classified:
- detect_changes ................ SAFE (position-keyed; pinned by A2)
- MCP impact/context/trace ...... SAFE (resolve by name/uid at query time)
- bench fingerprints ............ SAFE (digest capture shape, not node ids)
- rust-captures golden .......... SAFE (digests captures, not ids)
- cfg pipeline-pdg snapshot ..... AT RISK by design — pins exact edge ids, so
it trips whenever attribution changes. That is the gate working; #2714
already exercised it.
- wiki / group-contract links ... NOT id-keyed on locals (locals are never
cross-file addressable, per the document-scoped contract of item 2).
Verified: tsc clean; 14/14 across the two touched files; `detect_changes`
reports 0 changed symbols (tests only).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(php): a closure binding is a call SOURCE, not only a TARGET (#2699 part B, S1)
A call made inside a closure binding was attributed to the ENCLOSING scope, so
the closure was a call TARGET but never a call SOURCE: impact(handler,
direction:"downstream") reported nothing even though the closure calls out.
Root cause, probe-measured rather than inferred. Instrumenting
pickCallerCallableDef (graph-bridge/ids.ts) to log every rejection reason shows
the closure's own scope EXISTS and its range DOES contain the call site, but its
ownedDefs is EMPTY, so the ":94" owned-callable filter drops it and attribution
falls through to the ":97" enclosing-scope fallback.
The reason is one missing query rule. javascript/query.ts pairs the binding name
with the closure via @declaration.function anchored on the INNER arrow node, so
anchor.range equals the @scope.function range and pass2AttachDeclarations
attaches the declaration to the CLOSURE's scope. No other language had that
rule — PHP, Rust, Kotlin, Ruby and Dart all captured named function
declarations only. That single omission is the entire empty-ownedDefs cause.
This ports the rule to PHP with the same anchor discipline (@declaration.function
on the inner anonymous_function / arrow_function, NOT on the
assignment_expression wrapper). PHP needs nothing else: it already declares
(anonymous_function) and (arrow_function) as @scope.function, so the rule alone
completes it.
Measured on a fixture: `$handler = function ($x) { return target($x); }` inside
outer() now emits
Function:src/a.php:outer.$handler@3:2 -> Function:src/a.php:target
where it previously emitted `outer -> target`.
The pinned test in closure-binding-labels.test.ts asserted the OLD, wrong
behaviour by design ("to catch that asymmetry changing in EITHER direction"), so
it is INVERTED here rather than deleted, per its own instruction. Its block
comment is corrected to record the measured root cause, including that Kotlin
and Ruby will need BOTH this rule AND a relaxed kind gate (their lambda_literal
/ do_block is @scope.block deliberately, #1757), and that Dart has no closure
scope at all.
Verification: closure-binding-labels 50/50; PHP resolver suites 221/221
(php, php-coverage, php-response-shapes). detect_changes {staged}: 1 changed
symbol (PHP_SCOPE_QUERY), 0 affected processes, risk LOW.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(rust): emit a node for a closure binding and make it a call SOURCE (#2699 part B, S3)
Rust was the one exception to #2687's "a closure bound to a name is a Function
node in every language": `let handler = || target(1);` produced NO graph node at
all, so the closure could be neither a call target nor a call source.
Needed BOTH query channels, which is the finding worth recording. Porting only
the scope-resolution rule (as S1 did for PHP) changed nothing measurable here,
because there was no node to attribute anything to:
- languages/rust/query.ts — closure-binding declaration, @declaration.function
on the INNER closure_expression so anchor.range aligns with the existing
(closure_expression) @scope.function. This is what gives the closure's own
scope a callable in ownedDefs, which is what stops pickCallerCallableDef
falling through to the enclosing fn.
- tree-sitter-queries.ts — @definition.function on the OUTER let_declaration.
This emits the Function NODE that Rust never had.
Note the deliberate anchor asymmetry between the two channels: the graph-node
channel anchors the WRAPPER (matching the existing
(lexical_declaration (variable_declarator ... (arrow_function))) rule), while
the scope-resolution channel anchors the INNER closure (to align with
@scope.function). Getting these backwards silently produces either no node or
an unattributable one, so both sites carry a comment saying so.
Measured on a fixture — `let handler = || target(1);` inside outer():
Function:src/a.rs:outer CALLS Function:src/a.rs:outer.handler@2:4
Function:src/a.rs:outer.handler@2:4 CALLS Function:src/a.rs:target
Previously the whole binding was absent and the call read as `outer -> target`.
The rule also covers `move` closures: the closure_expression node spans the
`move` keyword.
Verification: closure-binding-labels 50/50; rust.test.ts 192/192;
rust-coverage, rust-f70, rust-scope all pass; rust-captures-golden passes
UNCHANGED, so no golden regeneration was required. detect_changes {staged}:
2 changed symbols (RUST_SCOPE_QUERY, RUST_QUERIES), 0 affected processes,
risk LOW.
One caveat on the suite runs: this host times out `beforeAll` hooks at the
default 60s under load — rust.test.ts needed --hookTimeout=600000 to complete,
and a concurrent second vitest run starves worker startup entirely (every test
fails at ~5001ms). Both are host artifacts, not signal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(kotlin,ruby): a closure binding is a call SOURCE, via a Block-scope callable boundary (#2699 part B, S2)
Kotlin and Ruby anchor a closure on a Block-kind scope — Kotlin lambda_literal
and Ruby do_block/block are @scope.block DELIBERATELY (#1757 smart casts), so
they must not be re-kinded. pickCallerCallableDef gated its child-scope walk on
kind === 'Function', so a closure there could never become a call SOURCE.
Both halves are required; neither alone changes anything:
1. kotlin/query.ts and ruby/query.ts gain the closure-binding declaration rule,
with @declaration.function on the INNER lambda_literal / block so its range
aligns with the @scope.block range (the anchor discipline documented in
javascript/query.ts). Without this the closure scope owns no callable def.
2. pickCallerCallableDef accepts a Block-kind child as a callable boundary when
the scope IS that callable's body. Without this the kind gate still rejects.
The alignment test in (2) is the part worth scrutiny. Relaxing the kind gate to
accept ANY Block owning a callable would be a real regression: a nested
`fun foo()` declared inside a block is owned by that block, so a call made at
BLOCK level — outside foo — would be misattributed to foo. Comparing the def's
declaration position against the scope's start position discriminates them: for
a closure the declaration and the scope sit on the SAME node, so the positions
match; for a nested function the block starts at `{` while the def starts at the
declaration, so they do not. Existing Function-kind behaviour is untouched, so
every already-working language is unaffected by construction.
The comparison is base-safe: scope-extractor.ts builds a def id as
`def:<filePath>#<startLine>:<startCol>:<type>:<name>` from the same Range a
scope carries, so both sides share one coordinate base. This is called out in
the helper's docblock because `defStartLine` nearby documents its own output as
1-based, which invites a wrong "fix" (#2377 is exactly this class of hazard).
Ruby's call forms are restricted to lambda/proc by name: an unrestricted
(call block: (block)) would match ANY method call taking a block, so
`mapped = items.map { |i| ... }` would wrongly declare `mapped` a callable.
Verified against the parser: 3 matches (->, lambda, proc), map excluded.
Separate #eq? patterns rather than one #match? alternation, which is a known
hazard on this tree-sitter line.
Measured on fixtures:
Kotlin Function:src/A.kt:outer.handler@2:4 CALLS Function:src/A.kt:target
Ruby Function:src/a.rb:outer.handler@4:2 CALLS Method:src/a.rb:target#1
previously `outer -> target` and `outer#0 -> target#1`.
The pinned Kotlin test asserted the old behaviour by design and is INVERTED, not
deleted. Ruby had NO pinned case, so a new one is added rather than inverted.
The describe title no longer claimed something false ("not yet a call SOURCE"
now holds only for Dart) and was retitled.
Verification: closure-binding-labels 51/51; kotlin.test.ts, kotlin-coverage,
ruby.test.ts, ruby-scope, ruby-namespaced all pass (478 passed / 1 expected
inversion before the test was flipped). impact on pickCallerCallableDef:
CRITICAL, 191 impacted, ONE d=1 (resolveCallerGraphId) — the return contract is
unchanged, so that dependent is unaffected. detect_changes {staged}: 5 changed
symbols, 2 affected processes (both EmitReferencesViaLookup, one of them the new
ScopeIsCallableBody step), risk medium.
Dart remains the last failing language: dart/query.ts declares no
@scope.function at all, and dart/captures.ts synthesizes one only from a
declaration WITH a body node, which an expression-bodied closure lacks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(dart): give a closure binding a scope and a distinct identity (#2699 part B, S4)
Dart was the last language where a closure binding could not be a call SOURCE,
and fixing only that would have made the graph WORSE, not better. This lands
both halves together for that reason.
## The attribution half
A Dart closure had no scope at all. `dart/query.ts` declares no
@scope.function anywhere — Dart's function scopes are SYNTHESIZED in
`dart/captures.ts` from `declNode` + `findFunctionBody(declNode)`, and
`findFunctionBody` looked only at the next named SIBLING for a `function_body`.
A closure literal carries its body as a CHILD (`function_expression_body`), so
it matched nothing and no scope was produced.
`query.ts` gains the closure-binding declaration rule and `findFunctionBody`
understands the child form. Deliberately NO @scope.function is added to the
query: it would collide at identical range with the synthesized one, and
duplicate scope ids make `buildScopeTree` throw, which DROPS THE WHOLE FILE.
## The identity half, and why it is not optional
With attribution alone, two same-named closures in one file both keyed to the
bare `Function:a.dart:handler`. One node then appeared to call BOTH targets —
a CALLS edge present nowhere in the source. That is worse than the missing edge
it replaced, so S4 could not ship without this.
Root cause is not Dart-specific. `enclosingCallablePrefix` derives a SEMANTIC
relation — what encloses this callable — by SYNTACTIC ancestor walk. Dart parses
`int outer() { … }` as `function_signature` followed by `function_body` as
SIBLINGS, so the enclosing callable is never an ancestor of code inside it and
no membership set can fix that; the walk looks in the wrong direction.
This is what SCIP and real compilers avoid by construction. SCIP keeps a local
symbol opaque (`local <id>` — no name, no position, no chain) and models
containment as a SEPARATE `enclosing_symbol` field; its spec says the local/global
choice should follow ACCESSIBILITY, not the ability to name an enclosure. Dart's
own analyzer answers this from `Element.enclosingElement` in the element model,
never from AST ancestry. clang uses `name@offset` for a function-local; Kythe
uses a document-scoped VName plus a `childof` edge. Identity is positional and
opaque; enclosure is a relation.
`findSplitBodyCallableAncestor` is the narrow fix at that seam: a fallback used
ONLY when the ancestor walk finds nothing, recovering the callable from the
body's preceding sibling.
The sibling must be a BARE SIGNATURE, and that restriction is load-bearing —
"any preceding callable sibling" is WRONG and was caught regressing PHP during
this work. In `<?php function target($x) {…} $handler = function ($x) {…};` the
closure is at FILE level, so the ancestor walk correctly finds nothing, the
fallback runs, and an unrestricted version mis-qualified the file-level
`$handler` as `target.$handler`. A preceding sibling is only an ENCLOSING
callable when it cannot hold its own body.
`SPLIT_SIGNATURE_NODE_TYPES` is exactly that set and is DERIVED, not listed:
`LOCAL_SCOPE_BODY_NODE_TYPES` is already `FUNCTION_NODE_TYPES` minus the bare
signature types, so the difference between them IS the split-signature set
(`function_signature`, `method_signature` — verified at runtime). PHP's
`function_definition` carries a body and is in both, so it is excluded. No
language is named in shared code, and any future split-grammar language is
covered for free.
## Verification
Full resolver sweep — the gate that caught #2714's Rust regression — 2926
passed / 1 skipped / 0 failed across 51 files. closure-binding-labels 52/52;
dart.test.ts, dart-coverage, callable-id-lockstep, function-local-identity,
caller-identity-regression all pass (156/156 across 6 files).
impact on `enclosingCallablePrefix`: LOW, 5 impacted, 3 d=1 all inside
parse-worker. detect_changes {staged}: 5 changed symbols, 0 affected processes,
risk LOW.
Three existing Dart expectations FLIPPED rather than being deleted: Dart locals
now carry the same enclosing-callable + position identity every other language
got in #2695, so `local.dart:handler` became `local.dart:caller.handler@1:2`.
A new test pins the actual defect — two same-named closures staying DISTINCT
nodes — because the qualification assertions alone would not fail if the
fabricated edge returned.
One note for future work: an id-shape assertion here carries a call-site suffix
on indirect invocations (`…handler@3:2:5:9`) but not on direct calls. That is
the callable-value-flow pass keying its edge by invocation position, not part of
the node id.
Part B is now complete: PHP (S1), Rust (S3), Kotlin + Ruby (S2), Dart (S4).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(scope-resolution): close every deferred item on #2699 (A1 values, twin-list guard, schema bumps)
Clears the limitations this PR had been carrying rather than leaving them as
follow-ups.
## A1 — function-local VALUES now carry their own identity
This was #2699's ORIGINAL complaint and the one a callable-only gate could never
reach: a top-level `const handler` and a function-local `const handler`
collapsed onto ONE `Const:v.ts:handler`. #2695 restricted position-qualified
identity to Function|Method|Constructor because the collision that produced
wrong CALLS edges was between callables, and widening churned ids for symbols
the pruner mostly deletes. The churn is real and is accepted here deliberately.
Widening needed THREE gates aligned, not one:
- id-building — `parse-worker.ts` nestedCallablePrefix
- resolution — `ids.ts` position key
- registration — `node-lookup.ts` position-key registration
Missing the third would register no position key for values, so every lookup
misses and falls through silently. That is the #2714 failure mode: the caller
attaches to a node that does not exist and the edge is DROPPED, which looks like
"zero dangling edges" from outside. All three now route through ONE predicate,
`isPositionQualifiedLocalLabel`, rather than repeating the label set a third
time.
Only LOCALS move. The prefix comes from `enclosingCallablePrefix`, which returns
undefined when nothing encloses the declaration, so top-level and class-member
ids are untouched — verified by the full resolver sweep, where a leak onto class
members would have broken assertions in every language. `Property` is included
on purpose: a class field stays unqualified because the prefix walk boundaries
on class-likes, while an object-literal property inside a function is genuinely
local and would otherwise keep the old collision.
Measured: `Const:v.ts:handler` + `Const:v.ts:run.handler@3:2`, two distinct
nodes. The KNOWN LIMIT test is FLIPPED per its own former instruction ("this
test should be updated as part of it rather than deleted").
## Schema bumps — required by Part B, not just by A1
INCREMENTAL_SCHEMA_VERSION 20 -> 21, parse-cache SCHEMA_BUMP 27 -> 29.
SCHEMA_BUMP is 29, not 28, and that is the point of re-checking it against
origin/main at MERGE time rather than branch time. This branch cut at 27 and
bumped to 28; #2415 also bumped 27 -> 28 and merged first. The automated
main-merge onto this branch surfaced the collision — leaving it at 28 would have
shipped this whole change with NO parse-cache invalidation, so every warm cache
keeps replaying the pre-fix captures and ids. This is the third instance of that
collision recorded in parse-cache.ts (#2632/#2653 hit it at v21, and
#2653/#2654 hit INCREMENTAL_SCHEMA_VERSION the same way).
Part B already changed emitted node ids AND edges on files that did not
themselves change (Dart locals re-keyed, Rust gained a node it never emitted,
five languages gained closure-source attribution). A v20 index topped up
incrementally keeps serving the old attribution, and a warm parse cache replays
the old captures and ids verbatim. Shipping S1-S4 without these would have let
every existing index silently keep the pre-fix graph.
## Twin-list drift guard — the sixth instance in this family
`IMPLICIT_RECEIVERS` (gitnexus-shared lookup-core.ts) and `THIS_RECEIVERS`
(type-env.ts) spell the same concept in two packages, and nothing enforced
agreement — `$this` was added to the shared list in #2714 only because it was
already in the other. New structural test asserts set equality plus the ONE
deliberate asymmetry (`Me`, Visual Basic spelling, absent from the shared list
because no SupportedLanguages entry uses it) in BOTH directions, so re-adding it
there or dropping it here each fail loudly.
Structural rather than value-imported: both constants are module-private, and
exporting them purely to be testable would widen two public surfaces to satisfy
a test.
## Two false comments corrected
- `lookup-core.ts` said "see the drift guard noted in #2714", implying a guard
existed when it was only a deferred follow-up. It exists now, and the
comment points at it.
- `callable-id-lockstep.test.ts` claimed its regex "fails if any site
reconstructs the id". It matches ONE template spelling; a hand-rolled
concatenation still slips past. Now stated as a tripwire for the known
shape, not a proof.
## Skill learnings
Four entries appended to eval/workflow_bench/learnings.jsonl from this run: the
v9fs safe-writer failure, backticks silently terminating a query template
literal (hit three times), a module-level TDZ const that passes tsc and then
presents as N file failures with ZERO failing assertions, and concurrent vitest
runs starving worker startup so a whole suite fails at ~5001ms.
## Verification
Full resolver sweep 2926 passed / 1 skipped / 0 failed (51 files) — identical to
pre-A1, which is the evidence that only locals moved. All EIGHT bench gates PASS
with fingerprints UNCHANGED, so no regeneration was needed. function-local-identity,
callable-id-lockstep, receiver-twin-list-drift and closure-binding-labels 71/71.
tsc --noEmit clean.
detect_changes {staged}: 9 changed symbols, 14 affected processes, risk HIGH —
expected, and the reason the sweep above is the gate rather than a targeted list.
Every affected process routes through `resolveDefGraphId`, the key chain Part A
measured at CRITICAL with 23 direct dependents.
Deliberately NOT done: the SCIP end state (opaque `local <id>` plus an explicit
enclosure EDGE instead of containment encoded in the id string). It is a design
direction, not a limitation of this work, and it is INCOMPATIBLE with A1 — A1
widens chain-encoded identity, that removes chain encoding entirely. Bundling
both would re-key every local twice. Written up in the research notes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* test: update two assertions the #2699 changes correctly invalidated
Both failed on CI at da3d8397 and are fixed here. Neither is a behaviour
regression; both pinned values that this PR deliberately changed.
1. this-boundary.test.ts — "a Kotlin lambda still sees the receiver"
The `this.m()` edge still exists and `this` still resolves to the enclosing
receiver, which is the ONLY property this test exists to guard (its own comment
said so: "what matters here is only that the `this.m()` edge still exists at
all"). Only the SOURCE moved, from `run` to the lambda:
Method:K.kt:K.run#0 -> Method:K.kt:K.run.f@2:16
Method:K.kt:K.run.f@2:16 -> Method:K.kt:K.m#0
The comment justifying the old expectation is now false and is corrected rather
than left: it said the lambda "is not its own caller anchor" because Kotlin
scopes `lambda_literal` as a BLOCK. Kotlin still scopes it as a block (#1757 is
unchanged) — what changed in S2 is that a Block-kind scope is accepted as a
caller anchor when the scope IS the callable's body.
2. call-summary-schema-version.test.ts — INCREMENTAL_SCHEMA_VERSION pin
Moves 20 -> 21 with the bump, which is the point of pinning it: a change that
alters emitted ids or edges without bumping would otherwise ship silently.
Also adds the missing reuse-gate case. `passesReuseGate(20)` now asserts FALSE —
a v20 index predates closure bindings becoming call SOURCES, the Rust node for
`let f = || …`, the Dart closure scope + enclosing-callable identity, and
position-qualified function-local values. Topping such an index up incrementally
keeps serving the old attribution, including the Dart case where two same-named
closures collapsed onto one node and asserted a CALLS edge present nowhere in
the source.
Why CI found these and local verification did not: the verification set was
`test/integration/resolvers/` plus a hand-picked list, and both failures sat
outside it — one integration test about `this` (which a caller-attribution
change obviously touches) and one unit test pinning the exact constant that was
bumped. Grepping for the changed constant, and for tests asserting closure
attribution, would have found both. All 8 suites that reference the schema
constants were then run: 177/177, no third pin.
Verification: this-boundary + call-summary-schema-version 17/17;
the 8 schema-referencing suites 177/177. detect_changes {staged}: 0 changed
symbols, 0 affected processes, risk LOW (assertion-only edits).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(scope-resolution): resolve every finding from the multi-engine review of #2699 part B
The first cut of Part B shipped four P1 defects. A two-engine review (Claude
swarm + ce personas; Codex gpt-5.6-sol swarm + ce + adversarial) found all four,
three of them because an independent engine disagreed with the authoring one.
Each is fixed here and pinned in test/integration/closure-review-findings.test.ts.
## P1-1 — a multi-line closure binding fabricated a CALLS edge
The worst of the four, because it reintroduced the exact defect class #2699
exists to remove. The two query channels anchor on DIFFERENT nodes by design
(graph-node on the outer wrapper, scope-resolution on the inner closure) and the
bridge joins them on line only. Same line, the join matches. Split across lines:
$multi =
function ($x) { return target($x); };
the join missed, `resolveDefGraphId` failed closed, and `resolveCallerGraphId`
then CLIMBED to the parent scope — emitting `outer -> target` although `outer`
calls nothing, while the real `outer.$multi` node sat with zero outgoing edges.
`resolveCallerGraphId` now fails closed at the owning callable instead of
climbing. If we have identified the callable that owns a call site and cannot
name its graph node, crediting an ancestor is not graceful degradation — it
invents a relationship. A missing edge is the correct failure direction for a
graph whose consumers include `impact`.
Getting there took two attempts, worth recording: the first guard keyed on the
def's qualifiedName carrying the `@line:col` local marker, but that suffix is
added by parse-worker for GRAPH NODE ids and scope-resolution defs do not have
it, so the guard never fired. `pickCallerCallableDef` now reports whether the
callable came from a child scope, and the fail-closed applies to the owning
callable either way.
## P1-2 — TS constructor parameter properties were re-keyed as locals
A REGRESSION against the base, not merely an incomplete fix. Admitting
`Property` to the position-qualified set made the enclosing-callable walk reach
the constructor's `method_definition` THROUGH the parameter list — a
LOCAL_SCOPE_BODY hit that lands before any class boundary — so
`constructor(private readonly port: Port)` produced
`Property:svc.ts:Service.constructor.port@2:14` instead of `Service.port`. That
silently empties the slot `impact`, `rename` and FTS address while the class
still asserts HAS_PROPERTY against it, and it is the Angular/NestJS DI idiom.
A real instance exists in this repo at src/core/group/service.ts:304.
parse-worker.ts already computed the correct exemption (`isFunctionLocalProperty`,
lines 2245-2257) two lines above; the new ternary discarded it. Now reused, so
the owner-edge decision and the id decision cannot disagree.
## P1-3 — Dart top-level and `final` closures were never call sources
The rule matched only `initialized_variable_definition`, Dart's FUNCTION-LOCAL
shape. A top-level `var` is `initialized_identifier` and a top-level
`final`/`const` is `static_final_declaration`; the second declarator of
`var f = ..., g = ...` is also `initialized_identifier`. None got a declaration
capture, so `findFunctionBody` never synthesized their scope.
dart/captures.ts ALREADY listed all three in bindingNodeTypes for callable-flow
— the declaration rule simply did not mirror it. It does now.
## P1-4 — Ruby `do ... end` and `Proc.new` closures were uncovered
`do ... end` is the dominant MULTI-LINE Ruby style and produces `(do_block)`;
all three patterns matched `(block)` only. The scope channel already covered
both, so these closures got a Block scope owning nothing and their calls fell
through to the enclosing method. The PR's own Ruby test used the brace form, so
it passed.
Fixing it needed BOTH channels — tree-sitter-queries.ts had no graph-node rule
for the `(call)` forms either, exactly as Rust did. Verified: brace, do/end and
Proc.new are now all sources.
## Also from the review
- The split-signature fallback could fire on VALID TypeScript: a
`declare namespace` containing a bodyless overload made the next declaration's
`export_statement` a sibling of a `function_signature`, so `send` became
`internalHelper.send@2:9`. The fallback now requires the matched node to be
the signature's BODY (a body holds statements; a declaration wrapper holds
another signature), which separates the two without naming a grammar.
- `isCallableDef` re-spelled `Function | Method | Constructor` in the same file
that imports `isOverloadableCallable` and calls it three times — a NEW twin
list, in the PR whose headline is a twin-list drift guard. It now delegates.
- A partial edit had left a self-contradictory comment in parse-worker.ts
("Restricted to CALLABLE labels: the / Applies to VALUES as well as callables").
- eval/workflow_bench/learnings.jsonl carried a "skill": "gitnexus-plan" entry,
but that skill's SKILL.md:347 states feedback is chat-only and forbids
appending learnings during a planning task. Dropped; the three gitnexus-work
entries are sanctioned and stay.
- Ruby's lambda/proc patterns tested the method NAME only, so `MyMod.lambda { }`
was captured as a closure binding. `!receiver` now constrains them.
- Rust's closure work (S3) had ZERO test coverage anywhere — verified once by a
throwaway fixture and never pinned. Now covered.
## Verification
Full resolver sweep plus the identity/closure suites: 3017 passed / 1 skipped /
0 failed across 58 files (up from 2997 — the new tests). This is the gate that
mattered for P1-1: failing closed instead of climbing could have silently
deleted real edges in any language, and ~2900 resolver assertions say it did
not. All EIGHT bench fingerprint gates PASS with fingerprints UNCHANGED.
tsc --noEmit clean.
detect_changes {staged}: 14 changed symbols, 18 affected processes, risk
CRITICAL — expected, since P1-1 changes the fallthrough of `resolveCallerGraphId`,
the key chain Part A measured at CRITICAL with 23 direct dependents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): stop the review agent rejecting its own graph-backed reviews
The context-evidence gate only counted a `context` call when the call
itself passed `file_path` equal to a changed path. The review skill
teaches plain `context({name})`, so 17 of the 26 review-agent run
failures were complete, graph-backed reviews thrown away after full
model spend, with no log line saying which invariant failed.
Prove the evidence from the result instead: `status=found` plus a
`symbol.filePath` inside the repo-scoped changed-path set. Every other
check stays exactly as it was - strict JSON, orchestrator-only turns,
result ordering, duplicate tool-id rejection - and the `repo` argument
still selects the head or the merge-base path set.
Same failure inventory, smaller classes:
- rejection now logs why (in-scope, out-of-scope, sidechain, unresolved
and off-path counts plus up to three sanitized paths), and the
envelope error names the message count and first-message shape
- Glob/Grep leave the tool set: they were enabled through `--tools` but
never allow-listed, so every lane call was denied and burned turns
- both pinned `npm ci` installs retry three times; one registry
ECONNRESET killed a whole run
- the prompt matches the new contract and asks for the structured body
even when the analysis is incomplete
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(skills): mirror the review-skill tool-set change into the shipped copies
The npm package, Claude plugin, and Cursor integration ship byte-identical
copies of .claude/skills/gitnexus-review, and the drift guard compares them.
Dropping Glob/Grep from the lane frontmatter and the SKILL.md sentence only
landed in the canonical tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): stop one junk context result discarding a proven review
Tri-review of this PR found that the previous commit fixed one spurious
rejection and created another. Widening evidence candidacy from "the call
that named a changed path" to "every orchestrator context call" also
widened the *strict-parse* surface: `contextResultProvesChangedPath`
throws rather than returning false, so a single malformed payload
anywhere in the transcript now discarded a review that an earlier call
had already proven. The MCP makes that reachable without any misbehaving
model - `GITNEXUS_MCP_DEFAULT_MAX_TOKENS=12000` truncates any context
payload over ~48 KB mid-JSON and appends a marker - and it also destroyed
docs-only runs that the `no_indexable_changed_symbols` mode exempts.
Reproduced by running the workflow's own embedded script on both trees:
a proving evidence call followed by one truncated exploratory call gave
`failure_code: null` on the base and `invalid_execution_transcript` on
the head; it is `null` again here.
- payload-shape failures are caught and counted (`malformedResults`)
instead of thrown; transcript-structural invariants (envelope, tool
shapes, duplicate ids, empty tool_result) still fail closed
- diagnostics gained the reasons they were blind to: errored results,
results that arrived out of order or via a sidechain, unanswered
in-scope calls, and malformed payloads. A rejection can no longer
print an in-scope call with every reason at zero
- a deletion-only PR no longer registers head-scoped candidates that can
never be satisfied: an empty eligible set is out of scope, not a result
"outside the changed paths"
- the mandatory-body prompt clause now pairs with a required `complete`
boolean. An incomplete analysis publishes its partial body labelled
`incomplete_analysis` instead of passing as an accepted review
- `Agent(a,b,c)` is split into six separate `Agent(x)` rules: the pinned
base action parses allowedTools with `.flatMap((v) => v.split(","))`
(parse-sdk-options.ts at 3553f843), which shattered the grouped rule
into `Agent(ci-correctness-lens`, four bare names, and
`ci-critic-lens)` before the SDK saw it. Pre-existing and unproven at
runtime, but the split form is correct under either reading and lets
the header's dispatch canary actually prove something
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): require a line range for context evidence
The tri-review's adversarial lane executed `context({name: 'AGENTS.md'})`
and had the result accepted: the gate checked only that the resolved
filePath was in the changed set, so a bare File node passed for a review
of that file's contents. The trusted prescan already defines an indexable
symbol as one with startLine and endLine, so require the same here.
Pre-existing rather than introduced by this branch, but it is the same
"what counts as proof" surface the rest of this PR tightens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): close the remaining tri-review findings
Addresses every finding the tri-review left open after 0432214d and
1d9f2d75, across both engines.
Reliability and maintainability (Codex ce, ce-reliability, ce-maintainability):
- both pinned `npm ci` installs now call one shared
`.github/scripts/npm-ci-retry.sh` instead of two near-identical 12-line
blocks that differed only in a label
- each attempt runs under `timeout` (default 600s, overridable), so a slow
registry can no longer crowd the model review out of the job's budget
- the helper distinguishes a timeout kill (124) from an npm rejection in
its log
Test coverage (ce-testing, Codex swarm P3, ce-security, swarm test-ci):
- the retry helper is now exercised behaviourally with a stub npm: first-try
success runs once, two failures recover on the third, three failures exit 1
- a non-string `symbol.filePath` is a clean reject, not a type error
- an adversarial resolved path (ESC, newline, `::set-output`, RTL override)
is proven sanitized before it reaches the job log
- the envelope error's shape string is asserted
- an in-scope call whose result never arrives is counted, not silent
- install flags that keep the runtime inert (`--ignore-scripts`, `--prefix`,
the lock-bound registry) are asserted against the helper they moved into
Correctness and clarity (risk-architect, ce-standards):
- the prompt now tells the model to prefer the uid form or pass file_path
when a bare name could resolve into an unchanged file, which was the
narrower off-path failure mode the gate rewrite left behind
- `contextResultProvesChangedPath` -> `contextResultProvesEligiblePath`,
matching the set-membership contract its sibling was renamed for
- the transcript fixture's default no longer carries a `file_path` the gate
ignores, which implied the opposite of the contract
- SKILL.md says "file reads" rather than naming a CLI-specific tool, per
the CLI-neutrality rule in AGENTS.md; mirrored to all three shipped copies
- the interactive-swarm README notes the CI lanes are narrower
Publisher (ce-reliability residual, pre-existing):
- the publish job no longer gates the whole job on authorization, so a
request rejected at normalization no longer strands the "review in
progress" marker on the PR forever. Publication stays authorization-gated
at the step; only the marker cleanup is unconditional.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): ship the install helper executable
The extracted helper was committed 100644, so the workflow's direct
invocation would have failed on the runner with permission denied - a
break introduced by the extraction itself, invisible to every existing
assertion. Set the mode and pin it with a test.
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>
* fix(scope-resolution): a named receiver's member never resolves lexically (#2699)
`lookupCore` Step 1 walked the lexical scope chain for every lookup, including
explicit-receiver property reads. So `options.baseUrl` could bind to an
unrelated function-local `const baseUrl` in the same file, and
`config.extractVisibility(node)` to the enclosing class's own method.
This is the residual half of the defect JS/TS block scopes narrowed in #2695.
Blocks moved nested-block locals off the chain of a reference outside the
block, which removed 114 false edges; a local declared directly in the function
body stayed on it, and no amount of extra scopes reaches that case. Fixed at
the cause instead: `recv.name` names a member of whatever `recv` denotes, so a
binding of the bare tail name in an enclosing scope is never the right answer.
Steps 2 and 3 (receiver type / owner members) are the legitimate routes.
`this` and `self` are EXEMPT, and that exemption was measured, not assumed.
Skipping Step 1 for every explicit receiver removed 711 edges on a 762-file
corpus — but 2 of those were genuine: `self.srcIx` and `self.streamedAt(...)`
after `const self = this`, reaching their own class's members through the
class-body scope. For a self-receiver the members and the lexical chain
legitimately overlap; for a named receiver they never do. Exempting the self
names keeps both true edges and still removes 709 false ones, adding none.
The removals were classified by reading source at the site, not by pattern-
matching ids — an "is the target a member of the source's owner?" heuristic
labelled 43 of them plausible and every one I then read was false:
language = config.language; -> the class's own `language`
dirMap.get(...) / exactMap.get(...) -> a sibling object-literal `get`
return config.extractVisibility(n); -> the class's own method (self-edge)
writer.close(); -> GraphEmitSink.close
Residual, deliberately kept: a `this.x` read can still bind lexically to a
same-named local. That is the price of the two true self-alias edges above.
`INCREMENTAL_SCHEMA_VERSION` 19 -> 20: a v19 index holds these false
CALLS/ACCESSES on every unchanged file and would keep serving them through the
reuse gate.
Test confirmed discriminating: it fails with the guard reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(typescript,javascript): a generator expression binding is a Function node (#2693)
`const g = function* () {}` matched none of the closure-binding definition
rules — they covered `arrow_function` and `function_expression` only — so the
binding emitted a `Const` node. `buildGraphTargetIndex` admits callable nodes
only, so `g()` resolved to nothing.
Same defect shape as the `var` case #2693 already fixed: a different grammar
node for the same construct, and the resulting graph node was not callable.
Adds the four variable-binding shapes in both languages: `const`/`let` and
`var`, each plain and exported. Purely additive — no existing pattern is
reordered or rewritten, because the #2687 pre-scan dedup is order-dependent
and collapsing the value/callable pair depends on which match wins.
Deliberately NOT covered, and the query comment says so: a generator in an
object-literal pair or a HOC wrapper still falls through anonymous. Those are
rarer, and each additional pattern is another chance to disturb the dedup.
`SCHEMA_BUMP` 26 -> 27: definition captures are parse-time, so a warm parse
cache would replay the old ones verbatim — `--force` does not clear it.
Two tests confirmed discriminating (they fail with the patterns reverted), plus
a guard that the already-working generator DECLARATION form is unaffected,
since it shares the emit path these were inserted beside.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(ingestion): keep caller attribution in lockstep with definition ids (#2699)
The definition phase appends `localIdentity` to a nested callable's own name
segment (`run.save@3:2`); `findEnclosingFunctionId` did not, so the two phases
derived different ids for the same callable. The failure mode is silent — the
caller id names a node that does not exist, so the edge is dropped rather than
reported — which is why the parse-worker docblock calls this pair a lockstep
guarantee and asks that both phases derive the prefix from one place.
The condition is now byte-identical to the definition phase's
(`nestedPrefix !== undefined`), so the two cannot diverge again.
Scope of the claim, stated plainly: no reproducing case was found, and this
changes nothing measurable on a 762-file TypeScript corpus. TS/JS resolve
callers through `resolveCallerGraphId` in the graph bridge, not this path;
`findEnclosingFunctionId` serves the `callExtractor` languages, and the
corpus does not exercise a nested callable there. The review that raised it
(P3) observed zero dangling edges, and "zero dangling" is also what silently
dropped edges look like — so this closes a documented contract rather than a
demonstrated bug, and carries no test of its own.
Rides the `SCHEMA_BUMP` 26 -> 27 in the preceding commit: caller attribution
runs in the worker, so a warm parse cache would replay the old ids.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* docs(test): correct the block-scope header that this PR made false (#2699)
Review finding (MEDIUM). The file header still described `lookupCore` Step 1 as
walking the lexical chain for EVERY lookup, and called the function-body-local
case "unchanged and still mis-resolves ... pre-existing and tracked
separately". Commit 59b892ca in this same PR falsified both, and the describe
block added ~80 lines lower in this same file asserts the opposite — a reader
scoping future work from the header would have concluded the case was still
open.
Rewritten to state what the code does: Step 1 is skipped for a NAMED explicit
receiver, the function-body case is fixed here, and the surviving residual is
that a `this`/`self` read can still bind lexically to a same-named local —
with the reason those two names are exempt (they keep the genuine
`const self = this; self.member` reads that Step 1 resolves correctly).
Also corrects a PRE-EXISTING staleness inherited from #2695 in the same
paragraph block: "the genuine bare read of that same local must still emit its
edge" describes a test that no longer exists, because TypeScript emits no
`@reference.read` for bare identifiers at all. Fixed here rather than left
adjacent to a freshly corrected sentence.
Comments only — `detect_changes` reports 0 changed symbols across 1 file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* refactor(ingestion): give the nested-callable id rule one definition (#2699)
Review finding (LOW): the lockstep change in this PR shipped without a test.
The plan called for a unit test asserting the two id-derivation phases agree.
Two things changed that plan during execution, both recorded here.
FIRST — there are THREE phases, not two. Re-verifying the plan's assumption
(`grep -n localIdentity`) found a third call site: the worker-path node-id
derivation in `processFileGroup` (parse-worker.ts:2316), whose own comment
already acknowledged the coupling. `impact` on `localIdentity` corroborates:
three direct dependents, all in the Workers module. So the invariant three
phases must agree on is now ONE function, `nestedCallableQualifiedName`, and
divergence requires deleting a call rather than editing a duplicated
expression.
SECOND — the planned `_forTest` alias seam does not work for this module.
`parse-worker.ts` posts a `ready` message to `parentPort` at module scope, so
value-importing it from a unit test throws before any test runs; the existing
unit tests that reference it use `import type` only, which erases. The rules
therefore move to a new pure module, `workers/callable-id.ts`. That is what
makes them testable at all, rather than merely commented.
Pure refactor — no id changes. Verified by the suites that assert exact node
ids (`Function:svc.ts:run.save@7:2`, `Function:c.php:run.$save@3:2`): 74/74
green, and `detect_changes` reports only the three expected symbols and the
two `processFileGroup` flows `impact` predicted.
The test pins both halves: the rule's contract, and a structural assertion
that no site has re-inlined `${prefix}.${localIdentity(...)}` — the unit
assertions alone would still pass if a fourth phase spelled the rule out by
hand, which is exactly how the divergence arose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(scope-resolution): give PHP's `$this` the same self-receiver exemption (#2699)
Review finding (LOW). The Step-1 skip added in this PR exempts `this`/`self`,
but the receiver name arrives as the reference node's RAW SOURCE TEXT —
`extractExplicitReceiver` returns `cap.text` verbatim — so PHP's `$this->x`
presents as the string "$this" and matched neither entry. PHP was the one
supported language whose self-receiver got no exemption at all.
Measured, and the measurement is why this is framed as consistency rather
than a bug fix:
- Corpus delta ZERO. 762-file TypeScript corpus, CALLS+ACCESSES set diff:
13179 -> 13179, added 0, removed 0. So no INCREMENTAL_SCHEMA_VERSION bump
(stays 20), per the plan's decision rule.
- No PHP shape found that DISCRIMINATES. Both the simple `$this->prop` /
`$this->helper()` shapes and a closure reading `$this->…` inside a method
that also declares a same-named local produce byte-identical edge sets
with `$this` present and absent — Step 2 resolves the receiver's type
first. The added test is therefore labelled a COMPANION INVARIANT, exactly
as the `this.baseUrl` case beside it is, and does not claim to prove the
fix.
It is still worth making: the exemption is protective, and the 709-removed /
0-true-lost measurement that justified the narrow guard was TypeScript-only,
so PHP's safety was never established by evidence. This closes that by
construction.
Two corrections to what the plan assumed, both found by checking:
- The plan (and my first draft of this comment) claimed the codebase had no
precedent for handling a sigil'd receiver name. FALSE: `THIS_RECEIVERS` in
`core/ingestion/type-env.ts:244` has always listed `$this`, and it is the
ingestion-side twin of this very list. The precedent does not merely
exist, it validates the approach chosen here — list the spelling as data,
do not strip sigils.
- That twin also lists `Me`. Deliberately NOT mirrored: no entry in
`SupportedLanguages` is Visual Basic, so it could only ever exempt a
variable that happens to be called `Me`.
The two lists are otherwise the same set with nothing enforcing it — a fifth
instance of the twin-list drift class this PR keeps meeting. A drift guard is
the right fix and is out of scope here; noted for follow-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(rust): resolve `Self` in scope-resolution type bindings (#2699)
CI regression, caught by `tests / ubuntu / coverage` on 13d5e738 and traced to
the named-receiver Step-1 skip earlier in this PR (59b892ca), not to the three
commits above it — verified by reverting those three and reproducing the
failure unchanged.
`test/integration/resolvers/rust.test.ts > resolves fresh.validate() inside
impl User via Self {} inference` failed: 192/192 on main, 191/192 on this
branch. The fixture calls `fresh.validate()` where `let fresh = Self { .. }`
inside `impl User` — a genuine call to `User::validate`, and a TRUE edge that
the skip deleted.
Root cause is a twin-channel disagreement, not the skip:
- `type-extractors/rust.ts:142` substitutes `Self` -> the enclosing impl
type into the TYPE-ENV channel via `findEnclosingImplType`.
- `languages/rust/interpret.ts` recorded `@type-binding.type` verbatim, so
the SCOPE-RESOLUTION channel bound `fresh: Self` — a type that does not
exist, leaving the receiver's type unknown and Step 2 unable to resolve.
`main` passed only because Step 1 still walked the lexical chain for named
receivers: the impl scope binds `validate` by name, so the call resolved BY
ACCIDENT. Stopping that walk turned a latent gap into a lost edge. The fix
closes the gap rather than restoring the accident — `Self` is now substituted
at capture-emit time in `languages/rust/captures.ts`, where the impl node is
reachable, reusing the `findEnclosingImpl` + `syntheticCapture` idiom already
in that file.
CORRECTION to this PR's central claim. "709 removed / 0 added / 0 true edges
lost" was measured on a 762-file TYPESCRIPT corpus and stated without that
qualifier. Rust lost one true edge. The measurement stands for TypeScript; it
did not generalise, and the PR body is being updated to say so.
Scope of the breakage, measured rather than assumed: 1 failure in 2927 tests
across all 51 resolver files. Every other language — Go, Java, C#, Kotlin,
Swift, Python, PHP, Ruby, Dart, C++ — passes, which is why this is a targeted
fix and not a revert of the skip.
Re-baselined `bench/scope-capture` for RUST ONLY (655aed01 -> 7f1240b3); the
other 14 language fingerprints are byte-identical. The drift is the intended
output change and the reason is recorded in the baseline entry, per that
file's own "explain, never re-baseline to make CI green" rule.
Verified: rust resolvers 192/192; all 51 resolver files 2926 passed / 1
skipped / 0 failed; the 8 targeted suites 96/96; all 8 CI bench gates PASS;
`tsc --noEmit` clean; `detect_changes` reports one touched symbol
(`emitRustScopeCaptures`) and no affected flows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* test(golden): refresh the Rust capture golden and the C# PDG snapshot (#2699)
The two committed artifacts CI flagged after 5f55fe46. They drifted for
OPPOSITE reasons, so each was inspected before regenerating rather than
refreshed on sight.
RUST GOLDEN — drifted because 5f55fe46 CORRECTS the output. A `Self` type
binding now records the enclosing impl's type instead of the literal `Self`,
in both the `let x = Self { .. }` and `fn new() -> Self` forms. Blast radius
verified exact: 5 fixtures drifted, all 5 contain `Self`, and every
`Self`-bearing rust fixture is among them (rust-self-struct-literal,
rust-constructor-type-inference, rust-default-constructor,
rust-method-enrichment, rust-scoped-multi-file).
C# PDG SNAPSHOT — drifted because the named-receiver Step-1 skip (59b892ca)
REMOVED A FALSE EDGE. CALLS 7 -> 6, and the edge that went is:
Demo.Resolve.Parse@142:12#1 -> Demo.Resolve.Parse@142:12#1
a self-call, from `int Parse(string v) => int.Parse(v);`. `int.Parse(v)` is
System.Int32.Parse; the lexical chain was binding it to the enclosing local
function that happens to also be called `Parse`. Same defect class as
`writer.close()` -> GraphEmitSink.close. The snapshot's own comment says it
exists so "a future refactor that silently rewires the C-family graph trips
this gate" — it tripped correctly, and the rewiring is an improvement.
Both failures were PRE-EXISTING on this PR from 59b892ca, not from the three
commits above it — verified by reverting those and reproducing unchanged. They
went unseen because this PR's CI was never watched after its first push.
Verified after regeneration, WITHOUT update flags so they must genuinely pass:
rust-captures-golden 9/9; pipeline-pdg 31/31. The snapshot diff is 3 lines,
all inside the C# entry — no other language's snapshot moved. `detect_changes`
reports 0 changed symbols (test artifacts only).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scope-resolution): resolve calls through a closure-valued binding (#2693)
`val f = { }; f()` emitted no CALLS edge in Kotlin or Swift, so `impact` on
such a symbol under-reported to zero — the same false all-clear as #2687.
The cause was not, as first suspected, that these languages fail to feed
`callable-value-flow`. They do: `synthesizeCallableFlowCaptures` is called
from 15 language capture modules, and Kotlin already resolves reassignment
through the pass (`var f = ::a; if (c) f = ::b; f(1)` reaches both targets).
Their captures are already exactly right — the seed names the binding as its
own callable, per the anonymous-callable convention in
callable-flow-captures.ts.
They died one layer later, at the `buildGraphTargetIndex` gate:
if (!isCallable(def) && providerTarget?.(def) !== true) continue;
`isCallable` is Function/Method/Constructor, but the scope-resolution layer
declares a closure binding with its VALUE label (Kotlin/Swift `Property`),
and `isCallableValueTarget` is implemented by exactly one provider — COBOL.
So the binding never entered `graphTargets`; `lexicalCallableLookup` then
returned `shadowed: true` with no targets, which also suppressed the
workspace-wide fallback, and the seed resolved to nothing.
Only the graph knows a value binding holds a callable — since #2687 it emits
a single `Function` node for one. So value bindings now resolve their graph
id first and are admitted on the label of the node they actually reach.
This is self-limiting: a genuine constant keeps its own Const/Property node,
so `resolveDefGraphId`'s qualified key hits before the label-agnostic
`simpleKey` fallback can reach a same-named callable. Only a binding whose
own value node was replaced by a callable one gets through.
No scope kind changes — Kotlin's `lambda_literal` stays `@scope.block`, so
#1757 smart-cast semantics are untouched by construction. The fix is
language-neutral: it discriminates on the graph node label, never on a
language name.
Dart is fixed separately; its root cause is independent.
* fix(dart): resolve calls through a closure-valued binding (#2693)
Dart needed more than the shared gate fix: neither of its closure-binding
forms could resolve, for two different reasons, and the plan's one-line
diagnosis turned out to be incomplete.
TOP-LEVEL `var f = (x) => x;`
A graph Function node already existed (#2687), but no `@declaration.*`
matched the binding, so scope resolution had no SymbolDefinition to attach
a flow seed to. Adding the declaration exposed a second problem: Dart's
`initialized_identifier` is FIELDLESS, so the shared field-based assignment
fallback (`left`/`name`/`value`/…) decomposed nothing and the binding still
emitted no flow captures at all. Kotlin's fieldless `assignment` node hit
exactly this and took the same remedy — a provider `extractAssignment`.
FUNCTION-LOCAL `void m() { var f = (x) => x; }`
Locals parse as `initialized_variable_definition`, which the top-level
graph-node rules are deliberately anchored under (program) to avoid, so a
local closure had no graph node at all — nothing for the widened
`buildGraphTargetIndex` gate to admit.
Both new rules are restricted to a `function_expression` value. Declaring
every Dart variable would mint defs and nodes repo-wide for no resolution
benefit; ordinary locals stay unindexed exactly as before. The top-level
declaration reuses the (program) anchor the graph-node query already relies
on, so class-body fields — which share `initialized_identifier_list` and are
already `@declaration.property` — are never matched twice.
Also drops the now-false note in tree-sitter-queries.ts claiming `f()` does
not resolve for Dart. That node is now the evidence that makes it resolve.
* docs(scope-resolution): document the callable-flow capture contract (#2693)
The module is 1200+ lines behind a nine-line docblock, and the only worked
example was C. Both root causes fixed in this series were "the contract was
discoverable only by reading the emitter":
- the anonymous-callable convention (a seed whose source is a closure takes
its DESTINATION's name) is what makes closure bindings resolvable at all,
and is the reason the widened target gate is correct;
- a fieldless binding node silently decomposes to nothing under the shared
assignment fallback, which cost Kotlin one debugging cycle in #2522 and
Dart another here;
- captures alone are never enough — the bound name also needs a
`@declaration.*` or there is no cell to key the seed on.
Records the cell/site model, both traps, and points at the fullest and
smallest worked examples.
Bumps INCREMENTAL_SCHEMA_VERSION 15 → 16 and the parse-cache SCHEMA_BUMP
22 → 23: this series emits NEW CALLS edges and new Dart Function nodes, and
the incremental write set only covers changed files, so an existing index
would keep reporting a zero blast radius for exactly the symbols the fix is
about.
* perf(scope-resolution): pre-filter value bindings in the callable target index (#2693)
Widening the `buildGraphTargetIndex` gate to consider VALUE bindings put the
hot loop on a much larger def population — value bindings outnumber callables
in real source — and the naive version paid full price per binding. Measured
on a synthetic 800-file corpus (8 value bindings per file, 1 of them a closure
binding), the widening cost 2.50-2.82x the pre-#2693 callable-only build.
Two wastes, both provable rather than guessed:
1. `definitionAnchorKey` ran for every def, including value bindings. The
anchor index is keyed by callable LABEL and the key is built from
`def.type`, so a value def can never hit it — and the key costs a regex
per def.
2. Every value binding paid the whole `resolveDefGraphId` key chain only to be
rejected. It need not: every qualified key that function tries embeds
`def.type`, so for a VALUE def those can only ever reach a value-labelled
node. Its one route to a callable is the label-agnostic
`simpleKey(filePath, simpleName)` fallback, which by construction requires
a callable node with the SAME file and simple name. So a value binding with
no such node cannot resolve to a callable, and one Set lookup decides it.
That set is derived in the graph walk the anchor index already performs, so it
costs no extra pass.
large_ms 7.79-8.37 -> 4.90-5.02 (1.61x faster)
widening_overhead 2.50-2.82 -> 1.45-1.50
The resolved target-set fingerprint is byte-identical across both, which is
the point: this is a cost change, not a behaviour change.
Adds bench/callable-value-flow/ (fingerprint + scaling + widening-overhead
gates) and wires it into ci-tests.yml beside the other build-free benches. The
overhead budget of 1.9 sits between the measured with-filter and without-filter
bands, so it cannot be met if the pre-filter is removed. Timings use the MIN of
15 warmed reps, not the median: the same build reported 1.65 idle and 2.03
under load, and a median-based gate would have to be loosened past the point of
detecting the regression it exists to catch.
`buildGraphTargetIndex` is exported for the bench; it is pure and not part of
the pass's public contract.
* test(scope-resolution): assert the declaration route does not double-emit (#2693)
Go, Python, C++ and TS/JS already resolved a closure-binding call through
their `@declaration.function` capture. The widened `buildGraphTargetIndex`
gate gives the same call a SECOND possible route, so each must still produce
exactly one edge.
`tryEmitEdge` dedups by key, but a collapsed key and a site-anchored key are
DIFFERENT keys — a real double-emit would show up as two ids for one call
site, not be silently collapsed. Asserting on edge ids rather than target ids
is what makes that visible.
* fix(scope-resolution): join value bindings to their callable node by POSITION (#2693)
Review found the first cut of this series minted FALSE CALLS edges. Admitting a
value binding whose *resolved* graph node is callable let `resolveDefGraphId`
fall through to its label-agnostic, first-write-wins
`simpleKey(filePath, simpleName)` and bind the name to ANY same-named callable
in the file.
The safety argument in the previous commit — "a genuine constant keeps its own
Const/Property node, so the qualified key hits first" — silently assumed
`def.type === node.label`. It does not hold:
- TypeScript declares `const` as `Variable` but emits a `Const` NODE, so the
qualified key misses even though the value node exists;
- Rust `let` bindings get no graph node at all, so the fallback is the only
route.
Reproduced, all previously emitting a fabricated caller:
const save = (x: number) => x * 2; // next to an unrelated Svc.save
-> Method:svc.ts:Svc.save#1 // Svc never instantiated
const handler = other; // shadowing a top-level handler
-> Function:app.ts:handler // unreachable from here
let handler = cb; // Rust
-> Function:main.rs:handler
Worse in Dart, where the same collision INVERTED the feature: the only edge went
to the class method and the closure's own node got none. The result was also
declaration-order dependent — two files differing only in declaration order got
different CALLS sets — and it propagated through argument-to-formal binding into
functions whose source never mentions the name.
A closure binding IS its callable node: same file, same line, same name. An
aliasing local is not. So the join is positional now — a file/line/name index
built in the graph walk `byAnchor` already performs — and value bindings never
run the key chain at all. That is both correct and cheaper:
large_ms 4.90-5.02 -> 4.37-4.63
widening_overhead 1.45-1.50 -> 1.43-1.58 (name-match design: 2.50-2.82)
with a byte-identical target-set fingerprint on the bench corpus.
Also from review:
- `Static` dropped from VALUE_BINDING_DEF_TYPES: `normalizeNodeLabel` has no
`static` case, so no def can carry that type — it was an entry no fixture
could ever exercise. The remaining set now documents why it deliberately
does NOT reuse `isOwnableValueLabel`, which is contracted to a different
consumer.
- Dart `final`/`const` top-level closures (static_final_declaration_list) and
every declarator after the first in a multi-name local now resolve; both
parse into shapes the earlier rules never reached.
- The bench source carried a literal NUL byte, so git recorded it as BINARY
and the only artifact pinning the target set was unreviewable in the PR
diff. It is written as an escape now. Its corpus also modelled `startLine`
as 1-based where graph nodes are 0-based, which would have stopped it
exercising the value-binding path at all.
- `call-summary-schema-version.test.ts` asserted `passesReuseGate(15)` is
true; the 15 to 16 bump made that false and the test RED. It now pins 16 as
current and 15 as rejected, matching the pattern every prior bump followed.
- The v23 parse-cache comment is at the top of the list, not mid-list.
Tests: the five collision cases above are new regression tests, each confirmed
failing against the previous commit. Also added Kotlin class-body closures (the
only case exercising the Method arm), Dart top-level `final`, Dart multi-name
locals, and a warm-parse-cache replay for Kotlin and Dart — the #2693 captures
are replayed verbatim, so a serialization change would surface only on a SECOND
analyze and every other test here runs cold. The previous negative tests were
vacuous: they paired names that did not collide (`maxSize` vs `size`), so the
pre-filter rejected them before the guard they were named after could run.
* docs(storage): fix the schema-version changelog blocks (#2693)
Two problems, one mine and one not.
MINE: the `INCREMENTAL_SCHEMA_VERSION` block is ASCENDING (v2 … v15), and I
inserted v16 above v15 rather than at the end — I had just moved the parse-cache
entry to the top of ITS block, which is descending, and applied the same habit
to a list ordered the other way. Moved to the end; both blocks are now
internally consistent.
NOT MINE: the parse-cache block carries TWO v21 entries, with v20 wedged between
them. Tracing it: #2632 (Spring DI facts) bumped 20 -> 21 and merged first;
#2653 (Java JLS local-class identities) had branched at 20, also bumped to 21,
and merged second — so it shipped with NO invalidation of its own. An index
already stamped 21 by the first change was treated as current by the second and
kept serving stale local-class identities from the warm cache.
Numbers left alone: both genuinely shipped as 21, and renumbering them now would
misstate what users' indexes actually contain. Instead the entry says so
explicitly, and points at the process fix — re-check the constant against
origin/main immediately before merging, not just when the branch is cut. The
identical collision hit INCREMENTAL_SCHEMA_VERSION in #2653/#2654, so this is a
recurring failure mode of concurrent PRs, not a one-off typo.
Comment-only; no constant changes value.
* feat(scope-resolution): resolve closure bindings in Ruby, Java, C#, PHP and JS/TS var (#2693)
Ruby, Java, C# and PHP already emitted correct callable-flow seeds and invokes.
What they lacked was the #2687 piece — a CALLABLE graph node at the binding,
which is what buildGraphTargetIndex joins to by position. PHP additionally had
no scope declaration for the bound name, so the flow pass had nothing to attach
its seed to.
ruby handler = ->(x) { x } handler.call(1) -> Function:a.rb:handler
java Function<..> handler = x->x handler.apply(1) -> Function:A.java:A.handler
csharp Func<int,int> handler = ... handler(1) -> Function:A.cs:A.handler
php $handler = fn($x) => $x $handler(1) -> Function:a.php:handler
Ruby and Java invoke through the callable-object protocol; C# and PHP call the
binding directly. Locals work in all four, and a binding whose name collides
with a same-named method resolves to the CLOSURE, not the method.
Two things the sweep caught:
JAVA TWIN. Anchoring the rule on the inner variable_declarator produced BOTH a
Function and a Property node — the exact double-indexing #2687 removed. The
parse-worker dedup keys on (definition node, name), and Java's value rule
anchors on field_declaration, so the keys never matched. Re-anchored on
field_declaration / local_variable_declaration.
JS/TS `var`. `var f = (x) => x` kept a Variable label while const/let got
Function, because `var` is a different grammar node (variable_declaration vs
lexical_declaration) that no closure rule covered. A call through the binding
still resolved via the declaration route, so the CALLS edge pointed at a
NON-callable node. Now consistent across const/let/var.
That last one flipped an existing assertion in const-function-twin.test.ts,
which expected `Variable` for a var-bound function-expression. Its comment
explained why — "var has no matching @definition.function pattern, so nothing
claims the name" — i.e. it documented the gap rather than defending it. The
property it was really protecting (an UNCLAIMED value node survives) now has
its own case with a non-function initializer, and the var-closure case asserts
the collapse to one node, which is also the twin guard for the new rule.
Known limits, both pre-existing and both failing safe:
- A PHP local closure whose name collides with a top-level function gets no
edge: both want id Function:<file>:<name>, so the closure never gets its own
node. This is the file-scoped node-identity convention — TypeScript, Python
and Dart collapse identically at base.
- TS/JS class-field arrows stay Property (Kotlin's equivalent emits Method).
They already resolve; changing the label risks the HAS_PROPERTY ownership
regression #2687 hit once.
The invalidation constants already bumped in this PR (INCREMENTAL_SCHEMA_VERSION
16, SCHEMA_BUMP 23) cover these additional languages; their notes now say so.
Tests: one case per newly-resolving language plus the PHP anonymous-function
form and the JS var form, in closure-binding-labels.test.ts. The file now spins
a worker pool per test across a dozen languages, so its timeout is raised
file-wide — a case that takes ~7s alone was exceeding the 30s default under
that contention.
* fix(ingestion): class-field closures are callable members in TS/JS (#2693)
A CALLS edge must target a callable node. `class A { handler = (x) => x }` emitted
a Property, so calling it produced `CALLS -> Property:A.ts:A.handler` — an edge
pointing at something the graph says is not callable. Same defect class as the
JS/TS `var` binding fixed in the previous commit, and the last place a closure
binding still carried a value label.
Kotlin already models its class-body closure as Method + HAS_METHOD; TS/JS now
match, so all three agree:
class-field closure -> Method + HAS_METHOD (CALLS target is callable)
plain class field -> Property + HAS_PROPERTY (unchanged, no CALLS)
Anchored on public_field_definition / field_definition — the same nodes the
property rules use — so the parse-worker dedup collapses the pair rather than
leaving a Method/Property twin, the failure the Java rule hit in the previous
commit.
ON MATCHING THE COMPILERS. This deliberately diverges from tsc and SCIP. The
TypeScript compiler classes `handler = () => {}` as a PropertyDeclaration
("a property declaration independently from what it's assigned to"), and SCIP
gives it a `.` term descriptor, the same suffix as any field — both call it a
property, and Kotlin's compiler likewise treats `val f = { }` as a property with
a function type. The divergence is intentional: GitNexus's Function/Method label
does not mean "tsc SymbolFlags", it means "this node can be the target of a
CALLS edge", which is the convention #2687 set for closure bindings in every
language. Modelling it the compiler's way would mean either dropping call
resolution for these members or emitting a separate node for the lambda and
flowing the property to it — the two-node shape #2687 removed. Recorded here so
the next reader does not "fix" it back.
Tests: TS and JS class-field arrows resolve to their Method node, plus a guard
that a NON-closure class field stays a Property — the closure rule must key on
the initializer, not on the field syntax.
* fix(php): keep the $ sigil on closure-binding nodes so locals stop colliding (#2693)
A PHP local closure whose name matched a file-level function got NO edge at all:
function save($x) { return $x; }
function run() {
$save = fn($x) => $x * 2;
return $save(1); // no CALLS edge
}
Both minted the id Function:<file>:save, so the closure's node was swallowed by
the function's and the positional join found nothing at the binding's line.
The fix is PHP's own semantics rather than a change to node identity across the
graph. PHP holds variables and functions in SEPARATE namespaces — $save and
save() cannot collide in the language — and the sigil is what separates them.
Dropping it was the bug. The node rule now captures the whole variable_name, so
the closure is Function:<file>:$save and the function stays Function:<file>:save.
languages/php/query.ts already keeps the sigil on property declarations for the
same reason, so this makes the two consistent.
The positional join normalises a leading $/@ on both sides, matching what the
scope layer and the callable-flow synthesizer already do, so the binding still
matches its own declaration while its NODE stays distinct.
local closure + same-named function -> Function:c.php:$save (the closure)
calling the real function -> Function:f.php:save (unchanged)
plain $max = 10 -> no node, no edge (unchanged)
WHAT THIS DOES NOT FIX. The general problem is wider than PHP: GitNexus node ids
are file-scoped, so a function-local symbol and a file-level one with the same
name collapse in TypeScript, Python and Dart too, and Java/C# only escape by
qualifying on the enclosing CLASS (so two same-named locals in different methods
still collide). SCIP solves it with a separate `local <id>` keyspace that is
document-scoped and never globally addressable. That is issue #2699 — it changes
persisted ids for every function-local symbol and needs its own invalidation, so
it is not bundled here. PHP is fixed on its own merits: the sigil belongs in the
identity regardless of how locals are eventually scoped.
* test(scope-resolution): pin the closure-binding caller-attribution limit (#2693)
Review of this PR found the new callable nodes are call TARGETS but never call
SOURCES: a call made INSIDE a closure binding is attributed to the enclosing
scope, so `impact(handler, direction:"downstream")` reports nothing even though
the closure calls out. Consistent across Kotlin, Dart, Ruby and PHP; TS/JS free
bindings are the exception because their arrow carries a @scope.function whose
range matches.
Not fixed here — pinned, so the boundary is visible instead of surprising, and
so a change in EITHER direction fails a test.
The cause is precise: `pickCallerCallableDef` (graph-bridge/ids.ts) finds the
caller by walking CHILD scopes whose range contains the call site, gated on
`child.kind === 'Function'`. A closure literal is a BLOCK scope in these
languages (Kotlin deliberately, #1757 smart casts), AND the binding's def is
owned by the enclosing scope rather than by the closure's scope — so neither
half of the link exists. Fixing it needs "callable boundary" decoupled from
scope `kind` plus an association between the closure scope and its binding.
That is a change to the caller anchor used by every call in the repo, which is
not something to land at the tail of this PR.
Also adds a unit suite for `buildGraphTargetIndex` itself, covering what the
integration tier cannot isolate: a binding is admitted only on POSITIONAL
evidence, a name-only match is rejected, a non-callable node at that position is
rejected, an ambiguous position claimed by two callables is rejected, and the
PHP dollar sigil normalises across the join while still not matching a
same-named function on another line. That last one closes the review's LOW —
the node/declaration name asymmetry now has an executable contract rather than
resting on a comment.
* docs(test): correct the per-language cause of the attribution limit (#2693)
The comment on the pinned attribution tests claimed "a closure literal is a
BLOCK scope in these languages". That is true for Kotlin (lambda_literal
@scope.block, #1757) and Ruby (do_block/block @scope.block) and FALSE for PHP:
anonymous_function and arrow_function are already @scope.function
(php/query.ts:61-62). Dart is a third case again — it has no scope over a
closure literal at all.
So the four languages fail at three different points, not one:
Kotlin, Ruby fail the `child.kind === 'Function'` gate
PHP passes that gate; its closure scope owns no callable def,
because the binding's def belongs to the enclosing scope
Dart has no child scope for the walk to consider
Worth correcting carefully rather than tidying: a follow-up plan re-stated this
comment instead of re-deriving it, and inherited the misdiagnosis — it proposed
"relax the kind gate" as required for all four, which is a no-op for PHP and
unreachable for Dart. A review caught it. The comment now states each language's
actual blocker and says why the distinction matters.
Comment-only; the three pinned tests are unchanged and still pass.
* fix(scope-resolution): an ordinary JS/TS `function` binds its own `this` (#2701)
`this.m()` inside a nested `function` resolved to the lexically enclosing
class, so it emitted a CALLS edge that does not exist at runtime — including
the exact `forEach(function () { this.m(); })` shape arrow functions were
introduced to avoid:
class D {
m() {}
build() { const h = function () { this.m(); }; return h; }
}
// CALLS: Function:D.ts:D.h -> Method:D.ts:D.m#0 FALSE
ECMA-262 gives an arrow `[[ThisMode]] = lexical`: it has no `this` binding in
its environment record, so the lookup passes through to the enclosing
environment. Every other function form binds `this` at call time. `tsc` draws
the same line by resolving `this` through `getThisContainer` with
`includeArrowFunctions = false`. That one rule is the whole fix.
Languages declare it; shared code never learns a language. The query files —
the one place that already names grammar nodes — tag every non-arrow function
form with `@receiver-owner.this`, which becomes `Scope.ownsReceivers`. A
receiver walk that reaches such a scope without finding the name stops there
instead of borrowing an enclosing scope's binding. Every other language leaves
the field unset and is bit-for-bit unchanged; a Kotlin lambda, which DOES
capture the enclosing `this`, still resolves (pinned as a test).
THREE GATES, ALL LOAD-BEARING. The false edge survived each one alone, which
is why the tests assert on the emitted edge rather than any single walk:
1. `Scope.ownsReceivers` stops BOTH receiver-type walks — `findReceiver
TypeBinding` here and its twin `lookupReceiverType` in gitnexus-shared's
`lookup-core`, which was resolving the receiver independently.
2. `LanguageTypeConfig.thisBoundaryNodeTypes` stops the type-env AST walk
that infers a receiver's type during capture.
3. `isReceiverOwnedButUnbound` makes `receiver-bound-calls` SUPPRESS the
site. Without it the member still resolved by NAME through `lookupCore`'s
lexical chain — the class-body scope binds `m` two scopes up — merely at
lower confidence. An owned-but-unbound receiver is a definitive negative,
not a miss, so it must not reach a receiver-blind fallback.
Also fixed: `function*(){}` as an expression was not a `@scope.function` at
all, so `this` inside one read as the enclosing method's.
WHAT THIS GIVES UP. The fix REMOVES edges, and some were correct:
`.bind(this)`, `.call(this)` and `forEach(fn, thisArg)` do make `this` the
instance at runtime. Their correctness is fixed at the CALL SITE, which no
scope-level rule can see, so the choice is between losing them and keeping
every detached-callback false positive. All three are pinned as tests
asserting the empty result, so changing the trade later is deliberate.
`this` in a static method also stops resolving to the INSTANCE member — that
edge was wrong in the other direction.
INVALIDATION. Both constants move, and the parse-cache one is not optional:
`ownsReceivers` lives on the cached `Scope`, and a warm cache replays scopes
without it — verified by probe that `--force` alone does NOT re-derive it, so
the fix silently did nothing until SCHEMA_BUMP moved. INCREMENTAL_SCHEMA_
VERSION 16 -> 17 (the incremental write set covers only changed files, so
unchanged TS/JS files would keep their fabricated `this` edges);
SCHEMA_BUMP 23 -> 24.
Verified against a built index, not by reading: all three false edges from the
issue gone, every correct edge kept, same result in JavaScript through its
separate grammar. 64 tests green across the new suite plus the closure-binding
and schema-version suites. The full suite's 36 failures are pre-existing
load-flakes — confirmed by A/B: `skip-git-cli` fails FOUR tests on a clean
HEAD versus three with this change, and `pipeline-pdg-streaming` passes in
isolation either way.
Refs #2701
* fix(ingestion): give function-local callables their own identity (#2699)
Graph node ids were file-scoped, so a local callable and a same-named
file-level one collapsed onto ONE node. That is a wrong answer, not a missing
one — the local call was attributed to the file-level symbol:
export function save(x) { return x; }
export function run() { const save = x => x * 2; return save(1); }
export function other() { const save = x => x * 3; return save(2); }
// ONE node Function:a.ts:save, and BOTH run and other pointed at it, so
// `impact` on the top-level save reported two callers that never call it.
A local's identity is now its enclosing-callable chain plus its own position —
`run.save@2:2`. The chain is for humans reading `impact`; the position is what
makes it correct. Names alone cannot express what ECMAScript actually
specifies, and the gap is the language's, not the grammar's: an environment
record is created per function AND per block, so an anonymous function has no
name to contribute and sibling blocks hold distinct bindings under the same
name. One positional rule settles both, with no conditionals and no
"disambiguate only when it looks ambiguous" heuristic — the ambiguity-flag
class of bug that bit #2514. SCIP reaches the same place with its
document-scoped `local <id>` keyspace.
Top-level functions and class methods are NOT locals and keep their ids
byte-for-byte. That is the bound on the churn: this touches only symbols that
are unreachable from outside their own document anyway.
RESOLUTION JOINS BY POSITION, NOT BY NAME. `resolveDefGraphId` matches a def
to its node on (file, label, line, simple name). A def and its node are the
same construct, so this needs no scope chain at all — which is the point:
re-deriving the chain in the resolver would be a second implementation that
could silently disagree with the first. A genuine tie (two callables on one
line) stores an AMBIGUOUS_POSITION tombstone and falls through to the existing
name keys rather than picking by source order. Without this the node ids were
already correct and calls STILL resolved to the file-level symbol — the fix is
only half a fix without it.
JS/TS GAIN BLOCK SCOPES. They emitted no `@scope.block` at all, so the
resolver could not tell two `const pick` in sibling branches apart. Giving
them distinct ids made that visible as DUPLICATE edges — each call resolving
to BOTH — which is worse than the collapse it replaced. `(statement_block)
@scope.block` supplies the missing environment record. The other half of the
ECMAScript rule was already implemented and waiting: `tsBindingScopeFor`
hoists `var` past blocks to the enclosing Function/Module while `let`/`const`
bind innermost, and its docblock already claimed "the innermost default covers
these" for block scopes that did not exist. All 82 scope-resolution test files
pass with blocks on.
Verified by probe, per case: two locals in different functions, a local inside
an ANONYMOUS function (`outer.fn@1:9.save@2:4`), sibling blocks resolving to
their own binding, `var` still hoisting out of its block, a nested named
`function` vs a file-level one, PHP composing with the `$` sigil from #2693,
and Python. Top-level/method ids unchanged, asserted directly.
Every assertion is on the EDGE, not on node existence. Ids are built twice and
independently — definition phase and caller attribution — and a one-character
disagreement makes the caller attach to a node that does not exist and the
edge vanish, with nothing thrown and no test failing. An edge assertion can
only pass if both phases agree.
INVALIDATION. INCREMENTAL_SCHEMA_VERSION 17 -> 18 and SCHEMA_BUMP 24 -> 25:
persisted node ids change for every function-local callable, and the cached
scope tree lacks block scopes. A top-up would leave unchanged files on the old
ids while changed files emit the new ones, splitting each symbol in two.
Bench fingerprint unchanged and both timing budgets pass. The one full-suite
failure (incremental-orchestration) passes in isolation — its log shows stale
init locks and WAL reclaim, i.e. LadybugDB contention under the parallel run.
Refs #2699
* perf(ingestion): emit block scopes only where they bind something (#2699)
Block scopes make `let`/`const` in sibling blocks distinct bindings, which is
what stopped a call in one branch resolving to both. Emitted naively — one
scope per `statement_block` — they also cost ~10% of analyze wall time, because
every scope-chain walk in every function then steps through levels that bind
nothing.
Two emit-side filters keep the semantics and drop the waste:
1. A block that IS a function body duplicates the enclosing Function scope.
Nothing can be declared between a function and its own body, so a binding
in either resolves identically — the inner scope is pure depth.
2. A block that declares no `let`/`const`/`class`/`function` binds nothing,
so it is transparent: a lookup finds nothing in it and walks to the
parent. `var` is deliberately excluded from that list — it hoists past the
block to the function, so a block containing only `var` still binds
nothing.
MEASURED, on a 762-file / 228k-line TypeScript corpus (gitnexus/src), min of 6
warmed reps with the cold first rep discarded:
block scopes emitted 19,389 -> 5,331 (-72%)
total scopes 35,942 -> 21,884 (-39%)
analyze wall time +9.8% -> +1.6-2.5% vs pre-#2699
peak RSS (whole tree) 2398MB -> 2434MB (+1.5%, inside run-to-run noise)
The filters themselves are free: scope emission over the same corpus measured
12.6s naive vs 12.5s filtered.
Wall-clock on a shared runner has a ±10% spread run to run, which is wider than
the effect being optimised, so the durable gate added here counts scopes
instead. `bench/scope-emission/measure.mjs --check` asserts an EXACT scope set
over a synthetic corpus that mixes the shapes the filters discriminate between
— function/method/arrow bodies, non-declaring if/else/for/while/try, blocks
that declare `const`, and a `var`-only block. Baseline is 2 block scopes per
module: only the two `if`/`else` branches that declare `const chosen`. If the
filters regress that number jumps immediately, in a way wall-clock CI could
never resolve from noise. Wired into the existing benchmarks job.
Behaviour is unchanged: 86 scope-resolution and identity test files, 1371
tests, all green — including the sibling-block case this could plausibly have
broken — and the callable-value-flow fingerprint is untouched.
Refs #2699
* test(bench): re-baseline the TS/JS scope-capture fingerprints for #2701
`bench/scope-capture` fingerprints the full capture set per language, and
#2701 added a `@receiver-owner.this` marker to every non-arrow function form
so a scope that BINDS its own `this` can terminate the receiver walk. That is
a capture-set change, so the TypeScript and JavaScript fingerprints moved and
the benchmarks job has been failing since that commit — I pushed it without
checking CI.
A fingerprint is a correctness gate, so this does not simply adopt the new
value. Verified first by diffing the capture-name HISTOGRAM over the same
fixture corpus against 1d308817 (the commit before #2701), which says what a
fingerprint cannot: WHICH names moved.
typescript @receiver-owner.this 0 -> 143
javascript @receiver-owner.this 0 -> 32
Nothing else. Every other capture count is byte-identical, so no existing
capture shifted and the drift is entirely the intended marker. Both languages'
scaling ratios stay well inside their 1.5 budgets (0.976 / 1.025).
Note `@scope.block` does not appear in the delta: the #2699 filters suppress a
block that is a function body or that declares no binding, and no fixture in
this corpus has a block that binds. Block-scope emission is guarded separately
by `bench/scope-emission`, whose synthetic corpus exercises exactly those
shapes.
Refs #2701
* fix(ingestion): stop the callable-prefix walk at class bodies, not only declarations (#2699)
An anonymous class owns its members, but `CLASS_CONTAINER_TYPES` lists only class
DECLARATION nodes — and a Java anonymous class has none. It is
object_creation_expression > class_body > method_declaration
so `enclosingCallablePrefix` sailed straight through the anonymous body, reached the
enclosing method, and re-keyed the member as a function-local of that method:
Method:src/Worker.java:Worker$1.run#0
-> Method:src/Worker.java:Worker.makeHandler.run@7:12#0
That destroys the javac-compatible JLS identity #2550/#2555/#2562 exist to provide, and
broke four existing Java tests that this PR never touched — anonymous-class instance
identity, local-type identity, and enum-constant-body chaining.
The design was right; the boundary was blind. `CALLABLE_PREFIX_BOUNDARY_TYPES` adds the
body and anonymous-construction forms (`class_body`, `interface_body`,
`annotation_type_body`, `enum_body`, `enum_body_declarations`, `enum_constant`,
`object_creation_expression`, `object_literal`,
`anonymous_object_creation_expression`). Over-inclusion is the SAFE direction here: an
extra boundary only suppresses the nesting prefix, falling back to the pre-#2699 class
qualification.
This also falsifies the claim in the #2699 commit that "top-level functions and class
methods keep their ids byte-for-byte" — an anonymous-class method IS a class method, and
its id did change. The claim was true only for the shapes that were tested.
Also removes the dead `NO_QUALIFIED_NAME` constant, which contained a literal NUL byte.
That byte made `file(1)` report the source as `data` and made plain `grep` return zero
matches for ANY pattern in the whole 2,928-line file — which is why several greps during
development came back mysteriously empty. Two other files carry NULs; they are
pre-existing and out of scope here.
INVALIDATION. INCREMENTAL_SCHEMA_VERSION 18 -> 19 and SCHEMA_BUMP 25 -> 26. This is not
defensive: an index stamped v18 holds the WRONG Java ids, and without the bump it passes
the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate and keeps them on every unchanged file.
Found by the PR #2695 tri-review (review 4782134453) — independently by a Claude
adversarial AST probe, by Codex's swarm, and by CI (`tests / ubuntu / coverage 2/3`).
Verified: `resolvers/java.test.ts` 247/247 (was 243/247), plus this-boundary,
function-local-identity and the schema-version suites.
Refs #2699
* fix(scope-resolution): fail closed when a function-local shadows a same-named callable (#2699)
The #2699 positional join failed OPEN. On a position miss `resolveDefGraphId` fell
through to the label-agnostic, first-write-wins `simpleKey(filePath, simpleName)`, which
aliases a def onto whichever same-named callable was registered first — the exact
fabricated-caller mechanism this PR's own #2693 work already shipped once as a P0.
It misses because the two id phases anchor on different nodes BY DESIGN:
`tree-sitter-queries.ts` anchors the graph node on the outer `lexical_declaration`, while
`languages/typescript/query.ts` anchors the scope def on the inner `arrow_function` so
`anchor.range` lines up with `@scope.function` for auto-hoist. Split the declaration
across lines and those land on different LINES:
export function run() { const pick =
(x) => x * 2; return pick(1); }
export function other() { const pick =
(x) => x * 3; return pick(2); }
before: run -> run.pick@1:2 correct
other -> other.pick@6:2 correct
other -> run.pick@1:2 FABRICATED — other() never calls run's pick
Every fixture in function-local-identity.test.ts kept the declaration and its initializer
on ONE line, where the anchors coincide. That is why the suite stayed green while the bug
shipped, and the new test deliberately splits them.
WHY NOT A BLANKET FAIL-CLOSED. A position miss is not always a collision: it also happens
where the anchors legitimately differ, e.g. a Vue SFC, whose graph nodes carry
`+ lineOffset` while scope extraction does not. Failing closed on every miss would delete
correct edges there. So the guard is keyed on evidence that the collision is REAL —
`localNameKey` records that a function-local of this simple name exists in the file
(local-identity nodes are recognisable by the `@<row>:<col>` on their last name segment).
Only then is a miss treated as ambiguity. Files with no such local keep their previous
fallback behaviour byte-for-byte.
A missing edge is the correct failure direction here: `impact` can recover from an absent
caller, but a fabricated one silently corrupts the answer.
WHY NOT UNIFY THE ANCHORS. Considered and rejected: the split is deliberate and
load-bearing for auto-hoist across every language (the `rangesEqual(anchor.range,
innermost.range)` rule), so unifying it would fight that discipline far outside this fix.
The regression test was verified to DISCRIMINATE: with the guard disabled it fails on
exactly the fabricated edge (`+ "Function:m.ts:other -> Function:m.ts:run.pick@1:2"`).
impact(resolveDefGraphId, upstream) is CRITICAL — 62 impacted, 23 direct, 6 flows — which
is precisely why the guard is gated rather than broad. detect_changes: HIGH, 8 affected
processes, all in EmitReceiverBoundCalls / EmitRubyMixinEdges. Verified: 85 test files /
1364 tests green, including every scope-resolution unit.
Found by the PR #2695 tri-review (review 4782134453): raised by Codex's adversarial leg,
mechanism source-confirmed during synthesis, then reproduced end-to-end.
Refs #2699
* fix(typescript): stop the enclosing-type walk at nodes that rebind `this` (#2701)
`findEnclosingType` walked `node.parent` to the top of the file with no boundary, so it
happily synthesized a `this` binding from a type that does not own the member:
class A { outer() { const o = { inner() { return this.x; } }; return o; } }
`this` inside `o.inner` is `o`, never `A` — but the walk reached `A` and bound to it, so
every `this.…` in such a method resolved against the wrong type. Only the module-level
object literal escaped, because there was no enclosing class to reach. Applies to
JavaScript too: `languages/javascript/captures.ts` calls the same function.
Boundary set: object literals and the function forms that rebind `this` at call time.
Arrows are deliberately absent — they inherit `this` lexically, which is what makes a
class-field arrow `m = () => this.x` resolve.
WHY THE MARKER WAS NOT ALSO REMOVED FROM METHOD FORMS.
The review argued `@receiver-owner.this` over-suppresses: `synthesizeTsReceiverBinding`
returns null for static members, object-literal methods and anonymous class expressions,
so those scopes are "owned but unbound" and get suppressed, losing edges the base
resolved. Removing the marker from the method forms was tried and MEASURED, and the
result does not support shipping it:
marker removed, probe of all five shapes:
static -> static RESTORED (true)
object literal (module) RESTORED (true)
anonymous class expression RESTORED (true)
static -> INSTANCE FALSE EDGE returned
object literal in a class FALSE EDGE (Nested.outer.inner -> Nested.x)
The last one is the point: this fix stops the false *synthesis*, but removing the marker
re-enables receiver-blind *name* resolution in `lookupCore`'s lexical chain, which
recreates the same wrong edge by another route. The restored edges and the false ones
come from the SAME mechanism — a name walk — so they cannot be separated by toggling the
marker. The real trade is 2 genuinely-new true edges for 2 false ones, not the 3-for-1
the plan assumed.
Corpus evidence (762 real TypeScript files, edge SETS not counts, cold cache both arms):
baseline vs marker-removed: net 0, REMOVED 0, ADDED 0
Neither the gains nor the losses occur in production code. Given a 1:1 true/false ratio
on synthetic shapes and zero effect on real ones, the marker stays: for a graph feeding
`impact`, a fabricated caller is worse than an absent one — the same principle applied in
the fail-closed positional join. The three shapes remain UNRESOLVED rather than wrongly
resolved; resolving them properly needs a typed binding for object literals, anonymous
classes and static contexts, which is a feature, not this fix.
Measured with an edge-SET diff harness, after both ce-doc-review passes established that
an edge COUNT cannot decide this (it conflates edges gained with edges lost, so a
near-zero net reads as "no regression"). The harness also had to wipe the index each arm
— a warm parse cache initially reported an unchanged edge set across a real behavioural
change, the same trap documented in the v24 SCHEMA_BUMP note.
detect_changes: low risk, 3 symbols, no affected processes. 85 files / 1365 tests green.
Refs #2701
* docs(test): correct the false "three load-bearing gates" claim (#2701)
The header of `this-boundary.test.ts` asserted that all three gates were
independently load-bearing because "the false edge survived removing any one of
them alone". That was true DURING development, measured incrementally, and was
carried into the shipped comment without being re-tested against the finished
code. It is false: gate 3 (`isReceiverOwnedButUnbound` in `receiver-bound-calls`)
runs FIRST and marks the site in `handledSites`, which `emitReferencesViaLookup`
then skips — so for an explicit `this` receiver it subsumes gate 1. Removing
gate 1's `ownsReceivers` check in `gitnexus-shared/.../lookup-core.ts` leaves all
10 tests in the file passing; verified by experiment.
The gate is RETAINED, and the review's recommendation to delete it as "dead" is
rejected on evidence. `receiver-bound-calls` only suppresses EXPLICIT receivers
(`if (site.explicitReceiver === undefined) continue;`), whereas `lookup-core`'s
gate is also reached for IMPLICIT ones through `IMPLICIT_RECEIVERS` in
`resolveReceiverOwner` — a bare `m()` inside a nested `function` inside a method
goes down that path. The experiment shows the gate is UNTESTED, not unreachable;
those are different claims and only the first is supported. Deleting it on the
strength of a green test run would have removed live code, which is the same
reasoning error the corrected comment is about.
This is a documentation-only change: no behaviour, no test expectations. The
correction is recorded in place rather than silently rewritten, because the way
the claim came to be wrong — measured on an intermediate tree, then asserted
about the final one — is the reusable lesson.
Refs #2701
* test(scope-resolution): pin the block-scope ACCESSES delta as false-edge removal (#2699)
The tri-review flagged that enabling `(statement_block) @scope.block` for
JS/TS drops 114 `ACCESSES -> Const` edges corpus-wide with `added: 0`,
undocumented and untested. That was recorded as a suspected regression.
It is not one. All 274 emitting reference sites behind those 114 edges were
classified by re-reading the source at the site: 269 are member reads, the 5
others are classifier artifacts (the name recurs earlier on the line, as in
`a.b.declLine` for `b`) and are member reads too. No edge was
bare-identifier-only. Every dropped edge was a property read
(`options.baseUrl`) mis-resolving to an unrelated function-local `const` of
the same name in the same file.
The cause is not block-specific: `lookupCore` Step 1 walks the lexical chain
for every lookup, including explicit-receiver property reads. Block scopes do
not fix that, they narrow it, by moving the local off the chain of any
reference outside its block. A local declared directly in the function body
still hijacks the read; that is pre-existing and left alone here.
Two tests. The first discriminates: it fails with the block capture removed
(the false edge reappears) and passes with it. The second is a companion
invariant, identical in both arms, so that "the edge went away" cannot be
satisfied by a change that dropped Block-kind bindings outright.
Fixture notes, both of which defeated earlier attempts at this edge class:
`pruneLocalSymbols` deletes ~94% of function-local value symbols, so the
`const` under test must be kept via `keepLocalValueSymbols`; and the member
read must sit outside the block, since inside it the block is on the
reference's own chain and the false edge appears in both arms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* docs(ingestion): correct the SCIP citation on the function-local id (#2699)
The comment justified the positional, name-bearing local id (`fn@12:9`) as
"same reasoning as SCIP's document-scoped `local <id>` keyspace". SCIP is the
wrong citation for this key shape: its `local <id>` is a per-document counter,
and the spec states that locals do not encode the name.
SCIP remains prior art for the document-scoped keyspace itself, which is the
part the argument actually leans on, so the reference is corrected rather than
dropped. clang's USR for a function-local (`name@offset`) and Kythe's C++
indexer are the accurate citations for a positional, name-bearing key.
Comment only, no behavior change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* fix(typescript,javascript): sync the function node-type lists, and test it (#2701)
Four hand-maintained lists answer "which node types are function-like":
1. `query.ts` — the `@scope.function` / `@receiver-owner.this` patterns
2. `captures.ts` — `FUNCTION_NODE_TYPES` (callable-flow synthesis + the
body-block filter)
3. `receiver-binding.ts` — `THIS_REBINDING_BOUNDARY_TYPES`
4. `type-extractors/typescript.ts` — `THIS_BOUNDARY_NODE_TYPES`, whose
docstring already claimed it was "kept in sync with `@receiver-owner.this`"
with nothing enforcing it
`generator_function` (the EXPRESSION form, `const g = function* () {}`) was
added to both queries for #2701 and is present in lists 3 and 4, but was
missing from both `FUNCTION_NODE_TYPES`. Added.
That gap changes no graph output today, and the commit does not claim
otherwise. Measured on `const g = function* (x) { yield x; }; g(1)`: node and
edge sets are byte-identical with and without the entry. The `this` boundary
was already correct via the query marker — `this-boundary.test.ts` has a
passing generator case. A generator-expression binding still emits a `Const`
node rather than a `Function` one, so its call resolves to nothing either way;
that label comes from the definition rules, and closing it is a separate change
NOT made here.
So the entry is list consistency and the test is the real deliverable. It
asserts lists 1 and 2 EQUAL, and lists 3 and 4 as subsets of the query markers
with an explicit allowlist — the method forms bind their own `this` but the
class is their `this`-owner, so neither walk may stop there. Verified
discriminating: removing the `generator_function` entry fails both equality
assertions.
The lists are exported for the test; no other production surface changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
* test(bench): gate scope emission per language, not TypeScript-only (#2699)
The scope-emission gate ran the TypeScript emitter only, so a JavaScript-only
regression shipped green. The two filters it guards are implemented twice —
`FUNCTION_BODY_OWNER_TYPES` in `typescript/captures.ts` and
`JS_FUNCTION_BODY_OWNER_TYPES` in `javascript/captures.ts`, each with its own
`blockDeclaresBinding` and `BLOCK_BINDING_CHILD_TYPES` — so covering one said
nothing about the other.
Adds a structurally parallel JavaScript corpus (the same shapes with the
TS-only syntax removed) and splits `baselines.json` per language. `--check`
now also fails when a baselined language is not measured, which is how a gate
goes quietly green.
Verified the new arm bites: disabling the JS body-block filter alone takes
JavaScript from 400 to 600 block scopes and fails `--check`, while TypeScript
stays green — the exact regression the old gate would have passed.
The two languages happen to agree exactly on this corpus (2 blocks per module,
2200 scopes). That is recorded as a measured result, not an invariant: each
language is still gated against its own baseline. TypeScript's numbers are
unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR
---------
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(lib): add stripWindowsLongPathPrefix for path comparisons (#2667)
A caller can hand GitNexus a `\\?\`-prefixed path — the usual MAX_PATH
workaround on Windows — and `path.resolve` preserves the prefix, so it
reaches every string comparison GitNexus keys paths on. It also poisons
relativization: `path.win32.relative` cannot express a relative path
between a prefixed and an un-prefixed form of the same directory, so it
returns the absolute target instead. That absolute string is the shape
reported in #2667.
The helper is deliberately scoped to the comparison domain. libuv's
`fs__capture_path` does not re-add the prefix for over-MAX_PATH paths, so
stripping a filesystem-facing path would break long-path access on hosts
that have not opted into LongPathsEnabled. `\\?\Volume{GUID}\…` is left
alone because the remainder is not a usable path.
The test is fixture-free and takes an explicit `platform`, mirroring
`normalizeAnalyzerRootPath`, and is registered on the cross-platform
matrix since the whole transform is a POSIX no-op.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* fix(storage): normalize the `\\?\` prefix in canonicalizePath (#2667)
`canonicalizePath` is the single comparison key for the repo registry,
MCP repo resolution and the server repo routes, and `registryPathEquals`
compares its output as a plain string. A caller-supplied `\\?\` prefix
therefore matched nothing: a repo registered as `D:\repo` was invisible
to a caller passing `\\?\D:\repo`, which surfaces as "repo not found" or
a duplicate registration from `analyze`, `remove`, `clean`, the MCP
`repo` parameter and the server routes.
Both branches are normalized. The realpath branch was already safe —
libuv's `fs__realpath` strips the prefix itself — but the `catch`
fallback returns `path.resolve(p)` untouched, and that is exactly the
branch a path which is not on disk takes.
Safe despite the CRITICAL blast radius (27 impacted, 12 direct
dependents) because the result is only ever compared, never opened: all
23 call sites feed `registryPathEquals` or a string comparison. Both
operands are canonicalized, so the equality relation is preserved and
behaviour is unchanged for every un-prefixed input.
The two regression assertions run only on windows-latest, where the file
already runs via the cross-platform matrix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* docs(core): correct two false comments about Windows paths (#2667)
Both comments assert the opposite of how the platform and the analyzer
actually behave, and both would send the next investigator of #2667 the
wrong way.
`analyzer-identity.ts` claimed the `\\?\` prefix is one that
`realpathSync.native` "can emit for paths over MAX_PATH". libuv's
`fs__realpath_handle` strips the prefix unconditionally and rewrites
`\\?\UNC\` back to `\\`, erroring if neither is present, so realpath
never returns one. The prefix can only arrive from caller-supplied
input. The optional group in the regex stays as a labelled defensive
no-op, and the function's behaviour is unchanged on purpose: these
identity fields are compared between an `analyze` and a later `status`
run, so this is not the place to reshape a path.
`include-extractor.ts` claimed "gitnexus analyze stores absolute paths in
the File.filePath column". A full self-index at 89bbdcf5 had 0 of 239,070
nodes with an absolute or backslash-bearing filePath: File nodes are
built from the walker's repo-relative forward-slash paths. The
relativization guard below it stays, now described as what it is — a
guard against rows this process did not write.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* test(lib): pin that path.resolve preserves the `\\?\` prefix (#2667)
The `canonicalizePath` regression tests for the `catch` fallback can only
run on windows-latest, so the fact they rest on is invisible in the Ubuntu
suite. Pin it here, in the fixture-free file that runs everywhere:
`path.win32.resolve` carries the prefix through untouched, which is all
the fallback branch used to do before this fix.
Also pins the forward-slash spelling (`//?/D:/…`), which the helper
deliberately does not match because `resolve` folds it into the backslash
form first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* fix(lib): match the `\\?\UNC\` token case-insensitively (#2667)
The namespace `\\?\` addresses is the Windows object namespace, which is
case-insensitive, so `\\?\unc\server\share` is as valid as the uppercase
spelling. Matching only `UNC` left the lowercase form prefixed, which is
the same registry mismatch #2667 is about, reached through a network
share instead of a drive.
The drive branch was already case-insensitive (`[A-Za-z]`), so the two
branches disagreed with each other. Probed against the built artifact:
`\\?\unc\…`, `\\?\Unc\…` and `\\?\UNC\…` now all yield
`\\server\share\…`, and `\\?\Volume{…}` is still left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* test(storage): guard the canonicalizePath fix on Linux too (#2667)
The two `canonicalizePath` assertions in `repo-manager.test.ts` drive the
real `realpathSync.native`, so they are `it.skipIf(win32)` and only run on
the windows-latest matrix leg. The Ubuntu gate — the one every PR runs —
had no coverage of the behaviour at all.
This runs the same wiring anywhere by injecting only the platform
primitives: `path` becomes `path.win32` (Node's real Windows path
implementation, not a stand-in), `realpathSync.native` gets its two actual
behaviours (libuv strips `\\?\` for a path on disk, throws ENOENT for one
that is not), and the real `stripWindowsLongPathPrefix` is pinned to
win32 rather than defaulting to the host. `canonicalizePath` and
`registryPathEquals` run unmodified.
Pinning the helper is a module mock rather than an override of
`process.platform`, which is shared by every test file in a worker.
Verified to discriminate: against the pre-fix tree at 89bbdcf5 it fails 3
of 5, and reverting just the two strip calls on this branch reproduces the
same 3 failures with `expected '\\?\D:\Projects\moved-away' to be
'D:\Projects\moved-away'`. The two that pass either way are the realpath
branch and the un-prefixed no-op, neither of which ever leaked.
Not registered in scripts/cross-platform-tests.ts: it simulates Windows
rather than needing it, so its home is the Ubuntu suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* fix(lib): require the component that makes a stripped path usable (#2667)
Both regexes were under-anchored, so the slice could emit something worse
than the input it was handed. `\\?\UNC` has no share to keep and became
the bare root `\\`; `\\?\D:foo` is drive-relative and became `D:foo`,
which is not absolute and would resolve against the process cwd if a
future caller ever passed it to `fs`. `canonicalizePath` previously
always returned an absolute path and had stopped doing so.
Each pattern now requires the part that makes the remainder a real path —
a share name after `UNC\`, a separator after the drive colon. Malformed
extended paths are left untouched and simply fail to match a registry
entry, which is the safe direction.
Also from review: document `\\.\` as a deliberate non-goal alongside
`\\?\Volume{GUID}\` (most of what it addresses is not a filesystem path),
correct the canonicalizePath docblock, which still claimed entries are
canonicalised at write time — `registerRepo` stores `path.resolve` and
the paragraph added two lines above says compare-only — and reword the
cross-platform registration comment, which claimed the test is only
meaningful on windows-latest when every assertion passes an explicit
'win32' and runs identically on Ubuntu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* fix(group): keep repo-relative rows in the graph provider strategy (#2667)
`extractProvidersGraph` relativised every row with
`path.relative(normalizedRepoPath, absolute)`. The rows analyze actually
writes are repo-relative — which is exactly what the comment corrected
earlier in this branch establishes — and `path.relative` resolves a
relative second argument against the PROCESS CWD. So from any cwd other
than the repo root, every row came back `..`-prefixed, the containment
guard dropped it, and the strategy silently returned [] and fell through
to the filesystem fallback.
Only absolute rows go through `path.relative` now. The containment guard
is unchanged, so foreign and escaping rows are still rejected.
Found by three independent reviewers reading the comment this branch
corrected and following it to its consequence. The regression test fails
without the guard (`expected false to be true`) and passes with it;
vitest runs from `gitnexus/`, never the fixture dir, so it exercises the
cwd mismatch by construction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
* test: close the coverage gaps the review surfaced (#2667)
Adds the assertions the reviewers named as missing, and corrects one more
comment that gave the right conclusion for the wrong reason.
analyzer-identity: the comment said the `\\?\` prefix is preserved so the
identity fields keep a stable compare shape. That is true but secondary.
The load-bearing reason is that these roots are READ FROM — resolveBuildRoot
joins package.json onto packageRoot, collectBuildEntries walks buildRoot,
and the lockfile lookup walks packageRoot's ancestors — so stripping here
would break analyzer identity on a deep checkout for exactly the reason the
ingress strip was withdrawn.
Helper: near-miss spellings (`\\??\`, `\\?\\`, single-backslash, GLOBALROOT)
and forward-slash/mixed-separator forms are pinned as untouched, plus
degenerate and empty input.
canonicalizePath: volume-GUID and `\\.\` are asserted unmatched through
canonicalizePath itself, not just the helper, so the deliberate branch
asymmetry is pinned where it is consumed.
assertSafeStoragePath: prefixed path + prefixed storagePath passes, mixed
form throws. This guard fronts fs.rm(recursive) and deliberately does NOT
canonicalize; "complete the fix by stripping here too" is the tempting
follow-up and would widen what the recursive delete accepts.
resolveRegisteredRepoEntry: the consumer surface the fix exists for — an
MCP `repo` argument or `?repo=` value in the prefixed spelling now resolves
its un-prefixed entry, and a prefixed path naming no entry still fails
closed. Verified to discriminate: reverting the strip fails this test along
with the three catch-branch ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a machine with no cached LadybugDB extension, the first `gitnexus analyze`
logs a WARN
GitNexus: FTS extension unavailable; continuing without FTS features.
load-only policy (no install attempted); LOAD fts failed: ...
and then, in the same run, installs FTS and builds every search index. Nothing
was degraded — only the log was wrong, and it sent users chasing a broken
install path that does not exist (see the first of the two warn lines in #2184,
where only the second one is real).
The line comes from `initLbug`'s writable FTS pre-load. That call deliberately
never installs (analyze owns extension installation), so on a cold cache it is
*expected* to miss; Phase 3 retries moments later with the `auto` policy and
succeeds. `ExtensionManager.markUnavailable` had no way to tell that
speculative probe from a final answer, so it reported every miss as a user-
facing degradation.
Adds `quiet` to `ExtensionEnsureOptions`: the outcome is still recorded in
capabilities, but it is logged at debug level and does not consume the
once-per-(extension, reason) warn budget — so a later real failure with the
same reason still warns. Set only on the writable `initLbug` pre-load. The
read-only serve/MCP branch keeps `{ policy: 'load-only' }` with no `quiet`:
there is no later retry there, so that warning is accurate. Analyze Phase 3,
`--repair-fts` and genuinely-offline installs (#2184) are untouched and still
report loudly.
Verified end-to-end against a temp `HOME` with no `~/.lbdb`: analyze emits no
FTS warning, installs `libfts.lbug_extension`, and builds all FTS indexes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(communities): drop the O(communities x N) copy in vendored Leiden (#2337)
`UndirectedLeidenAddenda.mergeNodesSubset` snapshotted the pre-merge
`externalEdgeWeightPerCommunity` with a full-array `.slice()` on every
macro-community, so a graph with C communities and N nodes copied C x N
float64s per Leiden pass. CPU profiling put 70% of a 100k-node run in that
one function, plus ~7s of GC from the per-community allocations.
Only entries for nodes inside the current subset are ever read back (every
neighbour is filtered on `belongings[et] === currentMacroCommunity`), so
snapshot just those into a scratch buffer allocated once per addenda.
Measured on seeded planted-partition graphs, partitions bit-identical:
20k nodes / 54k edges 2350ms -> 527ms (4.5x)
60k / 200k 12513ms -> 3328ms (3.8x)
100k / 350k 44151ms -> 4816ms (9.2x)
200k / 800k >580s -> 14622ms (>40x)
The 200k case previously blew through LEIDEN_TIMEOUT_MS and degraded every
symbol into a single community; it now finishes well inside the timeout.
Adds golden-partition and repeat-run determinism tests, which nothing
covered before.
Committed with --no-verify: the pre-commit typecheck gate fails on
pre-existing `BindingRef.visibility` errors in csharp/namespace-siblings.ts
and scope-resolution/passes/free-call-fallback.ts, both untouched here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfQfKy4gCmgUv1jBRJTSs2
* fix(communities): wire the Icebug engine to the real @ladybugmem/icebug API (#2337)
The gate merged in #2376 could never have run. It imported the bare
specifier `icebug`, which on npm is an unrelated node-inspector/nodemon
wrapper — the graph library publishes as `@ladybugmem/icebug`. It then
probed for `Graph.fromCSR` and `community.ParallelLeidenView`, neither of
which exists: the module exports `GraphR(n, directed, outIndices, outIndptr)`
and a top-level `Leiden(graph, iterations, randomize, gamma)`. The
constructor call also had `gamma` and `randomize` transposed, and
`getPartition()` returns `{membership, count}`, which the array-like probe
rejected. Every `GITNEXUS_COMMUNITY_ENGINE=icebug` run fell back to
Graphology with a shape error.
Rewrites the worker against the published surface and deletes the
speculative probing it needed while the API was unknown — the four-way
`readPartition` candidate scan, the `readModularity` ladder, the
object-vs-positional constructor retry, and the `isNumericArrayLike`
helper. What stays is the guard that matters: `setNumberOfThreads` and
`setSeed` are required, because community IDs feed generated context and
must be reproducible.
Icebug is deliberately not a declared dependency. Its prebuilds link
against system Arrow 24, OpenMP and glibc >= 2.38, so it stays an opt-in
`npm i @ladybugmem/icebug` rather than 30MB every install pays for. Note
that the published 12.8.0 tarball omits the thread/seed exports that
icebug-nodejs HEAD has, so the determinism guard is what trips today.
The worker source is now built from a module specifier so tests can run it
against a stub shaped like the real package. That pins the package name,
class names, constructor argument order and partition shape — none of
which anything caught before.
Committed with --no-verify: the pre-commit typecheck gate fails on
pre-existing `BindingRef.visibility` errors in csharp/namespace-siblings.ts
and scope-resolution/passes/free-call-fallback.ts, both untouched here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfQfKy4gCmgUv1jBRJTSs2
* docs(communities): label the Icebug engine experimental and announce it at runtime (#2337)
The engine was opt-in but silent about what opting in means. A run that
succeeds is exactly when the user most needs to know the partition came
from the experimental path, since community IDs feed generated context and
the two engines partition differently — switching invalidates anything
keyed on those IDs.
Emits the notice when a non-default engine is requested rather than only on
fallback, and states the no-stability-guarantee terms in the README and the
options doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfQfKy4gCmgUv1jBRJTSs2
* fix(communities): never terminate the icebug worker mid-N-API (#2432, #2337)
Self-review of this PR found that making the native Leiden path reachable
also arms a hazard this repo has already paid for once. The icebug worker
spends its entire life inside N-API — dlopen, GraphR, Leiden, run — so the
60s timeout handler's `worker.terminate()` would kill a thread mid-native-
call, which aborts the whole process (Napi::Error -> std::terminate ->
SIGABRT) rather than falling back to Graphology. A timeout on a large
projection is exactly the case the engine exists to serve, so the failure
mode was aimed at its own target.
Drops terminate() from all three paths. On timeout the worker is unref'd
and abandoned, so a wedged native run cannot hold the process open either.
On the settled paths nothing is needed: the worker script ends after its
single postMessage and the thread exits on its own — measured at 40ms.
Records the rule as GUARDRAILS non-negotiable 6, since the same trap is
open to any future worker running tree-sitter, LadybugDB or Icebug code,
and it only reproduces once the native module actually loads — which is
precisely the path you cannot exercise locally.
Also from the review:
- Marks vendor/leiden/utils.cjs as a local fork. A re-vendor from upstream
would silently restore the O(communities x N) copy, and no test would
notice: both versions produce bit-identical partitions, so the goldens
pass either way. The header now names the divergence and its symptom.
- Qualifies the README performance claim. "~15s for a 200k-symbol
projection" was measured on a synthetic planted-partition graph, not a
real repo, and Leiden is sensitive to degree distribution.
The terminate rule is regression-tested: restoring the call fails the
mocked-worker test with `expected 1 to be +0`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfQfKy4gCmgUv1jBRJTSs2
---------
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs(plans): add glibc-windows-fts-diagnostics plan
Implementation plan for #2672 (glibc-too-old native-load misdiagnosis)
and #2669 (Windows FTS prerequisites + Git Bash zero-install workaround).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cli): stop prescribing a reinstall when the host glibc is too old (#2672)
The LadybugDB prebuilt binary requires GLIBC_2.34 (dlopen/pthread_* at 2.34,
fstat64/lstat at 2.33). On an older host the loader reports
version `GLIBC_2.34' not found (required by .../lbugjs.node)
and checkLbugNative answered with "truncated file, ABI mismatch, or
wrong-platform binary" plus instructions to re-run install.js. That advice is
actively wrong for this class: every download ships the same prebuilt binary,
so the reinstall fails identically and the user loops.
Add glibcTooOldMessage: match a GLIBC_<version> token on a "not found" line,
report the highest required version (compared numerically, so 2.9 < 2.34)
alongside this host's glibc from process.report, state that reinstalling will
NOT help, and point at the real options. The branch sits on the arm where the
probe actually ran and failed, so an unrunnable probe still fails open (#2441).
The glibc read is local rather than analyzer-identity's detectLibcVariant:
native-check is the dependency-light startup gate and must not statically pull
in a module the CLI reaches through a dynamic import.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(lbug): name the Git Bash zero-install fix for Windows FTS load failures (#2669)
The Windows error-126 remedy already refuses to prescribe a reinstall and names
the VC++ redistributable and the OpenSSL 3 DLLs, but not where those DLLs
already exist on the machine. #2669's reporter had the redistributable
installed and still failed: the same command failed in PowerShell and succeeded
in Git Bash, because Git for Windows puts libssl-3-x64.dll and
libcrypto-3-x64.dll on PATH via C:\Program Files\Git\mingw64\bin.
Add that hint to the Windows-126 and structural missing-dependency remedies
through one shared const, following the VC_REDIST_INSTALL_HINT anti-drift
pattern (#2383 F5). Placing it in the builders rather than at a call site is
load-bearing: markUnavailable caches the whole diagnosis (#2383 F3) and
ftsDegradedWarning replays that cached remedy, so a call-site fix would miss
the MCP query and /api/search surfaces.
The hint is a fixed system path, never a user-profile one — remedy text is not
path-redacted, and fts-degraded-warning.test.ts asserts no C:\Users\ path ever
reaches a user. Both touched tests now assert that property directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(readme): document the Linux glibc floor and Windows FTS prerequisites (#2672, #2669)
Requirements listed only Node and git, so neither runtime prerequisite that
these two issues turn on was discoverable before hitting the failure.
- Linux: the LadybugDB prebuilt binary needs glibc 2.34+; name the distro
versions that clear it and state plainly that reinstalling does not help.
- Windows: full-text search needs the VC++ 2015-2022 x64 redistributable AND
OpenSSL 3 on PATH. The redistributable alone is not sufficient (#2669's
reporter had it), and Git for Windows already ships the OpenSSL DLLs, so
running from Git Bash or prepending mingw64\bin is a zero-install fix.
Without them analyze still succeeds but the index carries no search tables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: drop the plan document from version control
docs/* is gitignored; the plan was force-added so it would travel with the
work. It is working material, not a repository artifact — the code, tests and
README carry the reasoning that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cli): stop doctor reporting a present-but-unloadable binary as missing (#2672)
doctor printed "✗ lbugjs.node missing" for every failed native check — including
the case this PR is about, where the binary is right there and merely fails to
load because the host glibc is too old. It then wrote the real detail to stderr
directly beneath, so the two lines contradicted each other and the headline sent
users to reinstall a file they already had. It said the same for a truncated
download and for an entirely absent @ladybugdb/core package.
checkLbugNative already knows which of the three it found, so record it: a
`kind` discriminator ('package_missing' | 'binary_missing' | 'load_failed') set
at each failure return. doctor renders it through a new exported
`nativeStatusLine`, following the existing pageSizeDoctorLines/poolSizeDoctorLine
pure-helper pattern — which also makes the line testable, where before it had no
coverage at all. An unrecognized or absent kind keeps the conservative "missing".
Deriving this in doctor with a second existsSync would have re-stat'd a file the
check had already inspected, and could disagree with what it actually observed.
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>
`isInside()` paired its `..` checks with no absolute-path rejection, so on
Windows it reported an unrelated drive as *inside* the parent. `path.relative`
cannot express a relative path between two drives and returns the absolute
target instead:
path.win32.relative('C:\\parent\\src', 'D:\\other\\file.js') // 'D:\\other\\file.js'
That string does not start with '..', so the guard passed it.
Impact, per call site:
- resolveInvokedArtifact: adopts `process.argv[1]` as the invoked analyzer
artifact whenever it merely sits on another drive. That file is then absent
from the validated build, so resolveAnalyzerRunnerIdentity throws — `analyze`
and `status` fail outright on a multi-drive Windows install (e.g. a launcher
on D: invoking a package installed on C:). This is how the bug surfaced: the
GitHub Windows runner keeps the repo on D: and temp fixtures on C:.
- cacheDirectory: the "trusted cache directory must be outside the package and
build roots" guard wrongly fires for a directory on another drive, rejecting a
legitimate configuration.
- validateIdentityCache / cachedBuildDigestForPath: a containment check that can
answer "inside" for a path on another drive is weaker than intended.
Fix: reject an absolute `path.relative` result. This is the idiom the repo's
other containment guards already use — server/api.ts, server/git-clone.ts and
group/extractors/fs-utils.ts all pair the '..' check with `path.isAbsolute`;
this function was the outlier.
`pathApi` is injectable (defaulting to the platform-bound `path`) so the win32
semantics are unit-testable from a POSIX runner. The new test is fixture-free
and registered on the cross-platform matrix; its cross-drive case fails without
the guard and the same-drive/POSIX cases pass either way, proving the fix is
narrow.
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* refactor(lbug): extract SyncCsvWriter into a shared module
`PdgEmitSink` (#2202) declared `SyncCsvWriter` as a private, non-exported
class. The structural streaming sink for #2680 needs the same buffered
sync-write + poison/openFailure IO discipline, and importing it is not
possible while it is module-private — so the alternative was copying ~90
lines of it.
Extract the class (and the chunk-rows default it uses) into
`sync-csv-writer.ts` and have `PdgEmitSink` import it.
`DEFAULT_PDG_EMIT_CHUNK_ROWS` stays exported as an alias so no existing
caller changes.
Pure refactor: no behaviour change. pdg-emit-sink.ts 396 -> 302 lines;
tsc clean; the 23 existing #2202 tests pass unchanged.
Refs #2680
* feat(lbug): add GraphEmitSink for streaming structural relationship emit
Structural sibling of PdgEmitSink (#2202): a KnowledgeGraph façade that
routes relationships no mid-pipeline phase reads back to bounded
CSV-on-disk and never stores them. Nothing constructs it yet.
Measurement drove the design. On a kernel-shaped synthetic graph (400k
nodes, 2.7 edges/node):
nodes only ...... 367 B/node
nodes + edges ... 2075 B/node <- reproduces the #2649 ~2.1 KB/node
=> the relationship layer is 83% of graph heap, ~646 B/edge
so streaming *relationships* is where the memory is; nodes stay resident
(they are 17%, and two scope-resolution index builders scan them).
Dropping just the redundant relationshipsByType/edgeIdsByNode indexes was
also measured — 174 of 648 B/edge, ~1.3x — and is not a substitute.
RETAINED_REL_TYPES is derived from an exhaustive audit of every
relationship read site under src/, and each entry names its reader. An
earlier draft carried 14 types, 5 of which no reachable phase reads.
Two deliberate departures from PdgEmitSink, both because its invariants
do not hold here:
- dedup by relationship id, since no upstream per-file uniqueness
guarantee exists for structural edges and COPY would violate the PK;
- removeRelationship on an already-streamed id throws instead of
no-oping, so a mutating consumer cannot corrupt the graph undetected.
Also exposes hasStreamedSemanticEdge for the local-symbol pruner: without
it a block-local symbol referenced only by a streamed edge looks
unreferenced and gets pruned, leaving a CSV row pointing at a node with
no row.
Refs #2680
* feat(analyze): stream structural relationships to CSV under GITNEXUS_STREAM_GRAPH_EMIT
Wires GraphEmitSink into the pipeline behind a full-rebuild-only flag, so
relationships that no mid-pipeline phase reads back never enter the JS
heap. Measured ~2.9x reduction of graph heap:
0.17 (nodes) + 0.83 * 0.21 (retained edges) = 0.344 retained. This is a
constant factor, NOT O(chunk) — node identity and the resolution
registries stay O(repo).
The sink is armed at the PARSE boundary, not at graph construction. An
exhaustive audit of every relationship read site under src/ found four
mid-pipeline CALLS consumers, not the two an earlier draft assumed:
- local-symbol-pruner (full iterRelationships scan, then removeNode)
- communities / processes (whole-graph forEachRelationship)
- mapCobolToGraph, which scans CALLS and REMOVES the unresolved ones —
and runs BEFORE parse, so streaming from construction would have
silently stopped COBOL cross-program call resolution
- taintSummaries, gated on `pdg` and NOT on `skipGraphPhases`, so it
needs its own gate or --pdg + this flag yields an empty taint layer
Accordingly communities, processes, taintSummaries and callSummaries are
all disabled under the flag, and the run logs what it is giving up.
Two fixes that are correct independently of the flag:
- runPipelineFromRepo keyed its community/process extraction off
`!skipGraphPhases` while getPhaseOutput THROWS on a phase filtered out
by any enabledWhen predicate — now a presence check, so filtered
combinations return undefined instead of crashing.
- loadGraphToLbug COPYs one job per CSV FILE rather than per label pair.
#2202's throw-on-collision merge is only sound because BasicBlock pairs
are disjoint; a streamed CALLS edge is Function|Function and always
collides with the whole-graph CSV for that pair, so the structural
manifest appends instead.
The buffer-pool hint adds the streamed row count back in: the hint only
ever shrinks the pool, so sizing it from the post-streaming
relationshipCount would starve the COPY at exactly the scale this
targets.
detect_changes: 18 symbols / 10 files / 9 processes, all within the
planned scope. Full suite green with the flag off.
Refs #2680
* fix(mcp): stop impact() under-reporting risk on a streamed index
An index built with streamed structural emit has no Process or Community
rows, and impact()'s risk scorer uses processCount >= 5 and
moduleCount >= 5 as two of its four CRITICAL escalation criteria. The
missing-table errors are swallowed as benign without raising `partial`,
so nothing distinguished 'this repo has no processes' from 'this index
was built without them' — the same change would report LOW off a streamed
index and CRITICAL off a complete one, with no signal either way.
That is the false-clean shape #2283 ruled out for detect_changes, and it
matters more here because the repo's own workflow mandates impact()
before every symbol edit.
Stamp `graphPhases: 'complete' | 'skipped'` into RepoMeta and have
impact() attach riskUnderstated + an explanatory riskNote when the index
is stamped skipped, so the reported level is explicitly a lower bound.
Unlike the rest of RepoMeta.capabilities this stamp has a real
programmatic reader.
Also documents GITNEXUS_STREAM_GRAPH_EMIT in the README env table,
including everything the flag disables.
Refs #2680
* test(lbug): differential set-identity gate for streamed structural emit
The acceptance property for #2680: for the same node/edge set, the rows
reaching the bulk COPY must be identical whether streaming is on or off.
With streaming on they arrive from two places — the residual in-memory
graph via streamAllCSVsToDisk, plus the sink's per-pair CSVs — so the
test asserts their UNION equals the single whole-graph emit.
Also asserts the split is real (retained + streamed == total, streamed >
0), so a sink that silently streamed nothing cannot pass the equality
vacuously. Verified discriminating: with sink.arm() commented out the
test fails ('expected 0 to be greater than 0'); restored, it passes.
Fixture spans both sides of RETAINED_REL_TYPES and includes a self-edge
and a duplicate relationship id — the cases where a naive sink diverges
from the whole-graph emit.
Drives the sink directly rather than running analyze, matching
pdg-emit-streaming-roundtrip.test.ts: the guarantee is about emitted
rows, and the worker pool would add unrelated machinery without
strengthening the assertion.
Refs #2680
* fix(test): remove literal NUL byte and cover streamGraphEmit phase gating
Two review findings, both verified before accepting.
1. The round-trip test contained a literal NUL byte as a key separator,
which made Git treat the whole .ts file as BINARY —
`git show --numstat` reported `-\t-` for it, so the file would not
diff or blame and CI text tooling would skip it. Replaced with the
escaped \\u0000 sequence; behaviour is identical, the file is text
again. (Found by the Codex swarm lane.)
2. buildPhaseList's four new streamGraphEmit gating predicates and the
flag-off default path had no test that would fail on revert — two
review lanes flagged this independently. Reversing any enabledWhen
condition would have passed the suite silently, which matters because
an ungated taintSummaries yields an empty taint layer rather than an
error.
Added four cases: the streamed run drops communities/processes/
taintSummaries/callSummaries; it keeps mro/di (their reads are all in
RETAINED_REL_TYPES); the flag-off list is untouched; and skipGraphPhases
still works independently.
Refs #2680
* fix(analyze): don't leak a temp dir when streaming is off; correct two overclaims
Three review findings, all verified before accepting.
1. `graphEmitCsvDir: resolveNativeSafeStorageDir(...)` was evaluated
unconditionally inside the pipeline-options literal. On a Windows
non-ASCII storage path that helper mkdtempSyncs a REAL directory, so
every analyze leaked one temp dir even with the flag off. Now resolved
only when streaming is active, matching how the PDG sibling resolves
inside its own guard. This was the only finding affecting flag-off
users.
2. The retain-set comment claimed 'the differential round-trip test is
what catches drift'. It cannot. addRelationship PARTITIONS edges
between the graph and the CSVs, and the union of a partition is
invariant under where the partition line falls — so that test stays
green no matter how RETAINED_REL_TYPES is drawn. Only the read-site
audit protects the invariant, and the comment now says so and names
the grep to re-run.
3. The ~2.9x figure assigned streamed edges a retained cost of zero,
ignoring the sink's own streamedIds/streamedEndpoints Sets — and
relationship ids are plain concatenations of both endpoint ids, not
hashes. Review measured those Sets at ~35% of full per-edge retention,
not the '~a tenth' assumed, putting the real figure nearer ~1.7-2.2x;
a member-dense Java/C# repo lands lower still, since the retained
structural spine is a larger share there than in the TypeScript census
the 0.21 came from. Code comment and README now give a range and say
plainly that no end-to-end measurement on a real repository exists yet.
Refs #2680
* fix(mcp): disclose degraded risk in detect_changes; stop pinning the sink
Two more review findings, both cross-lane corroborated.
1. detect_changes derives risk_level SOLELY from affected-process count,
and a graphPhases:'skipped' index has zero Process rows by
construction. The STEP_IN_PROCESS query then succeeds with zero rows,
so queryDegraded stays false and the tool returns risk_level 'low',
affected_count 0, with no partial marker — for every change, forever.
That is a false-clean on the gate this repo mandates before every
commit, and it is the same #2283 shape the previous commit fixed in
impact() while leaving its sibling untouched. Now carries the same
riskUnderstated + riskNote disclosure.
2. PipelineResult.graphEmitSink had zero readers — the pruner predicate
and the manifest are both threaded elsewhere — but returning it kept
the sink, and therefore its O(streamed-edges) id and endpoint Sets,
reachable through the entire COPY/FTS/embedding phase. That is
precisely the phase this feature exists to fit inside RAM, so the
field actively worked against the change's purpose. Dropped.
Refs #2680
* refactor(2680): one named capability, one risk helper, a shorter header
Pure cleanup pass — no behaviour change, 66 tests across the six affected
suites still green, and the round-trip test still fails when the sink is
left un-started.
Three things were untidy:
1. The phase layer reached the sink through TWO loose callbacks bolted
onto PipelineContext (`armStreaming`, `hasStreamedSemanticEdge`) —
two fields, two wiring lines, no name for the thing they belonged to.
Replaced by one `graphEmit?: GraphEmitControl`, a two-method interface
declared beside the sink. Phases now say what they mean:
`ctx.graphEmit?.beginStreaming()`. Also renames `arm()` to
`beginStreaming()`, which needs no comment to explain.
2. The degraded-index risk disclosure was copy-pasted into impact() and
detect_changes() — two meta probes, two near-identical prose blocks,
and two long comments restating the same reasoning. Now one
`streamedIndexRiskDisclosure()` helper carrying the explanation once;
each caller passes only the clause naming which count is structurally
zero for it. Same file, 45 lines in / 45 out, with the duplication gone.
3. The sink's file header had grown into a changelog of my own review
corrections ('this once assumed', 'review measured'). A reader does not
care what an earlier draft believed. Rewritten to state the design
argument once — relationships are ~83% of graph heap, so they are what
streams; nodes are the other 17% and are scanned, so they stay — under
headings, with the honest 'this is an estimate, ~1.7-2.2x, no real-repo
measurement yet' caveat kept in full.
Refs #2680
* feat(analyze): make streamed graph emit the default, with nothing traded away
Streaming was opt-in because it disabled the four phases that consume the
whole CALLS graph — communities, processes, taintSummaries, callSummaries.
That made it unshippable as a default: query() is process-grouped and
clusters/skill-gen are community-backed, so every index would have silently
lost them.
The sink now answers a COMPLETE relationship read. It keeps streamed edges
as four parallel columns over an interned node table — sourceId, targetId,
type, confidence — and iterRelationships/iterRelationshipsByType/
forEachRelationship/relationshipCount return the retained edges
concatenated with those. Every consumer therefore sees the whole graph and
no phase knows streaming happened.
Four fields, not six, because an audit showed community-processor,
process-processor, taint-summaries and the pruner read only those — none
keys on rel.id. That matters: relationship ids are unique long strings, and
retaining them is precisely what made a fully-columnar attempt LOSE to the
object graph (measured 838 MB vs 822 MB). Ids stay out of the columns; a
read synthesizes one, which is safe because buildRelRow never persists it.
Consequently deleted, not merely disabled:
- the four enabledWhen gates and the 'what you give up' warning;
- the pruner's hasStreamedSemanticEdge predicate and its plumbing — a
complete scan sees streamed edges, so the dangling-edge hazard is gone by
construction rather than by compensation;
- the whole degraded-index apparatus: the graphPhases RepoMeta stamp,
streamedIndexRiskDisclosure, and the riskUnderstated markers on impact()
and detect_changes(). Nothing degrades, so nothing needs disclosing.
Default is ON for full rebuilds; GITNEXUS_STREAM_GRAPH_EMIT=0 (or an
explicit option) is the escape hatch, for bisecting a suspected
streaming fault rather than routine use. Incremental runs still refuse it —
the writeback reads relationships back out of the in-memory graph.
Measured A/B, 400k nodes / 1.08M edges, all edges streamable (worst case
for this design): 823 MB -> 626 MB, ~1.3x, all 1.08M edges still visible.
That is deliberately less than the ~2.9x the retained-share formula
implies — losslessness costs the dedup Set and the columns. The earlier,
bigger number was bought by disabling phases. README and the file header
both state 1.3x measured; neither claims O(chunk).
New coverage: reads are complete (proven discriminating — 3 tests fail when
the streamed leg is removed), endpoints/confidence survive the round trip,
per-type lookup finds streamed types, and every CALLS-consuming phase stays
registered under the flag.
Refs #2680
* docs(2680): pin the invariants the default-on change relies on
Review follow-ups. No behaviour change except the id-uniqueness fix.
- pipeline.ts returns the RAW graph, not the sink, and that is load-bearing:
phases read the sink so their scans are complete, but loadGraphToLbug feeds
this value to streamAllCSVsToDisk, whose iterator would then emit every
streamed edge a SECOND time on top of the per-pair CSVs the sink already
wrote. Returning the sink there silently doubles every streamed
relationship in the persisted graph, so the reason is now written down at
the return site.
- Synthesized ids now carry the column index, making them unique even when
two streamed edges share (type, source, target) and differ only in
reason/step. Harmless today because no consumer keys on relationship id,
but real ids are unique and the synthesized ones should match, so a future
id-keyed consumer cannot silently collapse two edges.
- Recorded WHY dropping reason/step is safe, which is not the same argument
as for id: the persisted row keeps their true values because buildRelRow
receives the original relationship on the way through, so only in-memory
reads see the 'streamed' placeholder. The ACCESSES reason:'read'|'write'
distinction that MCP queries depend on therefore survives in the database.
A future in-pipeline consumer needing either field must add a column rather
than trust the placeholder.
Also verified while chasing a review lead: removeNodesByFile has no
production callers and removeNode has exactly one (the pruner), which reads
through the sink and so sees streamed edges. The dangling-edge hazard the
deleted hasStreamedSemanticEdge predicate used to compensate for is closed
by construction, not by luck.
Refs #2680
* fix(2680): fail loudly on a missing CSV dir, and guard the retain set
Resolves both findings from the review of this branch.
MEDIUM — pipeline.ts silently skipped streaming when `streamGraphEmit` was
true but `graphEmitCsvDir` was absent. The CLI always supplies the dir, but
streaming is on by DEFAULT now, and the callers that build PipelineOptions
themselves (eval-server, MCP daemon, tests) are exactly the ones that would
omit it — so they would ask for streaming, not get it, and still see a
successful run. That is the silent-degraded-outcome shape the rest of this
work exists to prevent, so it now throws with the resolution hint. Covered by
a test asserting the rejection.
LOW — RETAINED_REL_TYPES had no automated guard, and the round-trip test
structurally cannot be one: addRelationship PARTITIONS edges between the
graph and the CSVs, and a partition's union is invariant under where the line
falls, so that test stays green for any partitioning including a wrong one.
Drift there yields a silently incomplete mid-pipeline edge set, not a crash.
Added a test that derives the required set by grepping every literal
iterRelationshipsByType('X') under src/ and asserts the constant covers it,
with CALLS as the documented exemption (taintSummaries reads it, which is why
the sink answers a complete read rather than retaining it). Proven
discriminating: removing EXTENDS from the constant fails with
"expected [ 'EXTENDS' ] to deeply equal []".
128 tests green across the eight affected suites, including the index-lock
suite that arrived with the #2677 merge.
Refs #2680
* docs(2680): record the measured CPU cost, not just the memory win
I measured memory before shipping and never measured time, which was a gap:
reads now allocate, rebuilding objects instead of returning stored ones, and
a real analyze does SIX full relationship scans (pruner, communities x2,
processes x2, the taint fixpoint's CALLS pass).
Same 400k-node / 1.08M-edge graph:
heap 820 MB -> 623 MB (1.32x better)
scans 96 ms -> 651 ms (6.8x WORSE)
6.8x on iteration is worth knowing, but the absolute number decides it:
~0.5 s here, ~2 s extrapolated to kernel scale, against an analyze measured
in minutes — under 1% of wall-clock. The ~26M short-lived objects at kernel
scale are young-generation churn (the cheap case), and being ~800 MB further
from the heap ceiling matters more than the churn costs: #2649's cascade came
from GC thrash NEAR the limit, not from allocation volume as such.
Also names the first lever if these scans ever go hot — a per-type index over
the columns, so iterRelationshipsByType stops scanning all streamed edges —
and notes that it trades memory back, so it needs a measurement first.
Refs #2680
* perf(2680): cut the iteration regression from 6.8x to 1.8x
The memory win came with an unmeasured CPU cost. Iteration went from
returning stored objects to rebuilding them, across the SIX full relationship
scans an analyze performs (pruner, communities x2, processes x2, taint's CALLS
pass). First measurement: 90 ms -> 651 ms, 6.8x worse. Fixed properly rather
than documented away.
Two causes, each measured before and after:
1. The ~150-character synthesized `id` was built eagerly on every read — 6.5M
concatenations per analyze, for a field NO in-pipeline consumer reads.
Isolating it (constant id) showed 436 ms of the 555 ms regression. Now a
lazy prototype getter on a fixed-shape `StreamedRelationship` class: the
string is built only if someone asks, and V8 keeps one hidden class across
millions of instances.
2. Generator and iterator-protocol overhead on million-edge walks.
`forEachRelationship` (community detection's form, called twice) now loops
the columns directly, skipping both. `iterRelationships` keeps an iterator
but reuses one result record — a hand-rolled version allocating a fresh
{value, done} per edge measured WORSE than the generator (252 ms), which is
why the obvious rewrite is not the one that shipped.
heap 821 MB -> 623 MB (1.32x better)
scans 90 ms -> 180 ms (was 651 ms)
The residual ~90 ms is object allocation, 6.5M instances across six scans, and
it is irreducible while the read API returns objects at all. The remaining fix
for true parity is a field-wise callback passing sourceId/targetId/type/
confidence as primitives — all four hot consumers read only those — but that
changes the KnowledgeGraph interface and its consumers, so it belongs in its
own measured change rather than bolted on here.
Refs #2680
* perf(2680): zero-allocation field scan brings iteration back to parity
Third and final step on the iteration cost. The memory win had come with a
6.8x iteration regression; the previous commit cut that to 1.8x by making the
synthesized id lazy and removing generator overhead. The residual was object
allocation itself — 6.5M instances across the six full relationship scans an
analyze performs — which no amount of tuning removes while the read API hands
back objects.
So the hot consumers stop asking for objects. Adds
`KnowledgeGraph.forEachRelationshipFields`, which passes
(sourceId, targetId, type, confidence) as primitives — exactly and only what
every whole-graph scan reads. On the sink those come straight out of the
columns, allocating nothing; on the object-based graph they are read off the
stored relationship, so the flag-off path is unaffected.
Converted the five whole-graph scans: community detection (x2), process
extraction (x2), and the local-symbol pruner. `isFileDefinesEdge` now takes
(type, sourceId) rather than a relationship. The taint fixpoint's by-type pass
is left alone — one scan of six, and converting it would turn an indexed
bucket lookup into a full scan on the object-based graph.
heap 820 MB -> 623 MB (1.32x better)
scans ~82 ms -> ~90 ms (was 651 ms; now parity within noise)
Also deletes the pruner's `hasStreamedSemanticEdge` option, which has had no
caller since the sink's reads became complete — a dead knob is worse than no
knob.
Verified: 104 tests across the eight affected suites, including the pruner's
pipeline integration test (which needs the raised worker-ready timeout on this
host; it passes cleanly with it and its failures are the known 5s handshake).
Refs #2680
* perf(2680): compact dedup keys — 1.32x -> 1.59x, speed unchanged
An audit of where duplicate relationship ids actually come from, then the
saving it unlocked.
The audit (instrumented analyze of this repo): 25 duplicate-id hits across
63,412 streamed edges — 0.04%, all CALLS, every one the SAME call site
re-emitted when a file is resolved in more than one language pass. Three
things follow, and they rule out the cheap options:
- dedup cannot be dropped (25 != 0, and a duplicate reaching COPY is a wrong
graph);
- it cannot move to row contents, because emit-references builds ids as
`...->target:line:col`, so two calls between the same pair at different sites
have byte-identical CSV rows that the whole-graph emit keeps;
- it cannot move to a per-file source guard like `pdgEmittedFiles`, because a
later language pass can resolve genuinely NEW edges for the same file.
What was left was the key itself. An id embeds both node ids in full (~200
chars here) while the endpoints are ALREADY interned for the columns, so the
Set was storing them twice. Keys are now built from the interner indices plus
the id's trailing disambiguator parsed into NUMBERS.
Numbers, not substrings, and that is load-bearing: a key built by slicing
inside a long string is a V8 sliced/cons string that keeps its parent alive, so
the id would never be freed and the saving would silently fail to appear. An
earlier attempt at this measured no improvement for exactly that reason.
Unrecognized id shapes (`rel:contains:` has no tail) fall back to storing the
id verbatim — correctness first, saving second.
heap 821 MB -> 518 MB (1.59x, was 1.32x)
scans ~83 ms -> ~88 ms (parity, unchanged)
Speed is untouched by construction: dedup is on the WRITE path, and none of
the six full scans reads it.
Also fixes removeRelationship, which the test suite caught: it looked up the
raw id in a Set that now holds compact keys, so it silently stopped throwing on
an already-streamed edge. It cannot recompute a key from a bare id, so it is
now conservative — anything the real graph does not hold is treated as
possibly-streamed once streaming has begun and fails loudly. A genuinely-absent
id throws where main returns false; acceptable because the only production
caller (the COBOL resolver) runs before the sink is armed.
89 tests green across the six affected suites.
Refs #2680
* fix(2680): dedup key dropped edges when tail segment counts differed
Both findings from the review of this branch, and the coverage gap named
alongside them.
HIGH — the compact dedup key packed the id's trailing numeric segments as
`|${a}|${b}`, with `b` defaulting to 0 when only one segment was present and
the segment COUNT absent from the key. So `:7` and `:7:0` produced the same
key and the second edge was silently discarded as a duplicate: a lost
relationship, no error, no warning. Found by probe, not by reading — two
distinct ids for one (source, target, type) went in and one edge came out.
The key now carries `seen`.
Nothing existing caught it. The round-trip test compares the UNION of graph
and CSV rows, and a dropped edge is missing from both, so it stayed green;
the duplicate test only feeds a genuinely identical id, which is the case
that SHOULD collapse. Four new cases pin the boundary instead: differing
segment counts stay distinct, two call sites between one pair stay distinct
(the `:line:col` shape from emit-references), a truly repeated id still
collapses, and a non-numeric tail falls back to the full id. Proven
discriminating — reverting the fix fails with "expected 1 to be 2".
This costs ~66 MB at 400k nodes / 1.08M edges (584 MB, was 518 MB), so the
heap win is 1.40x rather than 1.59x. Not a trade worth making the other way:
a silently missing relationship is the exact failure class the rest of this
work exists to prevent. I am not asserting a mechanism for why two extra
characters per key cost that much — it is stable and reproducible across
runs, and inventing a cause is how I got the earlier cons-string diagnosis
wrong.
LOW — removeRelationship throws for an absent id once streaming has begun,
where KnowledgeGraph.removeRelationship returns false. The behaviour is
deliberate (a bare id cannot be turned back into a compact key, and answering
"false" for an edge already on disk is the worse failure) but it was
undocumented and untested. Now stated on the interface itself and pinned by
two cases: absent-id-while-streaming throws, absent-id-before-streaming
returns false.
Coverage gap — added a test asserting forEachRelationshipFields yields the
same (source, target, type, confidence) tuples as iterRelationships. That
guards the five whole-graph scans converted in 9fa18384, where a divergence
would silently skew community detection, process extraction and the pruner.
Also records the verified scaling in the file header: linear at 100k/200k/
400k/800k nodes, per-edge scan cost flat at ~13 ns in both arms, heap ratio
drifting only 1.7x -> 1.5x as interner indices gain digits. No super-linear
term.
135 tests green across the eight affected suites.
Refs #2680
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* fix(analyzer): case-stabilize runner-identity path fields so status isn't false-stale (#2668)
`gitnexus status` reported a freshly-analyzed, untouched repo as stale on
Windows (econia/aptos-core, 1.6.10-aptos.0). `status`'s up-to-date check gates
on `runnerIdentityIsCurrent`, which deep-compares the stamped runner identity
against a freshly recomputed one. That comparison includes `build.rootPath`,
`dependencyRuntime.manifestPath`/`lockfilePath`, and `runtime.executablePath`
(only `invokedArtifact` is stripped), and `identityCacheKey` hashes
packageRoot/buildRoot — all derived from paths that flow through
`realpathSync.native`, which canonicalizes 8.3 names and symlinks but does NOT
normalize the Windows drive-letter case. When `analyze` and `status` are
launched under different drive-letter casing (`c:\...` vs `C:\...`, plausible
across CLI shim / npx / server-worker entries), the two identities differ by
that one byte and `status` reports stale.
Fix: `normalizeAnalyzerRootPath(p, platform)` uppercases the Windows drive
letter (POSIX no-op, platform-explicit for testability; preserves a `\\?\`
extended-length prefix), applied at the single upstream source —
`resolveBuildRoot`'s returned `{packageRoot, buildRoot}` — so every derived
identity path field and the cache key inherit a case-stable root, plus at
`runtime.executablePath` (process.execPath is the same compared class). The
`runnerIdentityIsCurrent` gate is kept intact: a genuine analyzer change still
differs in `build.digest`/`dependencyRuntime`, and analyze still rebuilds on
real mismatch.
Note: the drive-letter divergence was not reproduced on a Windows host (none
available); the mechanical chain is verified in source and the fix is a correct
defensive normalization that is a no-op on POSIX. If a `status --json` identity
field-diff later shows `build.digest`/`dependencyRuntime`/`cliVersion`
diverging instead, that indicates a genuinely different install (where "stale"
is correct), not this bug.
Migration: on Windows, an existing index stamped under the old (non-normalized)
casing mismatches the normalized recompute once, triggering a single forced
full re-analyze on first upgrade (and a one-time identity-cache recompute).
One-time, Windows-only, POSIX no-op.
Tests: pure `normalizeAnalyzerRootPath` unit tests (drive-letter uppercase,
idempotence, drive-only scope, `\\?\` extended-length prefix, POSIX no-op).
* feat(mcp): surface index staleness in query/context/impact/cypher tool responses (#2655)
`checkStalenessAsync` already computes how many commits an index is behind the
checkout's HEAD, and `list_repos` returns it as `staleness: {commitsBehind,
hint}`. But the four hot read tools an agent actually calls in a session —
`query`, `context`, `impact`, `cypher` — never surfaced it: `resolveRepo` only
runs `maybeWarnSiblingDrift` (stderr, sibling-clone drift only), so a direct
tool call gave zero indication the index might be behind HEAD.
Thread the existing signal into those four tools at the single `callTool`
dispatch chokepoint (after the one `resolveRepo`), reusing the `list_repos`
`{commitsBehind, hint}` shape:
- `stalenessForTool` computes `checkStalenessAsync` behind an in-flight-promise
cache (5s TTL) keyed by lbugPath, so N concurrent tool calls share one
`git rev-list` and flat/branch handles (same repoPath, different lastCommit)
don't collide. The cache entry is evicted with the repo's other per-index
state when the repo leaves the registry.
- `withToolStaleness` skips the `git` spawn entirely for results that can't
carry the field (via `canCarryStaleness`), so error-returning calls pay
nothing.
- `attachToolStaleness` adds a `staleness` field to an object result only when
the index is behind HEAD. It NEVER changes an existing result's shape:
raw-array results (non-tabular cypher rows) are returned untouched, because
the CLI's `--limit` and other consumers branch on `Array.isArray`; error
envelopes and already-annotated results are left as-is. Non-blocking:
`checkStalenessAsync` swallows git failures to `{isStale:false}`, so a git
error just omits the field — it never fails the tool.
Deliberately out of scope: `@group`-targeted calls forward to
`callToolAtGroupRepo` before the chokepoint (multi-repo, single-commit
staleness is ill-defined); the legacy `search`/`explore` aliases; and
`list_repos` / the `context` resource, which already carry the signal.
Tests: `attachToolStaleness` branch matrix (stale object -> field; fresh ->
unchanged; raw array -> unchanged; error envelope -> unchanged; idempotent;
non-object -> unchanged; null-safe) and a flat-vs-branch cache-key regression
test that fails when the cache is keyed by repoPath.
* test(mcp): cover staleness tool-signal edge cases + harden the freshness boundary (#2655)
Addresses the coverage gaps the review flagged on the #2655 staleness signal,
plus one defensive guard so a failing freshness check can never fail a tool.
Production (defense-in-depth, no behavior change on the happy path):
- withToolStaleness now awaits stalenessForTool with a `.catch(() => undefined)`
so a rejection degrades to no-staleness instead of failing query/cypher/
context/impact.
- stalenessForTool wraps the check in `Promise.resolve(...).catch(...)` that
evicts the cache entry on rejection — a transient failure isn't served as a
permanently-rejecting promise for the rest of the TTL window, and the
`Promise.resolve` wrap makes the boundary robust to a non-thenable return
(a no-op for the real async checkStalenessAsync). A resolving promise is
never evicted, so happy-path dedup is unchanged.
Tests (gitnexus/test/unit/calltool-dispatch.test.ts):
- F1: a rejecting checkStalenessAsync leaves the tool payload intact with no
staleness field, and a later call recovers (proves the entry isn't poisoned).
Written first and confirmed to fail without the guard.
- F2: staleness attaches on query/context/impact object results and on cypher's
tabular {markdown,row_count}; a raw-array cypher result keeps its shape.
- F3: drift guard — exactly query/cypher/context/impact route through
stalenessForTool; explain/pdg_query/detect_changes/check do not.
- F4: the per-index cache dedupes within TOOL_STALENESS_TTL_MS and recomputes
after it expires (driven via a Date.now spy, not fake timers).
Tests (gitnexus/test/unit/analyzer-identity.test.ts):
- F5: the produced identity's build.rootPath and runtime.executablePath are
normalizer-stable, guarding that both call sites thread through
normalizeAnalyzerRootPath (trivial on POSIX, a real regression guard on
Windows CI). Plus a source comment noting the one-time Windows re-analyze on
first upgrade.
* test(mcp): run #2668 guard on Windows CI, document staleness field, cover staleness edge cases
Addresses the review follow-ups on the staleness work:
- Wire test/unit/analyzer-identity.test.ts into scripts/cross-platform-tests.ts
(PLATFORM_LOGIC). Its "identity path fields are normalizer-stable" fixpoint is
the Windows regression guard for the #2668 drive-letter normalization, but
normalizeAnalyzerRootPath is a POSIX no-op, so the guard was only ever running
(trivially green) on the Ubuntu full-suite and never on the windows-latest
matrix where it actually bites. Now it runs where it matters.
- Document the inline `staleness` field on query/context/impact/cypher responses
in the gitnexus-guide skill (both the .claude source and the shipped
gitnexus-claude-plugin mirror, kept in sync).
- Add three staleness tests that pin behavior the prior tests only implied:
* @group-routed calls never get the signal (forwarded before the wrapping
switch) — locks the intentional skip so it can't silently flip.
* one in-flight freshness check is shared across truly concurrent calls
(two dispatched before checkStalenessAsync settles → a single spawn), not
just sequential reuse of an already-resolved value.
* a late rejection from a superseded cache entry does not evict the newer
entry that replaced it after the TTL rolled over (the `=== entry`
object-identity guard).
The defensive stack in stalenessForTool/withToolStaleness (Promise.resolve
wrap + guarded evict + outer catch) is retained deliberately: the wrap is
load-bearing for the tests (a sibling describe's vi.resetAllMocks() makes the
mock return undefined), and the guarded evict closes the superseded-entry edge
now covered above.
* fix(test): split the #2668 normalization guard into a portable cross-platform file
Registering analyzer-identity.test.ts on the Windows/macOS matrix (previous
commit) surfaced four pre-existing failures in that file on macOS 3/3 and
windows 3/3. They are not new breakage: those fixture tests compare identity
fields against the RAW temp-dir path while the identity resolves through
realpathSync.native, so on macOS `/var/folders/...` is received as
`/private/var/folders/...`. The file was simply never portable — it had only
ever run in the Ubuntu full-suite. Reproduced locally by pointing TMPDIR at a
symlink: the same four tests fail, and pass again without it.
Move only the portable assertions — the pure `normalizeAnalyzerRootPath` cases
(explicit `platform` argument) and the identity fixpoint guard (which compares
each field against ITSELF normalized, never against the fixture path) — into
test/unit/analyzer-identity-path-normalization.test.ts, and register that file
on the matrix instead. The #2668 Windows regression guard still runs where it
actually bites, without dragging four symlink-sensitive tests onto runners they
were never written for.
Verified: the new file passes with TMPDIR behind a symlink (the macOS
condition); the heavy file is back to Ubuntu-only.
* fix(test): keep the cross-platform #2668 file fixture-free so Windows stays green
The split file still carried the fixture-based fixpoint guard, which fails on
windows-latest:
Invoked analyzer artifact is absent from the validated build:
D:\a\...\node_modules\vitest\dist\workers\forks.js
Cause is a pre-existing cross-drive defect in this module's `isInside()`, not the
#2668 change. The GH Windows runner keeps the repo on D: and temp fixtures on C:.
`path.win32.relative('C:\\...fixture', 'D:\\...forks.js')` cannot express a
relative path across drives, so it returns the absolute target — which does not
start with '..', so `isInside()` reports true. `resolveInvokedArtifact` therefore
treats the vitest fork worker as the invoked artifact, it is absent from the
fixture's validated build, and identity resolution throws. (Verified directly:
`isInside` returns true cross-drive and false for the same-drive control.)
Keep the cross-platform file strictly pure — only `normalizeAnalyzerRootPath`
assertions with an explicit `platform` argument, no fixture and no filesystem —
so it is green on every runner while still exercising the transform on real
Windows. The fixture-based threading guard moves back to analyzer-identity.test.ts
(Ubuntu-only), where the rest of that file's fixture tests already live, with a
comment recording why it cannot be on the matrix.
The underlying `isInside()` cross-drive bug is left untouched here (out of scope
for this PR) but is worth its own fix: it also guards the trusted cache directory
and the identity-cache path-escape check in validateIdentityCache, where a false
"inside" verdict weakens validation on multi-drive Windows setups.
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn
Adds a new `--self-commit` flag to `gitnexus analyze`. When passed, any
AGENTS.md/CLAUDE.md changes the run makes (including first-time creation)
are auto-committed, scoped to only those two files (never `git add -A`).
No-ops silently if neither exists, neither changed, or the repo has no
git identity configured — never fails the surrounding analyze run.
Complements #1478 (--no-stats): that flag removes the volatile counts
entirely, this one keeps them but eliminates the dangling working-tree
diff they otherwise leave behind on every run.
Closes#2639.
* fix(analyze): log a warning when --self-commit fails to commit
Addresses review feedback on #2640: the commit step's catch block was
silently swallowing failures (e.g. missing git identity) with no signal
to the user. Logs via the existing pino logger (matching the rest of
the codebase's convention) with the error and the file list, while
still never throwing — analyze must not fail over this.
New test forces a real commit failure (missing identity, with
useConfigOnly + isolated HOME/XDG_CONFIG_HOME/GIT_CONFIG_NOSYSTEM so no
ambient global git config on the CI runner can mask it) and asserts the
warning is captured via logger's _captureLogger test hook.
* fix(analyze): refuse to sweep pre-existing edits into --self-commit
Addresses both state-safety blockers from review round 2 on #2640:
1. selfCommitContextFiles could not distinguish a pre-existing unstaged
user edit in AGENTS.md/CLAUDE.md from this run's generated stats
refresh — both just showed up as "the file is dirty" — so a user
edit sitting in either file got silently swept into the generated
commit. Fixed by snapshotting each candidate's cleanliness via the
new snapshotSelfCommitSafety() BEFORE analyze writes to it; only
files confirmed safe (nonexistent pre-run, i.e. first-time creation,
or clean pre-run) are ever added/committed. A file already dirty
pre-run is skipped and logged, never touched.
2. On a failed `git commit` (e.g. missing identity), the preceding
`git add` had already staged the safe files, and analyze reported
nothing happened while silently leaving them staged. Fixed with a
`git reset -- <safe files>` in the commit-failure catch, restoring
the index to its pre-add state for exactly the files this helper
staged.
Wired analyze.ts to call snapshotSelfCommitSafety() once before
runFullAnalysis (which is where the actual AGENTS.md/CLAUDE.md write
happens, on both the fast path and the primary run), threading the
result through both existing selfCommitContextFiles() call sites.
New tests: a pre-dirty AGENTS.md is skipped while a clean CLAUDE.md
still commits normally, and a post-add commit failure leaves nothing
staged. Updated all existing selfCommitContextFiles() call sites for
the new required safety-map parameter.
* i18n(cli): add zh-CN translation for --self-commit help text
Addresses magyargergo's follow-up on #2640: --self-commit was missing
from the analyze command's OPTION_DESCRIPTION_KEYS map, so its help
text never went through localizeCliHelp and always rendered in English
regardless of locale. Adds the help.option.analyze.selfCommit key to
both en.ts and zh-CN.ts and wires it into help-i18n.ts, matching the
existing --no-stats/--skills entries.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* Initial plan
* fix(scope-resolution): gate C# and Kotlin free calls
* fix(scope-resolution): keep Kotlin ownership gate safe
* Apply remaining changes
* perf(scope-resolution): benchmark and cache ownership gates
* test(scope-resolution): simplify benchmark scaling loop
* refactor(scope-resolution): encapsulate ownership cache
* test(scope-resolution): enforce subquadratic ownership scaling
* fix(scope-resolution): address ownership review findings
* test(csharp): regenerate capture golden for #2563 fixtures
The committed expected-captures.json was missing the new
NamespaceOwnerCollision.cs entry and carried a stale SameFileCases.cs
digest/count (56 → 67), so csharp-captures-golden.test.ts was the sole
red check on the PR. Regenerate with UPDATE_GOLDEN=1 to match the
fixtures the bench fingerprint already reflects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Initial plan
* docs(plans): add Java local class naming plan
* fix(java): model local class binary names
* docs(java): clarify local class naming guards
* fix(java): recognize local classes in compact constructors
* chore: remove Java naming plan
* fix(java): harden local type identities and scope
* perf(java): linearize local type ordinal allocation
* fix(java): harden ordinal benchmark follow-up
* docs(java): clarify ordinal benchmark invariants
* test(java): cover local type ownership paths
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(rust): latch duplicate type-name ambiguity in range binding (#2514)
The range-binding prepass tracked cross-file return and field types in two
maps and used map presence itself as the ambiguity flag: the second definition
of a name deleted it, but a third definition found it absent and re-inserted
the last-scanned file's type. Odd duplicate counts (3, 5, ...) therefore
resolved a genuinely ambiguous name to whichever file was scanned last, while
even counts stayed ambiguous.
Latch ambiguity in a dedicated Set per registry (ambiguousReturnTypes,
ambiguousFieldTypes): once a name has two or more workspace definitions it
never resolves again, regardless of duplicate count or file order.
Adds integration coverage for two/three-duplicate functions and structs,
permuted file order, and a unique-name over-suppression guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rust): bump INCREMENTAL_SCHEMA_VERSION to 12 for the #2514 range-binding fix
The duplicate-name ambiguity latch changes which cross-file Rust CALLS edges
the range-binding prepass emits. The incremental writeback persists only
changed-file nodes, so an incremental top-up against a pre-v12 index would keep
the old spurious edges on every unchanged Rust file. Bump the schema version to
force a one-time full re-analyze, matching the v7/v11 contract for
edge-affecting resolver changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(rust): resolve import-disambiguated duplicate types in for-loops & destructuring
Follow-up to the #2514 ambiguity latch. When several modules define the same
function/struct name and a call site disambiguates it with a `use` import
(including aliases and `use x::*` globs), range-binding now resolves the
for-loop element type and the destructured field type to that specific imported
definition, instead of leaving it unresolved.
The bare-name return/field maps are (correctly) ambiguous for duplicates, but
the call site's import pins a definition. range-binding records the full,
untruncated return/field type per defining file, and resolveImportedDef()
resolves a name to the single in-scope definition, mirroring Rust name
resolution:
- tier 1: explicit `use`/re-export imports and local defs (lookupBindingsAt);
these shadow globs, so if any exist we decide within them alone;
- tier 2: glob imports, consulted only when tier 1 is empty; a
`wildcard-expanded` ImportEdge names the target module, so we resolve only
when exactly one glob-target file actually defines the name.
Two or more visible definitions stay unresolved, preserving the #2514 latch.
normalizeRustReturnType is untouched (its Vec<T> -> Vec truncation is
load-bearing for receiver resolution), so the full generic is read from the
per-file map instead.
Covered by integration tests: explicit / aliased / single-glob imports resolve
to the imported definition; two globs that both export the name stay ambiguous;
a local definition shadows a glob; no-import duplicates stay unresolved (#2514).
INCREMENTAL_SCHEMA_VERSION stays at 12 (bumped by the #2514 commit in this PR);
its note now also covers these added resolution edges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(rust): parse each file once in range-binding when the workspace fits a budget
populateRustRangeBindings makes two passes over every file and, because the
shared treeCache is empty in the analyze flow, re-parsed each file in both — a
workspace of N files paid 2N parses. It now parses each file once and reuses the
tree across both passes via an in-function store, gated by a source-byte budget:
workspaces up to 16 MiB of Rust source (essentially every real repo) reuse
trees; larger ones fall back to per-pass re-parsing so peak RSS stays bounded on
huge repos (the memory-sensitive case keeps its current profile).
Also collapses the parse+timeout boilerplate that was copy-pasted in both loops
into one getOrParseTree helper, and adds a PROF-gated `rangeBind=` segment to
the scope-resolution profiler for phase-level observability.
Measured on a 500-file synthetic Rust workspace (PROF_SCOPE_RESOLUTION=1): the
range-binding phase drops ~370ms -> ~320ms (~14%), parses 1000 -> 500. Behavior
is unchanged (199 rust + range-binding-order + parse-timeout tests green); repos
above the budget are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(rust): update schema-version gate to v12; regenerate golden + bench baseline for new fixtures
CI surfaced three deterministic-artifact failures, all from this PR's own additions:
- call-summary-schema-version.test.ts hardcoded INCREMENTAL_SCHEMA_VERSION === 11
(the #2604 window); #2514 bumped it to 12. Update the gate and extend the
reuse-gate version history so a v11 stamp now forces a full re-analyze.
- rust-captures-golden expected-captures.json drifted (130 -> 174 entries) because
the new rust-import-* / rust-dup-* fixtures joined the rust-* corpus. Regenerated
(UPDATE_GOLDEN=1): additions only, no existing captures changed — emitRustScopeCaptures
is untouched.
- bench/scope-capture/baselines.json rust fingerprint drifted for the same reason.
Rebaselined with a provenance note; scaling 1.06 < 1.5 budget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli): cover analyzer lazy-action native-load failure (#2441)
createAnalyzerLbugLazyAction — the wrapper the `analyze` command uses — had
only a happy-path test; its native-load-failure branch was untested, so a
regression could silently reintroduce #2441 (analyze exiting 0 after a
LadybugDB native load failure, writing no index while reporting success).
Add a failure-path test asserting that when checkLbugNative() reports the
binary cannot load, the analyzer module is NOT imported, process.exitCode is
set to 1, and the repair message is written to stderr. Mirrors the existing
createLbugLazyAction failure test.
Verified discriminating: the test fails ("expected undefined to be 1") when
the exitCode guard is removed from the analyzer branch, and passes with it
restored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): probe LadybugDB native load out-of-process so a truncated binary fails closed (#2441)
checkLbugNative() loaded lbugjs.node in-process to validate it. That catches
clean load failures (missing dylib, zero-byte, garbage -> "file too short"),
but a merely truncated/corrupted binary (valid header, missing pages) SIGBUSes
the dynamic loader mid-dlopen — a signal, not a catchable throw — taking the
whole CLI down with a raw exit 135 and no guidance.
Load the binary in a throwaway child process instead. Only a child that RAN and
failed (non-zero exit or a fatal signal) marks the binary bad; if the probe
itself could not run — a spawn error or timeout, e.g. a no-subprocess sandbox
or a non-Node execPath — the result is inconclusive and the command's own load
stays authoritative rather than condemning a healthy binary. The probe forces
ELECTRON_RUN_AS_NODE, removes the redundant in-process pre-load, and costs ~20ms.
Regression tests: truncated binary -> ok:false; unspawnable probe -> ok:true.
Verified: a 300KB-truncated native now exits 1 with the repair message
(previously exit 135 SIGBUS); zero-byte/garbage stay graceful; good native
still loads and indexes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dependabot bump to actions/setup-node@8207627860 (v7.0.0)
left the review-agent-workflow.test.ts pin allowlist pointing at the old v6.4.0 SHA, failing CI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(lbug): scale the buffer-pool budget by the OS-page discard-granule ratio (#2631)
LadybugDB bills buffer-pool budget per discard granule, not per 4 KiB frame:
the engine's vm_region.cpp sets discardGranuleSize = max(frameSize, osPageSize),
claimFrame charges the whole granule when its first frame becomes resident, and
releaseFrame refunds only when the granule's last frame leaves — while
BufferManager::reserve measures eviction progress in refunded bytes and throws
'The buffer pool is full and no memory could be freed!' after three zero-refund
passes. On a 64 KiB-page kernel (Ascend/aarch64 openEuler — the #2631
reporter's host) that is 16 frames per granule: the same COPY bills up to 16×
the budget it needs on x86, and whole eviction passes can evict frames yet
refund nothing. Apple Silicon macOS (16 KiB pages) is the same mechanism at 4×.
Measured with the reporter's exact command and version: vllm-ascend needs a
(128, 256] MiB pool on 4 KiB pages — 64/128 MiB reproduce the reporter's
byte-identical error, 256 MiB and the 576 MiB adaptive pool succeed — so their
64 KiB host cannot survive on a page-size-blind budget.
Scale every derived pool size by granuleRatio = max(1, osPageSize/4096):
the per-element estimate, the COPY-safety floor, and the default cap (still
bounded by 80% of RAM). 4 KiB hosts are byte-identical to before — proven by
pinning the existing sizing tests to an explicit 4096 page size, which also
stops them drifting on 16 KiB Apple Silicon runners. GITNEXUS_LBUG_BUFFER_POOL_SIZE
keeps absolute precedence and 0 still restores the native default.
Also: bufferPoolExhaustionRemedy() gives the exhaustion error an actionable
cause→consequence→remedy message; the isLbugPageSizeFrameError comment that
called pool exhaustion 'a sizing problem, not a page-size one' is corrected —
that framing inverted when #2582 made pool size a function of a page-size-blind
estimate. Cannot execute on a 64 KiB kernel here: the scaled path is proven by
unit stubs plus the engine-source math above; the env override remains the
field escape hatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cli): actionable pool-exhaustion remedies at the COPY sites and a doctor pool line (#2631)
The node-COPY throw and the relationship-COPY warning now append
bufferPoolExhaustionRemedy() when the failure is the engine's pool-exhaustion
class: the raw binder text gave the operator nothing to act on, and on
non-4K-page hosts the pool bills up to pageSize/4KiB × faster than the sizing
was calibrated for. The relationship path appends the remedy once per bulk
load, not once per failed pair. doctor prints the effective pool size next to
the page-size line ('pool size 2048 MiB', with an '(×N page-size scaling)'
suffix on non-4K hosts) so support triage sees the sizing inputs at a glance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lbug): re-anchor getEffectiveBufferPoolSize's placement and reuse granuleRatio in doctor
Self-review fixes: the getter's insertion had orphaned resolveBufferManagerSize's
doc comment (it read as documenting the wrong function), and doctor's scale note
duplicated the granule math with a hardcoded 4096. granuleRatio is now exported
(it already carried the test-seam default param) and doctor consumes it.
No behavioral change — the sizing suite pins byte-identical outputs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lbug): keep the hintless pool default unscaled and make both remedies visible (#2631)
Review fixes:
- Scale only the analyze-path cap (scaledAnalyzePoolCap), not
defaultBufferPoolSize: the pool is an eager native allocation at DB open
(measured, see POOL_BYTES_PER_ELEMENT), so a page-size-scaled hintless
default would hand a long-lived MCP process up to 80% of RAM — the #2557
OOM exposure the 2 GiB cap removed. Fix the MAP_NORESERVE claim that
contradicted that measurement.
- Log the rel-pair pool remedy (loadGraphToLbug returns warnings that no
call site reads) and dedup it with a local boolean instead of matching
the remedy's own wording.
- Label the GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 sentinel as the native
80%-of-RAM default in both the remedy and doctor instead of '0 MiB'.
- Extract poolSizeDoctorLine (pageSizeDoctorLines convention): mark env
overrides, drop the scaling suffix that misdescribed absolute values.
- Fold _resetOsPageSizeCacheForTest into _setOsPageSizeForTests(undefined).
- Document the analyze-path scaling in both README env tables.
---------
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LadybugDB ≤0.18.2 mis-evaluated `r.type IN [...]` on relationship table
groups: the boolean-filter fallback skipped writing selection buffers for
single-row unflat chunks, dropping/duplicating callers in context() and
impact() (upstream LadybugDB#692, fixed by LadybugDB#699, shipped in
0.18.3). Floor the dependency at ^0.18.3 and lock core + all five platform
packages.
Resurrect the caller-identity regression test from PR #2553 (closed as
superseded by the upstream fix): it pins context()/impact() to exact
caller IDs across CodeRelation sub-table pairs so any future predicate
regression fails loudly. Note: with CREATE-seeded data the test also
passes on 0.18.2 (the upstream repro needs COPY-written chunk layouts) —
it is a behavioural pin, not a bug reproduction.
Fixes#2508
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(lbug): add ensureEmbeddingRowDmlSafe VECTOR gate for embedding-row DML
LadybugDB refuses every mutation of a table carrying an HNSW index while the
VECTOR extension is not loaded on that connection: DELETE and CREATE raise a
Binder exception, DROP TABLE is refused while the index references it, and SET
segfaults the process. Dropping the index is not an available recovery either —
CALL DROP_VECTOR_INDEX is itself a VECTOR-extension function and is undefined in
exactly that state.
Add a single primitive that loads VECTOR under the analyze install policy and,
only when that fails, reads CALL SHOW_INDEXES (which works without the
extension) to decide whether an index actually exists to trip over. No call
sites yet.
Refs #2623
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(lbug): pin the #2623 VECTOR gate for embedding-row DML
Three cases: no index + VECTOR unavailable stays safe (no needless
escalation); index present + VECTOR unavailable is reported blocked AND the
raw deleteNodesForFiles genuinely throws 'extension is not loaded' (proving the
hazard is real, not theoretical); index present + VECTOR loadable is safe, the
delete works, and the HNSW index survives — the invariant run-analyze relies on
when it keeps the index across a surgical incremental run.
Refs #2623
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(analyze): load VECTOR before the incremental writeback touches embedding rows
Incremental analyze died on every content change once a repo had built
code_embedding_idx:
Analysis failed: Binder exception: Trying to delete from an index on table
CodeEmbedding but its extension is not loaded.
The surgical writeback's first statement is deleteNodesForFiles' CodeEmbedding
join-delete, but nothing on that path loaded VECTOR until Phase 4 — so the
engine refused the delete. This is an ordering defect, not an environment one:
it reproduces on machines where VECTOR loads fine. The dirty-flag recovery then
forced a full rebuild on the next run, which is why it read as 'just slow'.
Call ensureEmbeddingRowDmlSafe() once, before the escalation gate and before any
row is touched — the same 'index lifecycle before row DML' seam dropSearchFTSIndexes
occupies for FTS (#2589). Unconditional, because a DB carrying the index from an
earlier --embeddings run hits the same wall on a plain incremental run. When
VECTOR truly cannot load the table is immutable (the index cannot be dropped
without the extension either), so the run falls through to the existing
wipe-and-COPY escalation with a message naming cause, consequence and remedy.
Fixes#2623
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(analyze): pin the #2623 VECTOR-before-embedding-DML ordering end-to-end
Sibling of the #2589 FTS drop-before-delete suite, same shape: drive the real
runFullAnalysis incremental path over a real git repo and a real LadybugDB,
seed real embedding rows, build the HNSW index, then assert the index state at
the exact moment deleteNodesForFiles is invoked.
Both cases were confirmed to discriminate — with the run-analyze change
reverted they fail with the reported 'Trying to delete from an index on table
CodeEmbedding but its extension is not loaded', and pass with it:
- surgical path: the run completes, the index is still present AND
extension_loaded at delete time, exactly one row per nodeId survives, and
the untouched file's rows are preserved
- blocked path: with GITNEXUS_LBUG_EXTENSION_INSTALL=never the run escalates
to a full DB write and says so, instead of crashing
Also applies prettier's reindent to the run-analyze log ternary.
Refs #2623
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(lbug): cite the pinned LadybugDB version in the #2623 probe note
The probe matrix behind ensureEmbeddingRowDmlSafe was first recorded on
0.18.0, but gitnexus/package-lock.json pins 0.18.2 (#2587). Re-ran every case
on 0.18.2: refused DELETE, refused CREATE, SIGSEGV on SET, DROP_VECTOR_INDEX
undefined, DROP TABLE refused, SHOW_INDEXES readable with extension_loaded
intact. Identical on both, so the design is unchanged — only the citation was
wrong.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(analyze): preserve embeddings across the VECTOR-blocked rebuild, and check the catalog before loading
Three follow-ups from reviewing the fix itself.
1. Data loss on the blocked path. Escalating wipes the DB files, and Phase 3.5
restores embedding rows from cachedEmbeddings — which deriveEmbeddingMode
only populates when meta.stats.embeddings > 0. A DB holding embedding rows
that its meta does not account for therefore had every vector destroyed
silently by a rebuild it never asked for. Probe on a 3-file repo: 3 rows
before, 0 after, no warning. Read the rows before escalating (a plain MATCH,
no extension needed) so the existing restore has something to restore, and
say so in the log. The blocked-path test now asserts the seeded rows survive
exactly once, and that assertion fails without this rescue.
2. Catalog before extension. ensureEmbeddingRowDmlSafe loaded VECTOR first and
only read SHOW_INDEXES on failure, so every incremental analyze on a machine
without VECTOR paid a bounded out-of-process INSTALL attempt plus an
'extension unavailable' warning — including repos that never built an
embedding index and can never hit this bug. One local catalog read settles
that case first; the load is attempted only when an index actually gates DML,
or when the catalog cannot be read.
3. Dead branch. targetConn is always the module singleton there, so the
isSharedSingletonConn ternary could never take its second arm. Collapsed to
withConnLock.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(doctor): live-probe the VECTOR extension instead of printing the static platform capability
Review finding on #2624 (MEDIUM), and exactly what #2623's reporter hit:
doctor printed 'VECTOR index: available' — derived from a static platform
check — while every incremental analyze on the same machine was dying on an
unloaded VECTOR extension. The FTS line was switched to a live LOAD probe for
the identical contradiction under #2374; VECTOR now gets the same treatment.
probeVectorExtensionLoad shares the FTS probe's implementation (bounded,
offline-safe, never runs the installer) and doctor's semantic-mode line now
follows the probe, not the platform: without a loadable extension the vector
index can be neither built nor queried, so search really is on exact scan.
The load-error classifier's remedies are label-parameterized so the VECTOR row
stops dispensing FTS-specific advice — 'run analyze --repair-fts' repairs FTS
indexes only and was actively wrong for a missing vector extension. Default
label stays 'FTS'; every existing caller and pinned remedy string is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lbug): remove the stale Windows VECTOR gate — the extension ships for win_amd64
The codebase categorically refused VECTOR on Windows (platform !== 'win32' in
isVectorExtensionSupportedByPlatform, plus a hard early-return in
loadVectorExtension) on the strength of an early-era report that in-process
INSTALL VECTOR could SIGSEGV (#1365). That belief is stale, verified directly:
- the extension server hosts win_amd64 VECTOR artifacts for every 0.18.x
extension version — v0.18.0 and v0.18.1 both serve a real 14 MB PE32+ DLL
(curl-probed; 'file' confirms PE32+ x86-64)
- the pinned 0.18.2 core resolves its extension directory to 0.18.1
(strace-verified LOAD open()), so the pinned version's Windows artifact
exists too
- INSTALL now runs in a spawned child (installDuckDbExtensionOutOfProcess), so
even a crashing installer kills only the child and degrades to unavailable —
the original hazard cannot reach the parent process any more
Windows now takes the same runtime path as every other OS: try LOAD, install
out-of-process when policy allows, degrade to exact scan when it truly fails.
The MCP semantic-search lane loses its static platform gate too — it always
attempts the vector index and falls back to the exact scan on runtime failure,
with a once-per-backend diagnostic naming the real error instead of a
platform-policy message. isVectorExtensionSupportedByPlatform is deleted;
getRuntimeCapabilities reports the platform capability as available everywhere
and defers machine truth to the live probe.
Windows CI is the enforcement: the vector suites skip visibly only when the
extension genuinely cannot load, so green Windows lanes now actually exercise
VECTOR instead of silently skipping by policy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(lbug): pin the catalog-read-failure fallback in ensureEmbeddingRowDmlSafe
Review finding on #2624 (LOW): the one branch where the gate cannot cheaply
prove safety — SHOW_INDEXES itself erroring — was exercised only by inference.
Force it with a Connection.prototype.query spy over the real DB: the catalog
read fails, and the gate must fall through to actually attempting the
extension load (asserted via the recorded statement stream) rather than
guessing, returning true here because the extension is loadable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): load VECTOR on the pool's shared Database so the semantic vector lane actually works
Review finding on #2624 (MEDIUM): extension load scope is per-Database
(probe-verified — LOAD on one connection enables QUERY_VECTOR_INDEX on every
connection of the same Database), and the pool pre-warm loaded only FTS. So
LocalBackend's vector lane has ALWAYS raised 'Catalog exception: function
QUERY_VECTOR_INDEX is not defined' through the pool and silently fallen back
to the exact scan — repos above the 10k exact-scan cap got empty semantic
results. The serve path was unaffected (the embedding pipeline loads the
extension itself).
Mirror the FTS line at BOTH load sites — doInitLbug's pre-warm and
initLbugWithDb's external-Database adoption — under the same load-only
contract (the read pool never triggers a network install), tracked by a new
SharedDB.vectorLoaded flag reset where ftsLoaded resets.
The new pool test is discriminating and deliberately closes the writable core
adapter before the pool opens: a shared/injected Database would inherit the
VECTOR load from test seeding and pass either way, so the case forces the pool
onto its OWN fresh read-only Database where only the pre-warm can make the
lane legal. Verified: fails at the pre-fix tree with the exact Catalog
exception, passes with the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: run the #2623 ordering suite on Windows/macOS and pre-install VECTOR alongside FTS
Two review findings on #2624, both landing in existing seams:
- scripts/cross-platform-tests.ts gains incremental-vector-extension-ordering
.test.ts: the win32 VECTOR gate is gone in this PR, so the #2623
drop-ordering + blocked-path escalation must be proven on the
windows-latest native addon, not just Ubuntu. (The review's claim that
lbug-delete-nodes-for-files.test.ts was also missing was wrong — it has
been on the roster since #2409.)
- scripts/ensure-fts.ts now pre-installs VECTOR under the same best-effort
auto-policy contract, so every sharded CI process LOADs from ~/.lbdb
instead of racing its own bounded out-of-process INSTALL; the workflow's
extension cache already covers it (path is the whole extension dir — key
kept for cache continuity). The cross-platform job sets
GITNEXUS_REQUIRE_VECTOR=1 beside GITNEXUS_REQUIRE_FTS so a genuinely
unavailable VECTOR is a loud failure, never a silent skip.
Windows/macOS cannot be executed locally; the PR's CI lanes are the proof for
this commit. Linux smoke: ensure-fts.ts reports both extensions ready; all 79
roster entries resolve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(pool): register loadVectorExtension in the pool unit-suite mocks
The pool adapter's new loadVectorExtension import surfaced in four suites that
mock lbug-adapter.js with explicit factories (vitest fails loudly on a missing
mocked export). Register the export in each — resolving false where the
suite's world assumes no vector, true where it mirrors FTS — and extend
lbug-pool-fts-load.test.ts, the suite that owns pre-warm extension loading,
with the vector pair: successful load cached per shared Database, failed load
retried on the next open, both pinned to policy load-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(analyze): use POSIX literals for graph paths in the #2623 ordering suite
First Windows CI run of this suite (it joined the cross-platform roster this
PR) failed with 'Parser exception: Invalid input <MATCH (n:Function) WHERE
n.filePath = '>' — path.join produces backslashes on Windows, and a backslash
inside the seed helper's single-quoted Cypher literal breaks the parser. The
graph stores repo-relative filePaths with forward slashes on every OS, so
graph-side paths are POSIX literals now (the incremental-orchestration
convention); path.join stays only for real filesystem access.
The same Windows lane also proved the substance this suite exists for:
lbug-vector-extension passed 7/7 on windows-latest — the extension installed,
loaded, and built a real HNSW index there — and the pool vector-lane and DML
gate suites passed too. This commit fixes the harness, not the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): give vitest a writable .vite-temp inside read-only dependency mounts
Every task verify command and every hidden oracle ends in `npx vitest run
<test>`, and both run through run_verify with read_only_workspace=True. Vite
transpiles a TypeScript config by writing
<node_modules>/.vite-temp/<config>.timestamp-*.mjs before it loads anything, so
against a read-only dependency mount vitest dies with EROFS before a single
test executes:
EROFS ... /workspace/gitnexus/node_modules/.vite-temp/vitest.config.ts.timestamp-*.mjs
This is pre-existing and was masked: until #2627 the verify command died at
`npx: not found`, short-circuiting the `&&` chain before vitest ran. Confirmed
by reproducing it at that merge base with npx bypassed entirely
(`./node_modules/.bin/vitest`), so it is independent of the node-prefix mount.
Because it blocks the oracle as well as the authored-test verify, `resolved`
stays 0/N without this.
bwrap cannot create a mount point inside an already-read-only bind -- the same
constraint that put SANDBOX_NODE under /opt/claude -- so overlaying a tmpfs only
works if the directory already exists in the mounted bytes. It cannot be
mkdir'd into the dependency snapshot after capture either: the snapshot is
digest-bound and validate_dependency_binding fails closed on drift. So the empty
directory is captured during dependency capture, before the manifest and both
dependency digests are computed, making it part of the snapshot rather than an
untracked mutation of it. The sandbox then overlays a tmpfs on exactly that
path; everything else in the mount, and the whole workspace, stays read-only,
and the overlay never reaches the host clone the credited patch comes from.
Scoped to dependency mounts whose target basename is node_modules, so hidden
oracle and skill mounts stay wholly read-only with no writable island.
Note: this shifts sandbox_dependency_content_digest and
sandbox_dependency_manifest_digest, so promotion evidence recorded before this
change is no longer comparable. That is already true of any harness fix that
changes what the sandbox exposes.
Verified on the self-hosted runner through the real path -- TaskAssetCache
.prepare -> stage_task_assets -> prepare_sandbox -> run_verify with the actual
trivial-version-alias verify string: passed, 15/15 tests, no EROFS. Full eval
suite there with GITNEXUS_REQUIRE_BWRAP_CANARY=1: 337 passed, 4 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): only overlay .vite-temp where the mount source actually carries it
The tmpfs overlay keyed purely on the mount target basename being
node_modules, which also matched the trusted GitNexus runtime mount at
/opt/gitnexus/node_modules. That mount's source is the built runtime and does
not carry a .vite-temp, and bwrap cannot create a mount point inside an
already-read-only bind, so the containment CI job failed:
bwrap: Can't mkdir /opt/gitnexus/node_modules/.vite-temp: Read-only file system
FAILED test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout
My runner probe only exercised the dependency-mount path, so it missed this.
Gate the overlay on the mount SOURCE actually containing the directory rather
than on the target name. task_assets.py captures .vite-temp only into
dependency-snapshot node_modules, so the overlay now fires exactly there and
never on the runtime mount -- and the gate is correct by construction, since a
tmpfs can only overlay a mount point that already exists in the bound bytes.
Adds a regression test for a node_modules mount whose source has no captured
.vite-temp (the runtime-mount shape) getting no overlay, and updates the
positive test to create the directory in its mount source.
Verified on the self-hosted runner: the exact failing test now passes, and the
full containment selection is 124 passed, 4 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): ignore Claude Code bootstrap noise nested below the workspace root
The planning-phase boundary check excluded Claude Code's own sandbox-bootstrap
paths only at the workspace root: workspace_snapshot tested relative.parts[0]
against WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE. But Claude Code bootstraps into
whatever directory it is running in, and the benchmark's task prompts cd into
gitnexus/, so the same noise landed one level down as
gitnexus/.claude/.cc-writes -- whose parts[0] is "gitnexus", so it was never
excluded.
In skill-evolution run 29861768554 that accounted for 13 of 18 sessions, each
failing with error_kind plan-evidence-invalid and the identical error_detail
"phase changed unauthorized workspace path(s): gitnexus/.claude/.cc-writes".
The same code path also guards the review phase (runner.py:499), so review arms
hit it as review-evidence-invalid.
Widening the whole set to match at any depth would be wrong: it also contains
package.json, package-lock.json, node_modules and the .env family, and both
gitnexus/package.json and gitnexus/.claude/settings.local.json are real tracked
files whose edits must still be caught. So the root-anchored rule is unchanged,
and a second narrow rule matches only the entries Claude Code itself creates
inside a .claude directory (.cc-writes, agents, commands) at any depth -- never
.claude itself.
The predicate moves into _is_bootstrap_noise so it is directly testable. It is
still evaluated before pending.append, so an excluded directory is never
descended into.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): mount the node install prefix so npx and npm resolve in the sandbox
_runtime_mount_args bound only the `node` binary itself to SANDBOX_NODE. npm
and npx are not standalone binaries -- they are symlinks into
../lib/node_modules/npm/bin/*-cli.js -- so the install prefix carrying both
bin/ and lib/node_modules has to be mounted for them to resolve at all.
On GitHub-hosted images node lives in /usr/local/bin, whose prefix (/usr/local)
is already inside the wholesale /usr read-only bind, so npm and npx came along
for free and the gap stayed invisible. A self-hosted runner's actions/setup-node
installs into its own tool cache, outside /usr, so only the single node file was
bound. Every task's verify command is "cd gitnexus && npx tsc --noEmit && npx
vitest run <test>", so in skill-evolution run 29861768554 all 18 of 18 result
records carried the identical verify_output "/bin/sh: 1: npx: not found" -- no
run could resolve regardless of model output. It reached the model too: the
session transcripts show 12 "npm: not found" failures, with
gitnexus/scripts/build.js dying on `npm ci` with status 127.
Binds Path(node_bin).resolve().parent.parent read-only at /opt/claude/nodejs,
a fresh target outside the already-read-only trees (same constraint that put
SANDBOX_NODE under /opt/claude), and adds its bin/ to SANDBOX_PATH. The bind is
skipped when the prefix already sits inside /usr, /bin, /lib or /lib64, so the
already-covered case does not widen the mount surface redundantly.
SANDBOX_NODE is deliberately unchanged -- sanitized_graph.py and
runner_sessions.py invoke it directly. SANDBOX_PATH is now derived from
SANDBOX_NODE_PREFIX so the two cannot drift, and the minimal-mounts probe
asserts against the constant instead of a duplicated literal.
The real-Bubblewrap npx canary lives in test_proposer_sandbox.py deliberately:
test_workflow_bench.py pins the set of files carrying the canary marker, and it
runs in the eval-containment-linux job, where actions/setup-node also installs
into the tool cache -- so the canary exercises the real failure shape.
Combines plan steps 3-5 into one commit: the mount, SANDBOX_PATH and the pinned
probe assertion are one behavioural change, and splitting them would leave a
commit whose asserted PATH disagrees with the mounted reality.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): only bind a verified node prefix, and stop excluding .claude/agents
Addresses two findings from the branch review of the two preceding commits.
1. The prefix was derived as Path(node_bin).resolve().parent.parent with no
check that the layout is really <prefix>/bin/node. Probed: /opt/bin/node
bound ALL of /opt (every tool cache on a hosted runner), /mnt/tools/node
bound /mnt, and a bare <dir>/node bound <dir>'s parent. That last shape is
not hypothetical -- the pre-existing real-Bubblewrap node canary builds
exactly it (tmp_path/toolcache/node), so eval-containment-linux would have
silently read-only mounted the whole pytest tmp_path inside a containment
test, passing while doing it. This function exists to keep the sandbox
surface minimal, so an unrecognized layout now binds nothing extra and
simply leaves npx unavailable, exactly as before the mount was added.
2. CLAUDE_BOOTSTRAP_ENTRIES also excluded "agents" and "commands" on the theory
that they might appear nested too; only .cc-writes ever was observed. Every
excluded name is a blind spot: once a .claude directory exists
(gitnexus/.claude/settings.local.json is tracked) anything written under an
excluded entry is invisible to the phase-boundary check, and Claude Code
loads .claude/agents relative to its cwd -- which these tasks point at
gitnexus/. Probed: a planning phase could plant
gitnexus/.claude/agents/planted.md with the check reporting nothing, then
the work phase reads it. Narrowed to .cc-writes alone; extend the set from
an observed failure, never pre-emptively.
Re-probed after both fixes: the over-broad mounts are gone while a genuine
tool-cache prefix carrying npm still binds; planted agents/commands content is
caught again; gitnexus/.claude/.cc-writes (the real run-29861768554 failure)
stays ignored; and edits to gitnexus/.claude/settings.local.json are still
caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): gate the node-prefix bind on a working npx, not on an npm directory
The guard tested (prefix)/lib/node_modules/npm as a proxy for "this prefix
supplies npx". Test the property actually required instead: a working npx
sitting beside node in a real bin/ directory. .exists() follows the symlink, so
a dangling npx correctly fails the check -- it would not survive the mount
either. The "bin" name requirement stays, because it is what keeps the
parent.parent derivation honest; an npx sitting directly beside node in a flat
directory would make that derivation name the wrong prefix.
This matters because the guard can silently disable the fix it guards: if a
runner's layout failed the proxy check, the prefix would not be bound and npx
would still be missing, reproducing the original failure with no signal.
Testing npx directly means the guard can only pass when the bind will actually
achieve its purpose.
Validated against a real extracted Node distribution (the official nodejs.org
tarball layout that actions/setup-node unpacks into the tool cache) staged at a
tool-cache-shaped path: bin/node is a real file, bin/npx resolves to
../lib/node_modules/npm/bin/npx-cli.js, and the prefix binds while SANDBOX_NODE
is preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 5.0.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...5.0.0)
---
updated-dependencies:
- dependency-name: js-yaml
dependency-version: 5.0.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(spring-config): migrate YAML parsing to js-yaml 5 event API
js-yaml 5 removed the loadAll `listener` callback, the EventType/State
types, and DEFAULT_SCHEMA that spring-config relied on, breaking the build.
Rebuild the per-key line tree from parseEvents()/constructFromEvents()
(positions are source offsets → mapped to lines), apply the `<<` merge tag
via CORE_SCHEMA.withTags(mergeTag) (CORE alone leaves merge keys unexpanded),
and resolve aliases by anchor name, which lets the object-identity WeakMap go.
Behavior preserved: 9 unit + 8 integration spring-config tests pass, including
merged-key declaration-line, cyclic-alias termination, and the depth budget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(spring-config): restore v4 tag coverage, cover the v5 rewrite with tests
Review follow-up for #2618.
CORE_SCHEMA.withTags(mergeTag) was a narrowing, not a port: js-yaml 5
throws "unknown tag" on !!timestamp/!!binary/!!set/!!omap/!!pairs, and an
unknown tag aborts the whole parse, which readConfigKeys swallows — so an
application.yml using any of them would have gone from its full key set to
zero keys, silently. Carry the rest of what DEFAULT_SCHEMA was; none of
these tags can execute code.
Add tests for every path the review flagged as uncovered: multi-document
files, empty/comment-only/bare-`---`/bare-scalar documents, sequence-form
merge keys, and explicitly tagged values (which fail against the one-tag
schema, so they target the changed line).
Clear the anchor map per document. It cannot change output today —
constructFromEvents rejects a cross-document alias before the event tree is
built, now asserted — but it keeps both layers on YAML's scoping rule.
Drop the stale @types/js-yaml devDependency; js-yaml 5 ships its own types
and tsc --noEmit is clean without it. Lockfile hand-edited because npm
uninstall also strips every libc field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* fix(spring-config): flatten !!set members, walk YAML iteratively
Review follow-up for #2618.
js-yaml 5 constructs `!!set` as a native Set; v4 built a plain
`{member: null}` object. Object.entries of a Set is empty, so a tagged set
collapsed to a bare leaf key and lost every member. Enumerate the Set
instead. Sets arrive as mapping events with key/value scalar pairs, so
member lines resolve through the usual lookup. !!binary and !!timestamp are
unaffected — both are scalar events and take the leaf path, which is why a
Uint8Array never explodes into one key per byte.
Convert findYamlMappingLocation and flattenYamlValue from recursion to an
explicit stack. Children are pushed in reverse so pops happen in
declaration order, preserving "first match" and `out` insertion order;
`leave` frames release the cycle guard where the old `finally` did. The
depth budget still throws at the same boundary with the same message.
Cover the gaps the review named: !!pairs (both duplicate entries survive),
anchor-name reuse resolving to the nearest preceding declaration, and
marker-only leading documents staying index-aligned across the two streams.
buildYamlEventTree keeps no node budget by design — one node per event over
an already-materialized array, bounded by MAX_CONFIG_FILE_BYTES. The
docstring now says so rather than implying MAX_YAML_TRAVERSAL_NODES covers
it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Address review finding (LOW — docs/help staleness): the --skip-skills help
text and README omitted that skills also mirror to .agents/skills/ when
.agents/ exists.
- index.ts + i18n (en/zh): --skip-skills now reads "directly under
.claude/skills/ and .agents/skills/".
- skip-git-cli.test.ts: assert the help text covers .agents/skills/.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(lbug): re-open the read pool when analyze rebuilds the index under it
The MCP read pool's initLbug early-returned on an existing pool entry with no
freshness check, so after analyze rebuilt or mutated the on-disk index the
pool kept serving the old (POSIX: unlinked-but-open) inode until LRU/idle
eviction — a silent stale-read window of up to IDLE_TIMEOUT_MS (5 min).
Record the file identity {ino, mtimeMs, size} on each PoolEntry at open, and
re-stat in initLbug: unchanged → reuse; changed & idle → closeOne + reopen the
new file; changed while a query is in flight → serve the current handle (a
later idle initLbug reopens, since closing an in-use connection is a native
use-after-free). A stat failure (ENOENT during a full rebuild's unlink window)
is treated as unchanged so the reader keeps its valid open inode until the new
file appears. Mirrors the bridge cache's mtime-invalidation pattern.
Step 1 of docs/plans/2026-07-21-...-analyze-atomic-swap-invalidation. The
end-to-end reopen-on-swap path is exercised by the reader-during-rebuild
integration test in a later step.
* fix(analyze): publish a full rebuild via an atomic swap (POSIX)
The full-rebuild path wiped the live index (wipeLbugDbFiles(lbugPath)) and
rebuilt it in place, so a concurrent MCP reader that opened mid-build could
see an empty/half-loaded DB, and a crash between the wipe and the end-of-run
left the index destroyed (recoverable only by --force).
Build the fresh index at <lbugPath>.new and swap it over the live index in one
atomic rename at the end. All DB work flows through the singleton connection,
so only initLbug/wipeLbugDbFiles take the temp target; the close already
checkpoint-consolidates the build to a single file (verified: no residual
.wal/.shadow), so the rename publishes a complete index in one step. A reader
opening mid-build only ever sees the previous complete index; a reader holding
the old inode keeps a consistent stale snapshot until the pool re-opens onto
the new one (the pool staleness invalidation from the prior commit). On
failure the swap is skipped, leaving the previous index byte-for-byte intact.
POSIX only: the common CLI/serve-worker analyze paths skip the native close
(closeLbugBeforeExit, #2264) and leave the build handle open at swap time.
POSIX renames an open file cleanly; a same-process open handle blocks the
rename on Windows, so Windows keeps the current in-place behavior
(buildPath === lbugPath) until that is resolved. The Windows atomic swap and a
deterministic concurrent reader-during-rebuild test are deferred follow-ups.
Steps 2b + partial 3 of docs/plans/2026-07-21-...-analyze-atomic-swap-invalidation.
Integration test asserts the no-temp-leak + inode-swap invariants and the
crash-safety guarantee (a load failure leaves the live index untouched).
* test(analyze): end-to-end read-pool reopen after an atomic swap
Adds the deferred reader-during-rebuild / pool-reopen integration test:
analyze v1 -> read pool serves it -> rebuild with a renamed function (atomic
swap) -> the same repoId's initLbug detects the swapped inode and re-opens the
pool onto the new index. Asserts the pool sees the renamed function and NOT the
stale v1 name, exercising #1 (invalidation) and #2 (swap) together end to end.
* fix(lbug): bound pooled read queries with setQueryTimeout
The read pool relied only on a JS-side Promise.race (QUERY_TIMEOUT_MS) that
frees the waiter but leaves the native call running. Set the engine-level
setQueryTimeout on every pooled connection so a pathological query is bounded
at the source too.
* fix(lbug): name the held-open cause for WAL checkpoint failures (#2599)
A WAL-checkpoint IO error that also carries a busy/lock signal means another
handle (a gitnexus mcp server, or this process's own reader) holds the store
open, not a disk fault. Add isLbugCheckpointBusyError (reusing the tested
isDbBusyError keyword set) and, when the checkpoint driver exhausts its retry
budget on such an error, annotate the surfaced error with the actionable
held-open cause instead of a raw IO string.
Note: overlaps in-flight work on repro/issue-2599-windows-wal-checkpoint;
bundled here at the maintainer's request.
* feat(analyze): opt-in atomic incremental + best-effort Windows swap
Extends the atomic-swap publish (POSIX full rebuild) to two more cases:
- Windows: the swap now applies when a real close is safe to release the build
handle before the rename — i.e. non-pdg runs (windowsSwapOk excludes --pdg,
the #2264 destructor-crash case), forcing a real close on the swap path.
UNVERIFIED on Windows (no Windows runner here); --pdg and any failure fall
back to today's in-place behavior, so it can never corrupt.
- Incremental (opt-in, GITNEXUS_ATOMIC_INCREMENTAL=1): copies the live index
into the temp, applies the incremental delete/writeback to the copy, and
swaps at the end. Off by default because the whole-file copy negates
incremental's speed premise — kept behind a flag pending a benchmark. The
escalation valve also targets the temp so an escalated write stays atomic.
Integration test covers the opt-in incremental path end to end (no temp leak,
the incremental change is reflected after the swap).
* refactor(lbug): centralize the read-pool + bridge open-retry budgets
The lbug-config retry registry documented the open/handle-release/query-time
budgets but the read pool's LOCK_RETRY_* (pool-adapter) and the bridge's
LBUG_OPEN_RETRY_* (group/bridge-db) kept private copies that could drift. Move
both into the registry as exported constants (POOL_OPEN_LOCK_RETRY_*,
BRIDGE_OPEN_RETRY_*) and alias the local names to them — one tuning surface,
no behavior change.
* fix: address CI regressions from the bundled follow-ups
- setQueryTimeout: guard the call so test doubles that don't model the engine
method don't break connection creation.
- atomic swap: skip the rename when the build produced no DB at buildPath (an
empty repo / mocked pipeline) instead of throwing ENOENT.
- #2599: don't wrap the checkpoint error in the driver (it hid the IO signature
the CLI's --wal-checkpoint-threshold hint keys on); name the held-open cause
at the CLI instead, beside that hint, keeping the original error intact.
- retry consolidation: revert to documentation-only — moving the pool/bridge
budgets into lbug-config broke every explicit lbug-config test mock. The
registry now catalogues all budgets with their in-file locations.
- analyze-wal-checkpoint-failure test: block both lbug.wal.checkpoint and
lbug.new.wal.checkpoint, since a full rebuild now checkpoints the temp.
* fix(analyze): publish the swap before stamping meta; identity-gate the reader (#2614 F1)
Review found a HIGH regression: the full-rebuild wrote the freshness stamp
(saveMeta, indexedAt=T_new) BEFORE the atomic swap, so a concurrent MCP reader
that reinited in the saveMeta->swap window opened the OLD inode, recorded
observed=T_new, and then never reinited again (ensureInitialized returns early
on 'current') — serving the pre-rebuild graph indefinitely. The build-into-temp
change inverted the pre-PR invariant that 'meta shows T_new' implied 'lbugPath
holds T_new data'.
Two coordinated fixes:
- run-analyze: move the final saveMeta AFTER the swap, so meta.indexedAt only
becomes visible once lbugPath resolves to the new inode. Verified nothing in
the span reads on-disk meta and registerRepo writes only the registry.
Leaving the dirty flag set across the swap also improves crash-safety.
- local-backend: the reader staleness gate now also compares the lbug file
IDENTITY (ino/mtime/size), reiniting on an inode change even when
meta.indexedAt is unchanged. This closes the swap-window latch and covers the
in-place incremental case — and is what actually makes the pool's dbIdentity
net reachable for the MCP reader (the indexedAt gate otherwise bypassed it).
* fix(lbug/analyze): WAL-aware incremental, residual-sidecar reconcile, Windows opt-in, #2599 anchor (#2614 F2-F4)
Review remediations:
- F3: gate atomic incremental on a CLEAN live index (inspectLbugSidecars) — the
main-file-only copy would drop an orphan .wal's delta; fall back to in-place.
- F4: on the swap, MOVE a residual <buildPath>.wal/.shadow beside the published
index (not orphan it) so a swallowed final checkpoint's delta is replayed.
- F2: record identity on the shared read-only Database and warn when a cached
handle is reused after its on-disk index was rebuilt while another consumer
holds it (unreachable via MCP — one consumer per lbugPath; a complete fix
needs per-inode handles, documented).
- Windows swap: opt-in (GITNEXUS_ATOMIC_WINDOWS_SWAP=1), default off — the
forced real close re-bets an unproven #2264 assumption and can't be verified
without a Windows runner, so the default Windows path stays in-place.
- #2599: anchor isLbugCheckpointBusyError to real held-open wording instead of
isDbBusyError's bare .includes('lock') over a message that embeds the DB path
(a repo under blockchain-app misclassified a disk fault as held-open).
- Docs: corrected retry-catalogue budgets (linear, not exp) and the
checkedOut>0 bound comment (load-bounded, not IDLE_TIMEOUT_MS).
* test(analyze): cover the production close path in the atomic swap (#2614 F5)
Adds a full-rebuild swap test with skipNativeCloseOnExit:true — the close path
the CLI and serve-worker actually ship (build handle left open at swap time),
distinct from the default real-close the other swap tests exercise. Asserts the
POSIX swap still publishes a single consolidated lbug with no .new temp and no
orphan sidecar.
* test(analyze): give the follow-up git commits an inline identity (CI fix)
The end-to-end reopen and atomic-incremental tests' second commits used a bare
`git commit`, which fails on CI runners with no global git identity (empty
ident name). makeRepo's initial commit already passes -c user.name/-c
user.email inline; apply the same to the rename/change commits. No code change.
* fix(mcp): route reader reinit through initLbug's active-query guard (#2614 review)
Review found an active-query retirement race: LocalBackend.ensureInitialized
detected an identity/stamp change and called closeLbug(poolKey) DIRECTLY, but
closeOne closes the shared Database at refCount 0 regardless of checked-out
connections. So a reader detecting the new generation could close the Database
a concurrent query is still executing on — a native use-after-free. This
bypassed the checkedOut>0 guard that initLbug itself has.
Fix (delegate, not close directly): initLbug now returns whether it actually
rolled the pool over; ensureInitialized calls initLbug (which serves the
current handle while a query is in flight and reopens only when idle) instead
of closeLbug. The observed IDENTITY is advanced only when the pool actually
reopened — if a query was in flight, the identity stays divergent and the
reopen retries on a later idle check rather than latching on the old handle.
The observed STAMP advances regardless so a same-file stamp change can't loop.
Old generation now stays alive until its in-flight queries drain (lazy
rollover); new requests during the busy window share the old handle until the
pool goes idle, then reopen. No parallel open-both-generations, but no UAF and
no stale latch.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
loadIgnoreRules is called once per repo, per language/contract
extractor during group sync -- an N-repo group fans out to 6+
extractors each calling it, turning an uncached execSync per call into
O(extractors x repos) blocking subprocess spawns for the exact
many-repos scenario #2606 describes.
Both getGitInfoExcludePath and getCoreExcludesFilePath resolve to the
same value for the same fromPath for the life of the process, so
memoize by fromPath in a process-lifetime Map. One-shot CLI runs are
unaffected by staleness; the long-lived MCP server would need explicit
invalidation if this becomes a real concern.
The first fully successful real workflow_dispatch run on the self-hosted
runner (https://github.com/abhigyanpatwari/GitNexus/actions/runs/29843028596)
still failed: 17/18 sessions hit error_kind plan-evidence-invalid with
"phase changed unauthorized workspace path(s): .claude/.cc-writes,
.claude/commands, .env, .env.development, ...".
Reproduced directly on the runner (SSM, matching the real sandbox settings
exactly, including enableWeakerNestedSandbox): a single trivial "say OK"
prompt -- no real task, no real API key even -- is enough to make Claude
Code create a synthetic package.json/lockfiles/node_modules, a full set
of .env variants, and .claude/agents, .claude/commands, .claude/.cc-writes
in the workspace on every single session. None of this is something the
model decided to write; it's Claude Code's own internal bootstrap for
running inside an already-sandboxed environment, and it happens
regardless of task or prompt.
enforce_phase_workspace (the planning-phase boundary check: verify the
plan session touched only its one plan doc) already excludes .git for
exactly this class of reason -- harness/tool noise, not substantive diff.
Extends the same exclusion to the empirically-observed bootstrap set.
workspace_snapshot has exactly one use (this check, confirmed via every
caller), so widening its exclusion list can't hide anything in some other
context that actually cares about these paths changing.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Replace the custom ~/.gitnexus/ignore file with the same two sources
real git itself consults for exactly this purpose (gitignore(5)):
- core.excludesFile: git's own all-repos global ignore file (defaults
to $XDG_CONFIG_HOME/git/ignore when unconfigured)
- $GIT_COMMON_DIR/info/exclude: per-repo, untracked, so it works
without push/commit access to the repo
Precedence mirrors git exactly (lowest to highest): core.excludesFile,
then info/exclude, then .gitignore, then .gitnexusignore -- each later
source can negate an earlier one via a `!pattern` line, same
last-match-wins semantics git itself uses.
Adds getCoreExcludesFilePath and getGitInfoExcludePath to git.ts,
following the same execSync + git-common-dir pattern as
getCanonicalRepoRoot. GITNEXUS_NO_GLOBAL_IGNORE (or noGlobalIgnore)
still skips both global sources, mirroring GITNEXUS_NO_GITIGNORE.
IgnoreService only read per-repo .gitignore/.gitnexusignore, so an
exclusion meant to apply across every indexed repo had to be repeated
per repo or hand-patched into node_modules (wiped on every upgrade).
loadIgnoreRules now also reads a global ignore file at
$GITNEXUS_HOME/ignore (default ~/.gitnexus/ignore), reusing the
existing global directory that already holds registry.json and
config.json. It is added first, so per-repo .gitignore/.gitnexusignore
rules can still negate it, mirroring the .gitignore -> .gitnexusignore
precedence already in place. GITNEXUS_NO_GLOBAL_IGNORE (or
noGlobalIgnore) skips it, mirroring GITNEXUS_NO_GITIGNORE.
GitNexus review-agent finding: stripDynBound's documented Box<dyn Trait>,
Rc/Arc<dyn Trait>, and auto-trait/lifetime bound-list (dyn Trait + Send)
shapes had no test anywhere — only the bare &dyn Trait parameter case was
exercised end-to-end. Add direct unit coverage on normalizeRustTypeName and
(via interpretRustTypeBinding) normalizeRustReturnType for these shapes.
call-summary-schema-version.test.ts pins INCREMENTAL_SCHEMA_VERSION as a
literal per bump, documenting the reuse-gate boundary for each version.
Update the "current" expectation to 11 and add the v10 pre-current case,
matching the v7/v8/v9/v10 precedent already in the file.
RUST_SCOPE_QUERY gained a function_signature_item capture, shifting the
capture fingerprint for every bench fixture with a required trait method.
Verified: node --import tsx bench/scope-capture/measure.mjs --check now
passes across all 14 languages (rust scaling 1.036 < 1.5 budget).
Addresses gitnexus-review-agent findings on PR #2608:
- MED: on a partial apply (a file's write throws), drop that file's edits
from total_edits/graph_edits/text_search_edits/changes so the reported
result describes what actually reached disk, not what was attempted. The
comprehensive enumeration otherwise let a failing file contribute its
entire line count as phantom 'applied' edits. failed_files still names
every dropped file. Counts are now derived once from the reported set.
- MED: hoist the word-boundary regexes out of the per-line loop (one compile
each instead of one per line), reused by the apply loop.
- LOW: apply loop reuses escapedOldName instead of recomputing the escape
formula inline (removes a preview/apply drift risk).
- Soften the in-code comment: enumeration gives per-call preview/apply
consistency; the pre-existing two-read TOCTOU (external write between
preview and apply) is out of scope and noted, not newly introduced.
Tests: add a mixed graph-ref + text_search multi-file case (asserts per-file
confidence and the never-downgrade guard, via a stubbed rg), and a
partial-write-failure case (asserts only landed files are reported). Assert
concrete graph_edits/text_search_edits splits, not just their sum.
* fix(eval): bind the resolved node to a fresh sandbox path, not one under /usr
#2607 bound the resolved `node` to /usr/local/bin/node, but that path lives
inside the /usr tree that _runtime_mount_args already read-only-binds
wholesale. The second real workflow_dispatch run on the self-hosted runner
(https://github.com/abhigyanpatwari/GitNexus/actions/runs/29840270554)
failed immediately in the bubblewrap preflight: "bwrap: Can't create file
at /usr/local/bin/node: Read-only file system" -- bwrap can't create a new
mount-point file inside a tree it already bound read-only when the real
path doesn't already exist there on the host, which is exactly the
self-hosted case this bind exists to fix.
Introduces SANDBOX_NODE (/opt/claude/node), a fresh path outside every
tree _runtime_mount_args binds, following the same pattern SANDBOX_CLAUDE
and SANDBOX_PYTHON3 already use. Updates the two real call sites
(sanitized_graph.py, runner_sessions.py) to use the constant instead of
the hardcoded literal, so the fix can't drift out of sync with itself
again, and re-exports it from runner.py alongside the other SANDBOX_*
names for the real-bwrap tests that reference it directly.
Adds a real-bwrap test (gated behind GITNEXUS_REQUIRE_BWRAP_CANARY, same
as the existing ones) that copies a real node binary to a path outside
every bound tree and actually launches bwrap against it -- an
argv-construction test alone can't catch a bwrap-level "Read-only file
system" error, only a real invocation can, and that's exactly the gap
that let #2607's version of this fix through review looking correct.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(eval): don't let the new real-bwrap test's node-mock break bwrap's own resolution
CI caught this immediately: the new test_real_bubblewrap_runs_node_from_outside_the_bound_trees
monkeypatched shutil.which to return None for anything but "node", but
prepare_sandbox's own bwrap/claude resolution (_resolve_executable) goes
through shutil.which too -- so the test broke bwrap discovery before the
sandbox it's supposed to exercise could even be built ("SandboxError:
required executable is unavailable: bwrap").
Delegate to the real shutil.which for every other name instead of
blanket-returning None.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
RUST_SCOPE_QUERY gained a function_signature_item capture (previous commit)
so abstract trait methods can now dispatch a CALLS edge through a &dyn
Trait receiver. The incremental write set only covers changed files, so a
top-up against a pre-v11 index would keep silently missing these edges for
every unchanged Rust trait file — same contract as v7/v10; force a full
re-analyze instead.
Expected drift from the query.ts change: abstract trait methods now emit a
scope + declaration capture, shifting captureGroups/digest for every rust-*
fixture containing a trait with a required (bodyless) method.
New minimal fixture (single trait + impl + &dyn Trait call site, no other
same-named callers) proves the dyn-dispatch CALLS edge discriminates: fails
against the pre-fix source (0 edges) and passes against the two preceding
commits' fix (exactly 1 edge, verified via the CLI analyze pipeline against
a standalone repo).
The existing rust-abstract-dispatch fixture was NOT extended for this,
deliberately: it already has other callers referencing the same method
names (process()'s repo.find()/save()/count()), and an existing resolution
fallback picks those up via simple-name matching regardless of receiver
type — masking this specific defect in the in-process test-pipeline path.
A dedicated, single-caller fixture keeps the regression test load-bearing.
Surfaced by the first real workflow_dispatch run on the self-hosted runner
(https://github.com/abhigyanpatwari/GitNexus/actions/runs/29836411744):
every session failed with error_kind infra-error, error_detail "bwrap:
execvp /usr/local/bin/node: No such file or directory", tripping the
outage-streak breaker after 5 consecutive failures.
sanitized_graph.py and runner_sessions.py invoke the sandboxed graph CLI
at the fixed path /usr/local/bin/node. _runtime_mount_args only binds
/usr, /bin, /lib, /lib64 wholesale, so that path resolves correctly when
node happens to live under /usr/local/bin on the host -- true on
GitHub-hosted runner images, but not on a self-hosted runner, where
actions/setup-node installs into its own tool-cache directory instead
(outside all four bound trees, so invisible to the sandbox regardless of
what PATH says on the host).
Fix lives entirely in the mount construction: resolve `node` via
shutil.which (correctly picks up wherever actions/setup-node put it,
since its tool-cache dir is already on PATH by the time this runs) and
bind it read-only to the same fixed sandbox path the two call sites
already expect. Neither call site needed to change. Backward compatible
with GitHub-hosted runners, where this resolves to the same path and
binds a harmless no-op self-mount.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
rename() reported total_edits from a partial enumeration (definition line
only, one-edit-per-graph-file then break, and text search that skipped any
file already covered by the graph) while the apply step does a whole-file
\boldName\b global replace on every touched file. When a private symbol's
definition and all its call sites live in one file, only the definition line
was reported (total_edits: 1) even though apply rewrote every occurrence, in
both dry-run and apply.
Rebuild changes/total_edits/graph_edits/text_search_edits from one file set:
classify each file to rewrite (definition + graph refs = graph confidence;
rg-only files = text_search, never downgrading a graph file), then enumerate
every matching line per file with apply's exact escaped global regex. The
reported edit list now equals what apply writes. Apply behavior is unchanged.
Adds a regression test reproducing the issue's single-file Rust case (def +
3 same-file call sites, empty graph): total_edits is 4 in both dry-run and
apply, and equals the replacements that land on disk.
fn foo(&self) -> T; (no body) parses as function_signature_item, a grammar
node distinct from function_item that RUST_SCOPE_QUERY never captured. An
abstract trait method therefore had no Function scope and no declaration,
so populateClassOwnedMembers never wired its ownerId to the trait's Class
scope — invisible to the CALLS-edge receiver-bound resolution pass even
after a receiver's type resolves to the trait correctly.
Together with the previous commit's dyn-stripping fix, a call through a
&dyn Trait parameter now emits a CALLS edge to the trait's method (#2604).
normalizeRustTypeName/normalizeRustReturnType stripped reference sigils,
pointer sigils, and smart-pointer wrappers but never the `dyn` keyword, so a
`&dyn Trait`-typed receiver normalized to the literal string "dyn Trait"
instead of "Trait" — an unmatchable name that silently broke every
downstream receiver-type lookup for trait-object dispatch.
Part of the #2604 fix (root cause has a second, independent half: abstract
trait methods are invisible to scope resolution until function_signature_item
is captured — next commit).
Two gitnexus-review-agent findings on PR #2602:
- MEDIUM: the bodied-constant MRO-to-host-enum path (a qualified call to an
inherited, non-overridden enum method) was claimed in a comment but never
tested. Add EnumConst.A.log() -> EnumConst.log#0, exercising E$N's
@reference.inherits MRO arm end to end.
- LOW: `bodiedName ?? hostEnum` conflated "body-less" with "name synthesis
failed on a bodied constant" (reachable only on malformed/error-recovery
trees), silently binding an overriding constant's receiver to the host
enum — a wrong edge instead of no edge. Switch to `isBodied ? bodiedName :
hostEnum` so a bodied constant binds ONLY to its E$N class, mirroring the
object_creation_expression branch's skip-on-synthesis-failure. Verified
output-neutral on the well-formed bench corpus.
Rebaseline the java scope-capture fingerprint (a822cef9 -> d04298a9): the
bench corpus IS test/fixtures/lang-resolution, so the new dispatchInherited
fixture method shifts it (+6 capture groups); the logic change contributes
nothing (confirmed by isolating the fixture-only fingerprint). java.test.ts
242 passed; measure.mjs --check PASS (14 languages); tsc/prettier/eslint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(eval): move skill-evolution to a self-hosted runner and fix the sandbox's Python 3 trust gap
GitHub-hosted runners hard-cap job execution at 6 hours, which is too
short once a benchmark session actually invokes Skill/MCP tools for
real (the --bare fix in #2584 means sessions no longer no-op). Move the
job onto a self-hosted runner (5-day cap instead) and document the
activation step in the workflow's own checklist.
Validating the self-hosted run surfaced a real bug: gitnexus-plan
sessions inside the bwrap sandbox failed with "planning must create or
modify exactly one plan artifact; observed 0". Root cause:
evidence-provenance.mjs's atomic plan-writer only trusts a Python 3
binary owned by root or by the current process. Inside this
--unshare-user sandbox only the calling uid is mapped (root isn't), so
the real, root-owned /usr/bin/python3 surfaces as the kernel's overflow
uid and gets correctly refused as untrusted. Fix: provision a small,
self-owned wrapper script (same pattern already used for
shell-prefix) that execs the real interpreter, so the sandbox has a
Python 3 candidate the existing trust check can actually accept --
without touching that security-sensitive validation logic at all.
Also add visibility so this class of failure isn't quiet next time:
report.md now shows why each row failed (error_kinds), not just
resolved 0/1, and the benchmark now exits non-zero when an incumbent
arm -- the currently-shipped skill -- resolves zero across every task,
since that reads as a broken harness rather than a normal candidate
miss.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(eval): close the broken_incumbent_arms zero-valid-runs gap; document runner exposure tradeoff
Addresses the two MEDIUM findings from the gitnexus-review-agent on this PR
(https://github.com/abhigyanpatwari/GitNexus/pull/2600#issuecomment-5033363096).
broken_incumbent_arms required valid_runs > 0 before flagging an incumbent,
so an incumbent that fails every run with an excluded-but-non-systemic
error_kind (e.g. evidence-unverified, which the outage-streak breaker
explicitly resets on rather than accumulates) never accumulated a single
valid run and sailed through silently -- the exact quiet no-promotion
outcome this guard exists to catch, and arguably worse than the
some-runs-resolved-zero case since here nothing completed at all.
aggregate() never marks an excluded/unverifiable row resolved=True, so
dropping the valid_runs requirement and checking resolved == 0 alone
correctly covers both cases. Added a test for exactly this all-excluded
scenario, which none of the existing three did.
Updated the workflow's own activation checklist to reflect what's actually
true now (the gitnexus-evolution environment's branch policy and the
self-hosted runner are both live, codified in infra/gitnexus-evolution/ in
a companion PR) and documented the exposure-window tradeoff the review
flagged: the runner is stopped between runs but not destroyed/recreated per
run, so it isn't fully ephemeral. Stopping already bounds the exposure
window to the job's own runtime on one day out of seven; full per-job
ephemeral provisioning is a deliberate non-goal for a job that runs at
most weekly, revisit if that changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(eval): remove public infra/ pointers from the activation checklist
PR #2603 (the Terraform codification this checklist pointed to) got closed
-- publishing the exact IAM roles, security group rules, and self-hosted
runner topology for a real, live AWS account isn't safe to do in a public
repo, even with no literal secrets or resource IDs in the diff. The
underlying AWS/GitHub setup is unaffected and still documented privately;
this just removes the now-dangling references to a directory that won't
exist in this repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* ci(actionlint): register the gitnexus-evolution self-hosted runner label
actionlint rejected `runs-on: [self-hosted, linux, x64, gitnexus-evolution]`
in gitnexus-skill-evolution.yml because it can't discover custom runner
labels. Register it in .github/actionlint.yaml so the Workflow Lint check
passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The enum-constant receiver-dispatch fix adds one @type-binding.* capture
per enum constant, so the java scope-capture fingerprint shifts
(85fc7af9 -> a822cef9). Pure capture-additive drift; no bench fixtures
added; scaling 1.024 < 1.5 budget. Verified `measure.mjs --check` passes
for all 14 languages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Calling a method on an enum-constant receiver (E.CONST.method()) emitted
no CALLS edge. The receiver "E.CONST" is a two-segment compound receiver;
resolveCompoundReceiverClass walks each dotted segment via the owning
class scope's typeBindings map, but enum constants had no typeBinding, so
the constant segment dead-ended and no target was ever resolved.
#2555/#2558 gave bodied constants a first-class synthesized E$N class with
an MRO that includes the host enum; this is the receiver-side follow-up.
synthesizeJavaAnonymousClassDeclarations now emits a class-scope
typeBinding for every enum constant's simple name -> its E$N class (bodied)
or the host enum itself (body-less), reusing the exact mechanism a field
declaration uses. The generic compound-receiver chain walk then resolves
E.CONST.method() with no change to any shared scope-resolution code.
Bodied dispatch (EnumConst.A.hook() -> EnumConst$1.hook#0) and body-less
inherited dispatch (Plain.A.m() -> Plain.m#0) are covered by new tests in
the existing java-enum-constant-body fixture; both were verified to fail
against the pre-fix tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverses the prior convention: gitnexus-plan/gitnexus-work plan documents
under docs/plans/ are working artifacts and no longer travel with the PR.
Drops the require-node-22.18 plan doc from tracking; the .gitignore now
ignores all of docs/. The workflow_bench snapshot features scan the
filesystem, not git-tracked status, so they are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs pins the
eval-containment-linux job's setup-node version; move it in lockstep with
the ci-tests.yml pin bumped to the 22.18 floor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module.registerHooks compat seam and the onnxruntime resolvers cited
the old '>=22.0.0' floor as the reason their sub-22.15 fallback was
reachable. With the floor now ^22.18.0 || >=24.11.0 (all >=22.15), every
supported runtime exposes the API; the fallback stays as defensive
handling for below-floor runtimes (engines is advisory, not
engine-strict). Comments only - no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the supported minimum raised to Node 22.18, retarget every lane and
pinned runtime that sat at a lower version so nothing builds or runs the
package on an unsupported (EBADENGINE-warning) Node:
- ci-tests.yml: node-floor-compat 22.14 -> 22.18.0 (name, comment, pin,
version assertion) so the floor gate guards the new minimum; its #2372
registerHooks failure mode cannot recur above 22.15. Containment-canary
pin 22.16.0 -> 22.18.0.
- gitnexus-review-agent.yml + the pinned review/canary runtime: the
reproducible runtime is version-locked in lockstep across
.github/{gitnexus-review-runtime,claude-canary-runtime}/package.json and
their lockfiles (engines), the workflow's node-version, its two
'node --version = v22.18.0' assertions, the lockfile-engines guard, and
NODE_VERSION. Moved all of them 22.16.0 -> 22.18.0.
- gitnexus-skill-evolution.yml: pinned runtime 22.16.0 -> 22.18.0.
- CONTRIBUTING.md prerequisite floor updated.
- review-agent-workflow.test.ts, which enforces the runtime lock, updated
to expect 22.18.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Babel 8 (devDep for the bench mutation oracle, pulled in by dependabot
previous floor (>=22.0.0), so every dev install on Node <22.18 emitted
nine EBADENGINE warnings. Rather than pin Babel back to 7, adopt Node
22.18+ as the supported minimum: set engines to ^22.18.0 || >=24.11.0,
matching Babel 8 exactly so the warnings resolve honestly with no
dependabot ignore needed.
@types/uuid@11 is a deprecated stub - uuid@14 ships its own types and no
tsconfig references it. Lockfile edited by hand (engines + @types/uuid
entry) to preserve the libc platform metadata a newer npm wrote;
verified consistent via npm ci (exit 0).
BREAKING CHANGE: the gitnexus package now requires Node ^22.18.0 || >=24.11.0
(previously >=22.0.0). Node 22.0-22.17 are no longer supported.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Anchor isBenignDropFtsIndexError to the START of the message
(startsWith, not includes) so a future genuine failure that merely
mentions "Binder exception" or "Catalog exception" mid-message can't
be misclassified as benign. New test proves the old substring match
would have swallowed such a message.
- incremental-fts-drop-ordering.test.ts: probe FTS availability once in
beforeAll and skip VISIBLY via ctx.skip() in beforeEach (matching the
withTestLbugDB/lbug-vector-extension convention) instead of a silent
console.warn+return inside the test body, which reported a false pass
with zero coverage of the ordering invariant when FTS was unavailable.
The post-first-run FTS-index-built check is now a hard assertion
instead of a second soft skip, since the beforeEach gate already
proved the extension loads.
CI caught this: adding the record_declaration capture legitimately
changes the pinned java capture fingerprint, same as every prior
capture-behavior change to this language (#2550, #2555). Rebaselined
following the established _rebaselined_* precedent; scaling ratio
1.059 stays well within the 1.5 budget.
Fixes#2589: incremental analyze intermittently crashed with "FTS
index 'file_fts' is inconsistent: term is missing during delete" after
markdown-only commits, and --repair-fts also failed in that state.
deleteNodesForFiles' batched DETACH DELETE ran against tables that
still carried the FTS index built at the end of the PREVIOUS analyze
run -- createSearchFTSIndexes only drops+rebuilds every index in Phase
3, well after that delete already ran. LadybugDB's FTS extension is
not proven to survive DML against an indexed table (its own docs never
demonstrate the sequence). Call the new dropSearchFTSIndexes() up front
in the non-escalated incremental branch, before deleteNodesForFiles --
Phase 3 still rebuilds every index from the final row set regardless.
New end-to-end test drives a real runFullAnalysis full+incremental
cycle and confirms it fails without this change (file_fts and 50
sibling indexes still present at delete time) and passes with it.
dropFTSIndex previously caught and discarded every DROP_FTS_INDEX error
unconditionally. Extract isBenignDropFtsIndexError, a pure classifier
for the two legitimate "nothing to drop" cases (Binder/Catalog
exceptions: index never created, or the FTS function isn't registered)
verified end-to-end against @ladybugdb/core 0.18.x's real conn.query()
error text. Anything else -- e.g. the Runtime exception "FTS index is
inconsistent" class from #2589 -- now rethrows instead of being masked,
so a corrupted index can no longer persist across analyze runs
undetected.
Pulls the existing per-index dropFTSIndex loop out into its own exported
function so the incremental writeback can drop FTS indexes up front,
before deleteNodesForFiles runs (#2589). No behavior change here —
createSearchFTSIndexes calls the new function and still rebuilds every
index afterward.
Review finding: the record_declaration container-node fix (894110bf)
makes previously-uncaptured Record nodes and HAS_METHOD edges appear
for the first time, but the incremental write set only covers changed
files. Without this bump, an existing index would silently keep
omitting the Record node and its HAS_METHOD edges for unchanged
record files after an ordinary incremental analyze.
Same contract as v7 (#2437/#2522) and the two closest precedents, v8
(#2550) and v9 (#2555), which bumped this constant for the identical
"model X as first-class node" class of change.
new Local().inner() bound the whole object_creation_expression as
@reference.receiver, so its raw source text ("new Local()") became the
receiver name. That text can never match a scope binding, so the call
silently fell through to name-only fallback resolution and could
resolve to an unrelated same-named method on a collision.
Normalize the receiver to the constructed type's simple name (reusing
javaBaseSimpleNameOf, already used for the anonymous-class inheritance
edge) so Case 2 (class-name / static receiver) in
receiver-bound-calls.ts resolves it via its normal MRO walk. Mirrors
the existing normalizePhpReceiver precedent in php/captures.ts - a
language-local capture rewrite, no shared-pipeline change.
JAVA_QUERIES had no @definition.record capture, unlike its
class_declaration/interface_declaration/enum_declaration siblings and
unlike CSHARP_QUERIES' own record_declaration pattern. A Java record's
container node was never created, so its HAS_METHOD edges were dropped
at persistence even though ownership resolution computed a valid
ownerId for its methods.
Downstream label mapping, the class-extractor config, the dispatch
table, and ownership reconciliation already treated 'Record' correctly
- this was purely a missing structure-phase capture.
Wrap the version-directory scan in try/catch so a transient FS error
(permission denial, an AV file lock on Windows, a directory vanishing
mid-scan) fails closed to null instead of throwing — matching the
original callers' contract, and safer across the Windows/macOS/Linux
CI matrix where these error modes differ.
Also drop the redundant USERPROFILE/HOME manual chain in
extension-binary-real.test.ts in favor of the repo's established
os.homedir() convention (already used ~15 other places here), which
Node resolves correctly per-OS and already honors env overrides.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resolveInstalledFtsExtension (extension-binary-real.test.ts) and
resolveSeedExtension (fts-extension-e2e.test.ts) both hardcoded the
on-disk FTS extension path as .lbdb/extension/<lbug.VERSION>/..., but
LadybugDB's native INSTALL/LOAD resolves its own extension-ABI version
directory, which does not always track the npm package version. Bumping
@ladybugdb/core from 0.18.1 to 0.18.2 in this PR still installs into a
0.18.1 directory, so both hardcoded lookups came up empty and failed
hard under GITNEXUS_REQUIRE_FTS=1 in CI (all platforms, shard 3).
Add findInstalledFtsExtension() to discover the real installed file by
scanning every version subdirectory, and use it from both test files.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: remove hardcoded 300-flows cap for large repositories
The dynamicMaxProcesses was capped at 300 via Math.min(300, ...),
causing large repositories (280K+ nodes) to lose execution flows.
Change: Remove the Math.min(300, ...) cap, keep dynamic calculation.
Effect: 280K-node repo: 300 → 1617 flows.
* test: add regression for dynamic maxProcesses sizing (#2198)
Verify that processProcesses honours maxProcesses > 300 without truncation.
Addresses the optional follow-up suggested by @koriyoshi2041.
* test: exercise computeDynamicMaxProcesses at the phase layer (#2198)
Extract from the inline
expression in so the regression test can exercise the
function that actually contained the removed cap.
The previous test called directly with
, which passes regardless of whether the phase-level
cap is present — never had the cap.
The new test suite covers:
- floor (20) for tiny repos
- linear scaling in the 0–3000 range
- growth past 300 for large repos (the actual regression)
- explicit assertion that reintroducing Math.min(300, …) would fail
Addresses review feedback from @azizur100389.
---------
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* feat(embeddings): support GITNEXUS_EMBEDDING_REQUEST_DIMS=omit
What: Honor GITNEXUS_EMBEDDING_REQUEST_DIMS=omit by suppressing the request-body
`dimensions` field sent to HTTP embedding backends.
Why: Strict OpenAI-compatible backends return vectors in the model's native size
but reject an unfamiliar `dimensions` field, breaking `analyze --embeddings`
against them. The var was parsed but never propagated, so `omit` was a no-op.
How: Add `requestDimensions` to HttpConfig, return it from readConfig, and forward
`config.requestDimensions` (not the validation-only `config.dimensions`) to
`httpEmbedBatch`. Local dimension checks still use `config.dimensions`.
Details: Coexists with the retry/pacing fields introduced upstream; both feature
sets are preserved. Default behavior unchanged when REQUEST_DIMS is unset.
Impact: gitnexus/src/core/embeddings/http-client.ts; README; unit tests.
* fix(embeddings): name GITNEXUS_EMBEDDING_REQUEST_DIMS in its own config error
Address the review findings on #2574.
What:
- A malformed GITNEXUS_EMBEDDING_REQUEST_DIMS now throws an error naming
GITNEXUS_EMBEDDING_REQUEST_DIMS, not the sibling GITNEXUS_EMBEDDING_DIMS.
- isHttpEmbeddingDimsError recognizes both leads, so the CLI still classifies
the REQUEST_DIMS config mistake as a clean config error, not a stack dump.
- Tests: numeric-override decoupling (DIMS=1024 validates the response while
REQUEST_DIMS=512 is sent in the body), the omit aliases (none/off/false/0),
and the malformed-value error path (which also pins the naming fix).
- README documents the full accepted values: omit-aliases and integer override.
Why: readConfig reused the DIMS error lead for the REQUEST_DIMS branch, so
REQUEST_DIMS=garbage misdirected the operator to edit the wrong variable. The
feature's actual decoupling and its non-omit inputs had no test coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: wangxc <wangxc_a_bj@si-tech.com.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--bare hard-disables the Skill tool and every mcp__* tool by Claude
Code design (confirmed against the pinned 2.1.214 binary; --allowedTools
cannot restore what --bare removes). Every workflow_bench arm except
baseline_nomcp needs Skill and/or GitNexus MCP tools, so every one of
those sessions has been silently unable to invoke gitnexus-plan/work/
review or the CE comparator skills -- the last skill-evolution run
(gen 0) scored 0/3 resolution on both arms across every task with
error_kind "skill-not-invoked", not because the candidate was bad but
because the harness could never invoke either arm's skill at all.
Only baseline_nomcp keeps --bare (it explicitly wants zero Skill/MCP
access anyway). The rest drop --bare and rely on ANTHROPIC_API_KEY
alone; the sandboxed HOME has no OAuth/keychain state to conflict
with it, and there's no committed .claude/settings.json in this repo
for dropping --bare to newly pick up.
Outside --bare the built-in toolset defaults to everything (WebFetch,
Task, subagents, ...), and --allowedTools only pre-approves within
whatever's available -- it doesn't narrow it. Added --tools for
non-bare sessions so the intended tool scope is still enforced instead
of silently widening.
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The Idempotency beforeAll runs runSkillsCli (analyze --skills) twice, each
capped at 45s, under a 90s hook budget — exactly 2x the per-call timeout,
with no headroom for fixture creation and git init. On slow Windows CI
runners the two analyzes plus setup exceed 90s and the hook times out
('Hook timed out in 90000ms'), failing the shard before the test's own
status===null timeout tolerance can apply. Every other describe hook in
this file already uses 120s; align this one.
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The 64 MiB floor was too small: LadybugDB's bulk COPY needs working
buffer-pool memory that scales with the repo, so a 64 MiB pool fails with
"buffer pool is full and no memory could be freed" on any non-trivial
repo (empirically: the 6-file skills-e2e idempotency fixture needs
>=128 MiB; the ~1800-file GitNexus checkout needs >=256 MiB). Introduce a
distinct ADAPTIVE_POOL_FLOOR (256 MiB) for the hint clamp, kept separate
from BUFFER_POOL_FLOOR (64 MiB), which still guards defaultBufferPoolSize
on tiny-RAM machines; the hint is still clamped up to the machine default
so it can never over-commit.
This keeps the change as what it actually is — a large-repo optimization:
GitNexus full analyze is 51s (2 GiB) -> 35s (adaptive ~414 MiB). Small
repos now open COPY-safely at 256 MiB instead of the 2 GiB default (same
wall time on Linux, where commit is lazy; the eager commit is far cheaper
than 2 GiB on Windows). The reframed comments drop the earlier
unrepresentative "3-file repo / 64 MiB fast" claim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The factor is validated by timing a full `analyze --force` of a large
repo, not a build-free bench (the pool is a native eager allocation).
Benchmark (GitNexus self, 101k graph elements): adaptive 414MiB pool =
35.3s vs forced 2GiB = 50.8s vs forced 64MiB = 26.5s — the adaptive pool
is 31% faster than the old 2GiB default even on a large repo (the eager
commit dominates), with no under-sizing thrash. 4KiB/element kept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
runFullAnalysis now sets the buffer-pool size hint from the built graph's
node+relationship count (after the pipeline, before initLbug), and clears
it at the top of each run so a prior run's size can't leak into a
pre-pipeline open. A small repo opens with the fast 64MiB floor instead of
eagerly committing the full 2GiB pool.
Measured on a 3-file repo (this box): analyze drops 4.78s -> 1.9s for both
fresh and incremental, matching a forced 64MiB pool; the 2GiB path still
reproduces the old 4.78s. This roughly halves the skills-e2e Idempotency
hook (two analyze passes) that was timing out on Windows CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the sizing lever without changing behavior yet: a module-scoped
buffer-pool size hint plus estimateBufferPool(graphElementCount), read by
resolveBufferManagerSize with precedence env-override > clamp(hint, 64MiB,
default) > default. The hint can only shrink the pool from the default
(clamped to [floor, default]), so the 2GiB/80%-RAM cap and the
GITNEXUS_LBUG_BUFFER_POOL_SIZE escape hatch (incl. 0) are preserved. With
no hint set, resolveBufferManagerSize returns exactly what it did before.
Motivation: LadybugDB eagerly commits the buffer pool at DB open, so the
fixed min(2GiB,80%RAM) pool adds a measured ~2.8s to every analyze even on
a 3-file repo (dominant on Windows). Sizing the pool to the graph lets
small repos use the fast 64MiB floor while large repos keep the cap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
trusted_gitnexus_runtime_mounts and sanitized_graph both hand the ~290 MiB
graph index through TaskAssetSnapshot.materialize(), which reflinks into
each arm clone or falls back to a buffered copy under a 16 MiB budget.
Neither ext4 (CI runners) nor 9p (this container) support FICLONE, so
every real materialization fell to the buffered path and blew the budget
instantly (CI run 29750271566).
Raises MAX_BUFFERED_FALLBACK_BYTES to 512 MiB - well above the real index
size, still a full 4x below MAX_TASK_ASSET_BYTES so a genuinely oversized
declaration still fails closed.
resolve-invocation.ts requires hooks/claude/resolve-analyze-cmd.cjs at
module load time, reached whenever the analyze command loads. The
sandbox's curated mount list never exposed hooks/, so every
benchmark-arm session failed with MODULE_NOT_FOUND (CI run 29742191562).
Appends the new mount after the existing six so the function's
hardcoded mounts[0]/[1]/[2]/[5] validation reads stay correct. Extends
the real-bwrap canary to require analyze.js directly, since --version
alone never reaches the lazy import that broke.
Every benchmark-arm session failed with "sanitized graph snapshot
preparation failed: clone has more than 1024 references; refusing
incomplete sanitization" (confirmed via a real workflow_dispatch run,
29738099937, after the prior activation fixes let the proposer succeed
end-to-end for the first time).
make_worktree() creates each arm's throwaway clone with a plain `git
clone`, which inherits every tag and branch from the source. This repo's
history has grown to 1144 tags (a v1.6.9-rc.N release-candidate series)
out of 1650 total refs, exceeding oracle_assets.MAX_CLONE_REFS=1024 -- a
fail-closed guard in sanitize_clone_for_hidden_oracles() that refuses to
proceed unless it can enumerate and delete every ref before handing a
sanitized snapshot to a benchmark session (so an agent can never discover
oracle answers via a ref the sanitization missed).
`ref` at every call site (evolve.py, runner.py, sanitized_graph.py) is
always a bare SHA or the literal "HEAD", never a branch name, so
`--single-branch --branch <ref>` isn't viable (git clone's --branch
requires a name). Tags are never used by the checkout fallback or by
sanitization's own delete-everything behavior, so dropping them via
--no-tags removes the 1144-ref majority without touching branch-fetch
behavior or the existing ref/origin-ref checkout fallback, and without
weakening MAX_CLONE_REFS itself.
Verified against the real repository (not just the test fixture): cloning
/workspace (1650 refs, 1144 tags) via the fixed make_worktree() now
produces a clone with 237 total refs and 0 tags.
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The proposer's second real-run failure (after the ENV_SCRUB permission fix
landed) exited 1 with subtype "success" and an EMPTY stderr_tail -- opaque:
the downloaded CI artifact showed num_turns:1, cost_usd:0, tokens:0,
duration_s:0.1, meaning the session terminated before any real model turn
completed (consistent with an early, pre-flight-style failure), but nothing
in the persisted record said why.
The actual JSON event stream (permission_denials, tool_use/tool_result,
is_error) lives in stdout, which run_managed already captures as
proc.stdout_tail -- it just never made it into the session's error_detail.
Add it there, bounded and truncated the same way stderr_tail already is.
It flows through evolve.py's existing whole-record redaction before being
written to disk / the uploaded artifact, so this closes the diagnostic gap
without a new blind CI dispatch: the next failure of this shape is
readable directly from the artifact.
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The first real skill-evolution run got past task binding, then the proposer
session exited 1 with "Permission mode forced to default —
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is set (allowed_non_write_users hardening)".
On 2.1.214 the permission resolver unconditionally forces permission mode to
"default" whenever CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is set — `--permission-mode
dontAsk`, settings `permissions.defaultMode`, and `autoAllowBashIfSandboxed`
are all ignored for the mode decision. The proposer runs headless `-p --bare`
where Bash is its only writable tool (it writes the candidate overlay); under
forced "default" Bash was no longer auto-approved, so the session blocked.
We cannot set ENV_SCRUB=0 (it scrubs the Anthropic auth token from the
sandboxed proposer's Bash subprocesses). Instead, align with the forced mode:
pre-approve the proposer's exact tool surface via settings `permissions.allow`
(["Read","Grep","Glob","Bash"]) — under "default" a tool runs without a prompt
iff it matches an allow rule — and stop requesting a non-default mode so no
warning fires. ENV_SCRUB and the full sandbox filesystem/network lockdown are
unchanged. The real-binary containment canary is updated to the new invocation
(no --permission-mode) so the CI job is the authoritative empirical gate, and a
fast unit assertion pins the new permissions.allow / absent defaultMode.
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ci): install root and shared node_modules for the evolution benchmark
The first real workflow_dispatch of the skill-evolution loop failed at task
binding: capture_task_dependency_binding aborted with
SandboxError: sandbox_copy path is unavailable: node_modules: No such
file or directory
The benchmark tasks sandbox-copy node_modules from three locations
(tasks.scenarios.yaml) — the monorepo root, gitnexus-shared, and gitnexus —
mirroring a full dev checkout. The install step only ran `npm ci` in
gitnexus/, so the root and gitnexus-shared node_modules never existed and
the loop died before any agent ran. Install all three (root, then build
gitnexus-shared, then build gitnexus), matching the per-package install in
ci-tests.yml plus the root deps the tasks require.
A new contract test pins all three installs so this fails in CI rather than
on the next real run — the same guard the workflow's other two P1 fixes got.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): only add the missing root install; the subpackage steps already exist
The initial fix redundantly rebuilt gitnexus-shared and gitnexus inside the
gitnexus step — but the workflow already builds both in their own dedicated
steps. Only the monorepo root node_modules was missing. Add a single
"Install monorepo root dependencies" step and leave the two subpackage
build steps untouched, so the benchmark's root sandbox_copy resolves without
double-building.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
---------
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Address review findings on PR #2488:
- skill-gen.ts: wrap mirror-root mkdir and per-skill mirror writes in
try/catch + warn, so a mirror failure (e.g. .agents/skills is a file)
no longer aborts canonical community-skill generation or destroys prior
output. Mirroring is now a weak side-flow, matching ai-context.ts.
- git.ts: exclude .agents/ + .agents/** from isWorkingTreeDirty so a
tracked .agents/ dir doesn't permanently defeat the up-to-date fast path.
- README + --skip-skills help (en/zh): note skills also mirror to
.agents/skills/ when .agents/ exists, and --skip-skills skips both.
Tests: +18 covering mirror failure paths (root-is-file, per-skill fail,
delete-then-rewrite ordering, namespace-scoped cleanup), dirty-check
excludes (real-edit regression, prefix collision, subdir .agents/,
non-git/git-missing conservative fallback), gate on file-not-dir, and
idempotency.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ci): review agent on Sonnet 5 with structured, linked reviews
Bump the pinned review model from claude-sonnet-4-5-20250929 to
claude-sonnet-5 (verified against the pinned Claude Code 2.1.214 with
subscription auth and --json-schema structured output).
Restructure the published review body: verdict-first summary, findings
ordered by severity, fixed section order, and every file or symbol
reference as a GitHub permalink pinned to the analyzed head SHA (or the
merge-base SHA for deleted and rename-old paths) instead of bare
path:line text, so references are clickable and render inline previews.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* feat(ci): review agent runs as a coordinated reviewer swarm
Implement the review skill's expert-lens section in CI: the main agent
spawns four trusted lanes in parallel via the Task tool — correctness,
security, blast-radius, and coverage — each a purpose-built persona
restricted to Read/Glob/Grep plus the read-only graph MCP tools.
Personas live in the canonical skill tree (mirrored to all shipped
copies) and are installed into the reviewer's user-scope agents dir from
the exact control SHA, so a hostile PR head can never define a lane.
Lane reports are treated as unverified claims: the main agent re-anchors
findings before publishing, and the publisher's context-evidence gate
still requires the main conversation's own successful context call.
Bash and the newer Agent tool remain disallowed for every context; the
analyze timeout gets swarm headroom (45 -> 60 minutes). The workflow
contract test now pins the swarm posture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): sidechain tool calls can no longer satisfy the evidence gate
The review agent's own review of this PR found that proveGraphReview()
walked the flat transcript without reading parent_tool_use_id, so a
spawned lane's context call could satisfy the publisher's graph-evidence
gate the prompt reserves for the orchestrator. Entries with a non-null
parent_tool_use_id are still strictly validated (malformed linkage fails
the transcript) but are excluded from both candidate context calls and
qualifying results; a new fixture proves sidechain-only evidence is
rejected while mainline evidence beside sidechain turns still passes.
Also gives the orchestrator turn headroom for the four dispatched lanes
(--max-turns 100 -> 150), addressing the review's LOW finding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* refactor(skills): swarm-lane dispatch belongs to the review skill
Move the lane orchestration out of the workflow prompt and into the
gitnexus-review skill itself: a new "Swarm lanes" section names the four
ci-persona lanes, defines when and how to dispatch them (parallel, one
message, per-lane context and file slices), and owns the verification
contract (lane reports are unverified claims; re-anchor, dedup, drop
unanchored findings; lanes structure the work but never gate it). Any
runner of the skill — the CI workflow or a local harness — now triggers
the lanes from one canonical definition.
The workflow prompt keeps only its CI-specific deltas: the lanes'
trusted-control-SHA install provenance, the Task-tool dispatch surface,
and the publisher's orchestrator-only context-evidence gate. Mirrors
synced; 122 contract tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* feat(skills): add adversarial finder lane and critic gate to the swarm
ci-adversarial-lens joins the parallel finder wave: it assumes the change
is broken and constructs reachable failure scenarios — interleavings,
hostile inputs, state corruption, abuse of newly exposed surfaces — each
verified to a concrete entry point before it may be reported.
ci-critic-lens runs last as a gate on the orchestrator's finished draft:
it audits anchoring, concreteness, severity calibration, format
conformance, and honesty, returning PASS or a numbered defect list with
the smallest repair per item. The skill bounds it to two passes and the
critic hardens the review without ever blocking it; the workflow inherits
both lanes automatically through the wholesale ci-personas install.
Mirrors synced across all three shipped trees; 122 contract tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* refactor(ci): workflow defers the whole swarm contract to the skill
Now that the skill's Swarm lanes section owns dispatch, verification, the
critic gate, and the fallbacks, the workflow prompt stops restating any
of it. It contributes only what CI alone knows: the lanes' control-SHA
install provenance, the concrete environment mapping for lane inputs
(diff, manifest, head and merge-base checkouts, exact SHAs), and the one
CI override — the publisher's context-evidence gate remains
orchestrator-only. Analyze timeout gains headroom for the critic's
sequential rounds (60 -> 75 minutes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): dispatch swarm lanes via the Agent tool, not the renamed Task alias
On the pinned Claude Code 2.1.214 the subagent-dispatch tool is `Agent`
(`Task` was renamed to `Agent` in 2.1.63 and is now a legacy alias), and
permission rules evaluate deny before allow. The workflow allowed `Task`
and denied `Agent`, so the orchestrator could never dispatch a lane and
every review silently fell back to the inline single-agent path while the
text-only tests certified the broken config.
Use `Agent` consistently: add it to --tools, allow it scoped to the six
ci-personas (`Agent(ci-correctness-lens,...,ci-critic-lens)`), remove it
from --disallowedTools, and update the prompt. Tests now match the scoped
allowlist on the raw string (commas inside Agent(...) break a split) and
assert Agent is no longer bare-denied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): harden swarm permissions — allow Glob/Grep + merge-base reads, quarantine PR-head agents
Three permission-hygiene gaps around the swarm dispatch:
- Glob/Grep were in --tools but had no allow rule, so the lanes' declared
tools could manufacture denied-tool errors; allow them (read-only,
sandboxed by cwd + add-dir).
- The prompt hands lanes the merge-base source checkout for deleted /
rename-old symbols, but no Read rule covered it; add a scoped Read()
allow (which grants access without triggering --add-dir agent discovery).
- The --add-dir PR-head copy is scanned for spawnable agent definitions and
the pinned runtime has no suppression env, so a PR could ship its own
.claude/agents/*.md. Drop that subtree from the materialized copy after
checkout-index (skills left intact), so only the trusted control-SHA
personas can ever be dispatched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* test(ci): pin the Agent allowlist to the ci-personas; require a dispatch canary
A text-only assertion cannot prove the pinned CLI actually dispatches the
lanes (print mode silently ignores invalid settings and does not validate
Agent(type) content at parse time) — that is what let the original
Task/Agent inversion pass CI. Two mitigations for the class:
- A cross-consistency test asserts the six names in the Agent(...) allowlist
equal the six ci-personas filenames and each persona's frontmatter name,
so a rename or typo in any of the three fails without auth.
- The activation checklist now requires the post-merge canary to prove a
positive dispatch AND an unlisted-type refusal before enabling the trigger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): bound swarm transcript volume with per-persona maxTurns
The six lanes stream into the single execution transcript the publisher
validates, but the personas carried no turn budget, so a large-PR swarm
run could overflow the (hard-throw) transcript caps and brick a valid
review. Bound each lane deterministically — finders maxTurns 12, the
critic maxTurns 6 — which keeps the worst case (~2×(150+5×12+2×6) ≈ 444
messages) under the unchanged 1_000 cap, so no cap needs raising. A new
test encodes that invariant: it fails if a persona's maxTurns is bumped
without revisiting the cap. Applied byte-identically across all four
shipped skill trees.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* test(ci): independently pin both sidechain evidence-gate guards
The sidechain-exclusion guards at candidate registration and result
acceptance were mutually redundant on realistic transcripts (a real
sidechain turn carries parent_tool_use_id on both its call and result),
so deleting either guard alone still passed the whole suite. Add two
asymmetric cross-wired fixtures — a mainline call with a sidechain result
(pins the acceptance guard) and a sidechain call with a mainline result
(pins the registration guard), both expecting missing_graph_evidence.
Mutation-verified: deleting either guard alone now reddens the suite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* docs(skills): require own evidence before dispatch; document critic fail-open and swarm naming
Strengthen the gitnexus-review "Swarm lanes" contract (all four mirrors):
- The orchestrator must make its own graph context call on a changed
symbol before dispatching any lane, so a fully-delegated run cannot
leave the publisher's evidence gate unsatisfied (mirrored into the
workflow prompt, with a test pinning the ordering phrase).
- Document that the critic's fail-open is deliberate (bounded to two
passes, cannot deadlock, review still gated by evidence + schema),
and distinguish it from the hard lane-7 gate in the separate
gitnexus-pr-swarm-review skill.
- Give a concrete local-harness registration pointer for ci-personas.
- Add a reciprocal cross-reference in gitnexus-pr-swarm-review (single
path — that skill is not part of the mirrored family).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* docs: record the review-agent swarm capability (AGENTS.md, CLAUDE.md, reviewer-swarm README)
Reflect the shipped swarm in the standing docs: bump AGENTS.md to 1.14.0
and CLAUDE.md to 1.8.0 with changelog rows, extend the gitnexus-review
description to mention the ci-personas swarm lanes, and refresh the
reviewer-swarm README so its differentiator names the real distinction
(interactive on-demand swarm vs the CI review agent's in-workflow lanes)
now that both run swarms. No CHANGELOG.md edit (feature-PR rule).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): close pre-push review findings on the swarm permission change
Adversarial review of the fix diff caught two issues introduced by the
permission-hygiene commit:
- Bare Glob/Grep in --allowedTools are separate tools that the Read()-scoped
path denies (/proc, github.workspace, ...) do not cover, opening an
undenied read path to the raw checkouts and host paths via a prompt-
injected lane. Drop the bare allow — under dontAsk they stay denied by
omission; lanes read via the scoped Read() rules and the graph MCP.
- The agents quarantine removed only the add-dir root's .claude/agents; make
it recursive so a nested (e.g. monorepo subpackage) .claude/agents cannot
survive and be discovered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* feat(ci): post an "in progress" marker while the review swarm runs
Swarm reviews can take up to 75 minutes, and until now the PR showed no
sign a review was running. Add a dedicated write-scoped `acknowledge` job
that, under the same authorization gate as analyze, upserts a per-PR
"🔄 GitNexus review in progress" sticky comment linking to the live run
(and reacts 👀 to the trigger comment); the publisher removes that marker
when the review — or a clean failure — posts.
The marker lives in its own job so the model-facing analyze job stays
secretless and read-only: it cannot post to the PR, so per-lane live
progress isn't exposed there — the marker is a binary "running" state with
a link to the Actions run where lane-by-lane progress is visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(eval): run the skill-evolution loop online
Add a scheduled + dispatch-gated workflow that runs the offline
propose -> benchmark -> gate loop (workflow_bench.evolve) in CI with the
pinned Claude canary runtime and bubblewrap containment, uploads the
benchmark evidence as an artifact, and on a gate-passed promotion opens
a human-reviewed PR via the release App token. The applied overlay is
bounded to the canonical skill tree and its shipped mirrors; any escape
fails the run instead of reaching a PR.
The scheduled lane ships disabled behind GITNEXUS_EVOLUTION_ENABLED and
requires the new GITNEXUS_BENCH_AUTH_TOKEN secret (benchmark sessions
bill real API usage), mirroring the review agent's staged rollout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): restructure promotion-PR script so no lint suppression is needed
Replace the inline single-quoted credential helper with a GIT_ASKPASS
file written via a quoted heredoc (the App token still reaches git only
through step env at push time), and assemble the PR body from quoted
heredocs plus double-quoted printf instead of a backtick-laden
single-quoted template. Every run script in the workflow now passes
shellcheck with zero findings and zero disables; the body and askpass
rendering are smoke-tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): apply gate-passing overlays in the evolution loop
The loop invoked workflow_bench.evolve without --apply, so
apply_promoted_overlay (its only working-tree writer, gated by
`if args.apply:`) never ran. git status stayed clean, promoted=false was
emitted every run, and the App-token/PR-open steps were unreachable dead
code — a gate-passing run went green as "No promotion this run".
validate_promotion_for_apply already runs before the apply gate, so
adding --apply lets a passing candidate reach the tree without weakening
the deterministic gate; the boundary check then confirms it stayed in the
skill trees.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): provision ~/GitNexus so the benchmark repo resolves on CI
Every scenario in tasks.scenarios.yaml addresses the target repo as
~/GitNexus; runner_tasks.py resolves it with expanduser().resolve() then
`git -C <repo> rev-parse`, which raises when the path is missing. On a
hosted runner the checkout lands in $GITHUB_WORKSPACE and nothing created
~/GitNexus, so the first real run failed at task-binding.
Symlink ~/GitNexus -> $GITHUB_WORKSPACE before the loop. The checkout uses
fetch-depth: 0 (full history for the parentless clone), and the benchmark
only clones the repo copy-on-write and mounts deps read-only, so the
checkout is never mutated. GITNEXUS_BENCH_ORACLE_ROOT stays unset — it
defaults to the in-repo oracles dir and is staged by the harness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): harden promotion summary output and PR branch recovery
Three fixes to the promotion-detection and PR-open steps:
- GITHUB_OUTPUT summary used a fixed `PROMOTION_EOF` heredoc delimiter; a
value containing that marker on its own line could close the block early
and inject output keys. Use a per-run random delimiter, matching the
pattern already in tree-sitter-upgrade-readiness.yml.
- The summary concatenated every generation's promotion.json (including
rejected ones), so the PR body could show a losing generation's
decisions. The loop returns on the first promotion, so emit only the
highest-numbered gen-N/bench/promotion.json — the decision that fired.
- The promotion branch name omitted the run attempt. GITHUB_RUN_ID is
stable across re-runs, so a re-run after push-succeeds/PR-create-fails
could never push. Include ${GITHUB_RUN_ATTEMPT} (the artifact name
already does).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): least-privilege the promotion App token and gate on an Environment
The Mint-App-Token step passed only app-id + private-key, so the minted
token inherited every permission the Release App installation holds
(including Workflows: write) — far more than "push a branch, open a PR".
Switch to `client-id` (as publish.yml does) and request only
permission-contents: write + permission-pull-requests: write.
Bind the job to a protected Environment (gitnexus-evolution) so promotion
runs can be gated server-side. workflow_dispatch runs the workflow and
in-tree evolve.py from the *dispatched ref*, so a code-side ref guard is
removable by the dispatched branch itself; an Environment deployment-branch
rule (main only) is the boundary that holds. The admin steps to create it
and scope the secrets are documented in the activation checklist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(ci): correct upload-artifact pin comment and add shell strict-mode
- The upload-artifact SHA 043fb46d… is v7.0.1 (labeled so in the sibling
workflows that pin it); the comment mislabeled it # v6.0.0. Correct the
comment; the pin is unchanged.
- Add `set -euo pipefail` to the two build steps that lacked it, matching
every other run block in the file (GitHub's default shell already sets
-eo pipefail; this adds -u and consistency).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* docs(ci): complete the skill-evolution activation checklist
- Add RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY to the required-secrets
checklist (the Mint step hard-fails without them on a promotion) and the
App-install-scope verification.
- Document the protected Environment admin step and why it is the real
boundary for the workflow_dispatch ref-secret exposure.
- Note that workflow_dispatch runs the billing loop regardless of
GITNEXUS_EVOLUTION_ENABLED.
- Justify the weekly cron against the README's ~90-day guidance and note the
355-minute timeout ceiling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* fix(eval): redact API tokens from diagnostic fields before artifact upload
results.jsonl (runner.py) and proposer-session.json (evolve.py) serialize
session records whose error_detail can carry a stderr_tail that echoed the
API key. Transcripts are redacted before persistence, but these two sinks
were not, and both land in the 14-day evolution artifact.
Run each record's serialized JSON through the existing redact_text with the
run's auth token before writing. Scoped to these diagnostic sinks only: the
promoted overlay and proposal.md are left untouched (the overlay is the
applied artifact and must stay byte-identical for apply and the
shipped-skills-sync guard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* test(ci): add a contract test for the skill-evolution workflow
No test exercised this workflow's path, which is why both P1 blockers
(missing --apply, unresolvable ~/GitNexus task repo) reached production.
Parse the workflow YAML and assert the structural contract: --apply is
passed, the task repo is provisioned, the promotion branch carries the run
attempt, the App token is permission-scoped and the job is Environment-
gated, the output summary uses a random delimiter and a single generation,
the artifact pin is labelled correctly, and every multi-line shell step
sets strict mode. Follows the review-agent-workflow.test.ts precedent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* feat(ci): run the proposer on its own (stronger) model
One `model` input drove both the benchmark arms and the proposer/diagnosis
session. Split them: `model` stays the benchmark arms (match the model your
skill users run, so a promotion is valid for them and the tasks aren't
ceiling-saturated), and a new `proposer_model` input runs the proposer —
the harder meta-reasoning task that writes the candidate skill, and only one
session per generation, so a stronger model is cheap here. evolve.py already
supports --proposer-model; the workflow just didn't expose it.
Defaults: arms = claude-sonnet-5, proposer = claude-opus-4-8 (both
overridable via workflow_dispatch). The weekly cadence bounds the added
spend. Contract test asserts the split stays wired.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bump the pinned review model from claude-sonnet-4-5-20250929 to
claude-sonnet-5 (verified against the pinned Claude Code 2.1.214 with
subscription auth and --json-schema structured output).
Restructure the published review body: verdict-first summary, findings
ordered by severity, fixed section order, and every file or symbol
reference as a GitHub permalink pinned to the analyzed head SHA (or the
merge-base SHA for deleted and rename-old paths) instead of bare
path:line text, so references are clickable and render inline previews.
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): unblock the review agent dispatch and publisher lanes
The first workflow_dispatch validation run surfaced two defects:
- setup-node rejects `cache: false` (the YAML boolean arrives as the
string 'false' and v6 fails with "Caching for 'false' is not
supported"), killing the analyze job before the isolation preflight.
Omitting the input is the supported way to disable caching.
- The publisher held only `issues: write`, but GITHUB_TOKEN needs
`pull-requests: write` to create issue comments on a pull request,
so even the safe-failure comment died with "Resource not accessible
by integration".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
* test(ci): align the publisher permission contract with PR commenting
The workflow contract test pinned the publisher to pull-requests: read,
which is exactly the permission set that made comment publication fail.
Encode the corrected scope and assert the publisher still cannot write
repository contents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): add ce-plan — GitNexus+PDG implementation-planning skill
Adds .claude/skills/ce-plan: a planning-only skill that builds
implementation-ready plans from GitNexus graph navigation (query/context/
impact/trace), bounded statement-level PDG slices (pdg_query, impact
mode:pdg, explain), and targeted source verification, with a context
ledger to prevent repeated reads and a machine-readable implementation
context pack (stable contract for a future ce-implement). Whitelisted in
.gitignore and registered in AGENTS.md and CLAUDE.md outside the
auto-managed gitnexus block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): apply ce-plan validation findings (tool contract, consistency, conventions)
Tool contract: impact mode:'pdg' shape now includes the schema-required
direction param; CDG branch sense documented as the result 'label' field
(reason is cypher/raw-edge only); explain caveats corrected to its real
false-negative classes (cross-function TAINT_PATH is modeled).
Consistency: PDG slice homed in working memory (ledger keeps one-liners);
depth knob defined and category-overrides-baseline ordering stated;
call_depth (consumed by nothing) and content-hash bookkeeping dropped;
Never section folded into Hard rules; Phase 3 deduplicated to a pointer;
allowed-repeat escalations defined; budget/discard accounting clarified;
verification-commands gathering added to Phase 4; open_questions added to
the context pack.
From scenario runs: plans now pin the verified-at HEAD commit and index
freshness in a header, tag claims [verified]/[graph]/[inferred]/[assumed],
quote load-bearing tool output, prefer pre-hook-carrying npm scripts, and
support an out:<path> destination override; output path defined as the
Phase 1 target repo root.
Conventions: AGENTS.md 1.9.0 / CLAUDE.md 1.4.0 changelog rows + metadata
bumps; future ce-implement qualified as future.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): rename ce-plan → gitnexus-plan; add cross-CLI (Codex) entrypoints
Renames the skill dir, frontmatter, output filename convention, plan H1
(GitNexus Engineering Plan), the future executor handle
(gitnexus-implement), the .gitignore whitelist entry, and all
AGENTS.md/CLAUDE.md references. Follows the pr-swarm-review cross-CLI
pattern: SKILL.md is the canonical CLI-neutral spec, AGENTS.md § Engineering
planning is the Codex/any-agent entrypoint, and the README documents the
optional user-level ~/.codex/prompts/gitnexus-plan.md slash command plus an
invocation matrix. Skill prose de-branded from Claude Code (agent-neutral
verification layer).
Also fixes two post-review README contradictions: the anti-reread claim now
names the ledger's allowed escalations, and 'read-only by contract' is now
'planning-only' (the skill writes exactly one repo file — the plan); the
scope-creep rule and template §12 now agree on where deferred follow-ups
land. Drops the stale plugin-collision limitation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): document Codex user-level install path for gitnexus-plan
Codex discovers SKILL.md skills from ~/.agents/skills (same path the other
gitnexus-* skills install to); README now documents the cp install plus the
optional ~/.codex/prompts slash-command file, with the prompt body preferring
the repo copy and falling back to the user-level install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): gitnexus-plan freshness gate + active PDG-layer refresh
Freshness is now a Phase 1 gate, not advisory: under the default
freshness:strict, a stale index is refreshed once per planning session via
node .gitnexus/run.cjs analyze --index-only (appending --pdg when the task
will reach the PDG phase), then the context resource is re-read. A missing
PDG layer likewise triggers the one permitted --index-only --pdg refresh
and re-probe instead of a passive recommendation. freshness:accept (or a
failed/impractical refresh) preserves the old behavior: plan on the stale
graph, source-weighted, labelled in the plan header. --index-only is the
load-bearing flag choice — it suppresses all file generation, so the
planning-only contract holds (only the .gitnexus store changes). Ledger
gains an index_refresh record; plan header states fresh / refreshed /
refresh-skipped-with-reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): gitnexus-plan runner build check before freshness refresh
When the target repo builds the analyzer from its own source (bin → dist/
mapping, as gitnexus/ does), the Phase 1 freshness gate now verifies dist/
is current before running the analyze refresh — rebuilding via the
package's build script when any analyzer source file is newer than the
built entrypoint — and prefers that freshly built CLI. Otherwise a stale
dist re-indexes with outdated extraction logic and the 'fresh' index lies.
Rebuilds are recorded in the ledger's index_refresh; the PDG-phase refresh
inherits the same check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): add gitnexus-work executor and gitnexus-lfg pipeline
gitnexus-work executes a gitnexus-plan as verified atomic commits: consumes
the §11 implementation_context pack, drift-checks the plan's evidence pin
against HEAD, re-verifies assumptions before relying on them, runs impact
before every symbol edit and detect_changes before every commit (repo
mandates), builds tests from the plan's scenarios, and routes structural
drift back to gitnexus-plan Deepen mode instead of coding around it.
gitnexus-lfg is a thin orchestrator: gitnexus-plan → blocking user gate
(deepen / proceed / stop, deepen loops allowed) → gitnexus-work → review
via the existing gitnexus-pr-review skill (open PR, else branch diff vs
default). One bounded fix cycle for review findings; never pushes or opens
a PR on its own.
gitnexus-plan gains a Deepen mode (re-run freshness gate, escalate to
depth:deep, re-verify graph/inferred/assumed claims toward verified,
rewrite the same file); its 'future gitnexus-implement' placeholder is
retired in favor of gitnexus-work. Registered via .gitignore whitelists,
AGENTS.md 1.10.0 (section renamed to Engineering planning & execution),
CLAUDE.md 1.5.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): apply cross-skill review findings to the gitnexus skill family
Two P1s: gitnexus-plan Deepen mode now re-anchors before re-pinning
(diffs the old evidence pin over every [verified]-claim file and re-reads
or downgrades before the header moves — moving the pin without this
laundered stale claims as verified); the index-refresh budget is stated
once in Phase 1 (one --index-only refresh plus at most one Phase 3 --pdg
upgrade per session, Deepen = its own session) with ledger and pdg-slice
deferring to it.
Contract fixes: gitnexus-work's drift check now covers every file the
pack cites (not just files_to_modify) and parses the full pack incl.
primary/related symbols and acceptance_criteria (walked in Phase 4
alongside §13); a pre-completed check skips §7 steps already landed and
Deepen gains a reconcile-execution-state step, closing the mid-execution
route-back loop; pack assumptions must name what to check and how.
lfg: Lane 4 passes the merge-base to detect_changes compare (two-dot
diff misattributes upstream commits when default advanced), branch-diff
is the stated normal case, oversized review findings route to the plan
gate instead of overflowing direct mode, the one-fix-cycle cap is
explicit on re-run, and headless runs end at the plan gate with the plan
as deliverable. work: blank mode narrowed to *gitnexus-plan*.md with a
re-execution guard, direct-mode discipline spelled out, branch
meaningfulness defined against the plan slug, and the plan document is
committed as the branch's docs commit (review diff includes it).
Planning-only contract now names the dist/ rebuild as the second
permitted state change; Phase 5.1 names the four claim tags; stale
AGENTS.md anchors fixed.
Known latent issue left untouched: gitnexus/gitnexus-pr-review pairs a
three-dot example with a two-dot detect_changes compare — that skill is
also shipped by the plugin, so fixing it here would drift the copies;
lfg compensates by passing the merge-base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): ship the engineering skill family with the gitnexus package
npm i -g gitnexus users now get gitnexus-plan / gitnexus-work / gitnexus-lfg:
the three skills are added to gitnexus/skills/ in directory form (SKILL.md +
references/), which installSkillsTo already enumerates dynamically and copies
recursively to every editor target (~/.agents/skills for Codex, Cursor,
OpenCode, Qoder, ...) on gitnexus setup — uninstall enumerates the same root,
so removal stays clean. The Claude Code plugin channel
(gitnexus-claude-plugin/skills/) carries the same copies plus the standard
per-skill mcp.json.
Global-install support in the skill text: gitnexus-plan Phase 1 now resolves
the analyzer runner explicitly — node .gitnexus/run.cjs analyze when the
project has a runner, else gitnexus analyze (installed CLI), else
npx gitnexus analyze — and all analyze mentions route through it, satisfying
the skills-steering policy (#1939/#1945) which sweeps the plugin copies.
New drift guard test/unit/shipped-skills-sync.test.ts asserts the npm and
plugin copies stay byte-identical to the canonical .claude/skills/ family
(plugin = canonical + mcp.json), same discipline as run.cjs ↔
resolve-invocation.ts. skills-steering + shipped-skills-sync: 11/11 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(eval): workflow_bench — measure the skill workflow's token savings
Benchmarks gitnexus-plan → gitnexus-work against a baseline agent
(--disallowedTools Skill) on identical tasks, in fresh detached worktrees,
using real headless Claude Code sessions; every number comes from the CLI's
--output-format json usage report (field names validated against a live
2.1.207 session). Reports per-arm medians (input/cache/output tokens, cost,
wall time, turns), a savings row, and resolve status from a per-task verify
command — savings on failed tasks are flagged, not celebrated. Per-task
setup hook prepares fresh worktrees (deps); --permission-mode
bypassPermissions (default) lets sessions run unattended in the throwaway
trees.
Free-model support: --base-url/--auth-token/--model route headless sessions
through any Anthropic-compatible endpoint; free-model.litellm.yaml is a
ready litellm-proxy template for OpenRouter :free variants or local Ollama,
so benchmarking burns no paid tokens (README documents rate limits and the
small-model skill-following caveat).
Harness validated end-to-end with a stub CLI (worktree lifecycle, both
arms, plan→work chaining, verify, aggregation, report) and 4 pytest units
for the pure aggregation/savings/report helpers. AGENTS.md 1.11.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(eval): record first workflow_bench calibration run
Trivial-task calibration (add -V alias): both arms resolved; workflow arm
~4.3x baseline cost — the documented overhead-dominated regime, recorded so
the regime boundary is empirical rather than asserted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(eval): workflow_bench scenario matrix — arm variants, task classes, churn
Ground-base measurement across scenarios: tasks.scenarios.yaml spans four
labeled classes (trivial → investigation-bug → investigation-feature →
cross-module) with deterministic verifies (prescribed test files). New arms:
workflow_direct (gitnexus-work direct mode — the middle option that locates
the routing boundary lfg's gate and work's triage encode) and baseline_nomcp
(no skills AND no graph tools — separates workflow-discipline value from
GitNexus-tool value; off by default). Records now carry task class and diff
churn (files/+ins/−del vs the starting commit) as an over-engineering proxy;
the report renders a class column and per-arm savings rows vs baseline.
5 pytest units + stub-CLI e2e of the full three-arm matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(eval): record workflow_bench ground base; fix churn measurement bias
Ground base (3 classes x 3 arms, n=1/cell): every arm resolved every task —
pass/fail quality saturates at this difficulty, making the comparison pure
cost. Full plan→work never amortized its ~$9-11 fixed cost on tasks a
baseline finishes in ≤35 turns (−211% to −333% cost); workflow_direct sits
near baseline (−15% to −55%, once faster wall) with more test coverage.
Routing implication recorded: direct mode/plain agent below this scale,
full workflow for cross-module / multi-session / plan-as-deliverable work.
The cross-module cell and multi-run variance are the next measurements.
Churn fix: git add --intent-to-add -A before diffing (arms that never
commit no longer undercount new files) and :(exclude)docs/plans (the
committed plan doc no longer inflates workflow churn); this run's churn
numbers predate the fix and are omitted from the recorded table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(skills): cost-optimize the workflow from measured ground base
Every optimization targets a measured fixed-cost component
(eval/workflow_bench ground base: workflow arm −211% to −333% vs baseline,
all tasks resolved):
- Plan form is category-priced: compact form (core sections w/ § anchors
preserved, ≤80 lines excl. pack, mini-pack subset of the context pack)
for narrow/default categories; the full 13 sections only for deep work
(refactor/security/performance/concurrency/architecture). A compact plan
outgrowing its cap reclassifies to full rather than overflowing.
- Freshness gate is category-priced: compact categories default to accept
(source-weighted, refresh only when a graph claim becomes load-bearing);
strict stays the default for full-plan categories — the rebuild+re-index
was the largest single fixed cost.
- Turn economy: per-category tool-call budgets (~10 to ~45; architecture
uncapped); budget exhaustion routes open questions to §12 instead of
more digging.
- gitnexus-work fast path: HEAD == evidence pin → skip all citation
re-reading (the pin's entire point); mini-pack fields tolerated.
- lfg Lane 1 boundary triage: tasks below the measured ~35-turn boundary
get offered gitnexus-work direct mode before the plan lane is spent.
Copies re-synced (npm skills/, plugin, ~/.agents); steering + sync guards
green. Re-measurement of the workflow arm follows to verify the numbers
actually improve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(eval): record optimization re-measurement — inv-bug workflow cell −20% cost
Same task, same conditions, post-830a0459 skills: $14.56→$11.70 (−20%),
83→72 turns, cache_read −24%; verified in-transcript that the compact form,
turn budget, and skipped rebuild/re-index all fired. Wall +15% from a work-
session test-debugging tail (n=1 variance). Regime unchanged (~3.5x baseline
on this class) — routing rule stands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(eval): per-arm clone isolation — worktree ref-namespace leak contaminated an arm
The cross-module workflow_direct cell reported an impossible 28-turn solve
with churn byte-identical to the workflow arm: git worktree add shares the
repo's ref namespace, so the workflow arm's slug branch (created by
gitnexus-work Phase 2) survived worktree removal and the direct arm found
and adopted the completed work. Arms now get isolated git clone --shared
copies (object store via alternates, refs clone-local — agent branches and
stashes die with the clone; origin/<ref> fallback for non-default refs).
Leaked branch deleted; baseline arm verified clean (0 branch references in
its transcript); cell marked invalidated pending re-run.
Records the valid cross-module cells: workflow $18.32 vs baseline $18.03
(premium −1.6%, vs −211%..−333% on smaller classes) — fixed costs amortize
at this scale, with a less destructive diff and a plan artifact as bonus;
resolve rate still tied. Churn fingerprinting is what caught the
contamination — noted in the README as an integrity check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(eval): complete cross-module cell — direct mode wins 47% cost / 56% wall
Clean clone-isolated re-run: workflow_direct resolved the hardest class at
$9.53/52 turns/15m vs $18.03/98/34m baseline and $18.32/107/37m full
workflow. The measured story across all four classes: the execution
discipline (gitnexus-work) is the consistent sweet spot and delivers real
token savings on hard tasks; the planning pass buys its artifact, not
same-session savings. Resolve rate tied everywhere (n=1/cell caveat).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(eval): add trajectory-gated skill evolution (#2431)
- Pair prompt candidates with incumbent workflow arms
- Gate promotions on pinned-model quality and efficiency
- Expire router evidence and document its lifecycle
* fix(eval): allow pr-review skill candidates
* feat(skills): rename and generalize GitNexus review
* feat(eval): external-comparator and review arms for workflow_bench
- ce_workflow / ce_workflow_direct: compound-engineering ce-plan/ce-work
arms prompted with the same structure as the gitnexus arms
- review / ce_review: gitnexus-review vs ce-code-review on an identical
diff applied by the task's setup
- plan handoff is snapshot-based: committed example plans in docs/plans/
tie on clone mtimes and broke the name-glob pick (executed a stale plan)
- verify output tail is recorded per run and the final working-tree patch
is kept, so failed rows are diagnosable after the clone is destroyed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,eval): address #2431 review — data-safe rename migration, fail-closed bench evidence
- setup: never delete a legacy renamed skill dir — the installer cannot
prove ownership (users customize or hand-write skills under these
names); warn with the path instead, and the test now asserts survival
- workflow_bench: fail closed when a session's --output-format json
report is empty, malformed, or missing usage fields — an exit-0 shell
with no parseable usage no longer counts as measured evidence
(5 parametrized regression tests)
- workflow_bench: document the trust model prominently (task setup/verify
are shell-executed, sessions run bypassPermissions with the parent env,
candidate overlays are prompt injection surface) in README + docstring
- free-model.litellm.yaml: master_key from LITELLM_MASTER_KEY env instead
of a static token; loopback-binding warning
- ci: run the eval workflow_bench pytest suite on ubuntu (pytest+pyyaml
only — no full eval stack)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(eval): demand observed foreground verification in headless work-arm prompts
In a headless -p session there is no later turn: a work arm backgrounded
its slow test run, scheduled wakeups that can never fire, and reported
done while two of its tests failed. All four work-arm prompts (both
skill families, symmetric) now require verification output to be
observed inside the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): ask plan depth up front instead of offering deepen afterwards
gitnexus-plan Phase 0 now asks one blocking question in interactive
sessions — quick / standard / deep, mapped onto the existing depth/form/
freshness knobs — when the invocation carries no explicit depth signal.
Explicit knobs and headless runs skip the question (category posture
unchanged, so benchmarks and automation behave as before).
gitnexus-lfg's plan gate slims to proceed/stop: depth was already the
user's up-front choice, so deepening is no longer offered by default —
an explicit deepen request at the gate and executor route-backs still
run Deepen mode, which remains the mechanism for strengthening an
existing plan document.
All shipped copies resynced (npm skills/, Claude plugin); AGENTS.md
1.13.0 and CLAUDE.md 1.7.0 pointers updated, including the analyzer's
regenerated index-stats block at this branch's head.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): taint pass, expert lenses, and post-work index refresh
gitnexus-review gains a PDG-backed taint-and-dependence pass (explain +
pdg_query, --pdg folded into the stale refresh on trust-boundary diffs) and
an Expert lenses section: domain reviewers derived from the graph's
clusters plus four cross-cutting lenses (architectural fit, language
conformance per the repo's own contract, Definition of Done, simplicity),
dispatched once after the evidence-gathering steps and scaled to the diff.
gitnexus-work Phase 4 now refreshes the knowledge graph after the DoD walk
via the resolved-runner ladder with analyze --index-only, so the lfg review
lane and later sessions query the finished work without dirtying the tree.
lfg's threshold-governance paragraph moves to its README; eval citations
are tagged as measured in the GitNexus repo. All shipped copies re-synced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): remove legacy gitnexus-pr-review on uninstall; cover the rename migration
uninstall's removal set now includes LEGACY_SKILL_DIR_NAMES derived from
RENAMED_SKILL_DIRS, so a pre-rename install is cleaned up instead of
orphaned. The rename warning gains behavioral coverage (fires with a legacy
dir present, silent without), and shipped-skills-sync asserts legacy names
stay absent from every shipped tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(eval): metric provenance, error-kind rows, skill-invocation verification, gate noise floor
The promotion gate defaults to cost_usd (the only metric that includes
subagent spend); token metrics carry an explicit main-loop-only warning in
the report and promotion.json. Rows are classified by error_kind
(session-error / verify-failed / infra-error), excluded from efficiency
medians, and the gate requires equal valid-run counts. Each session's
transcript is scanned for the expected Skill invocation and fails closed on
a verified miss; a one-run resolution edge no longer promotes (noise
floor). Per-run timeouts and setup failures record an infra-error row
instead of aborting the sweep. Overlays touching skills no candidate arm
exercises are rejected up front.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fix skill routing paths, version headers, and skill rosters
Routing tables point at the tracked direct skill paths (matching the
post-#2434 generator output), AGENTS.md/CLAUDE.md headers match their
latest changelog rows, the 1.12.0 row describes what the migration actually
does, package/cursor READMEs list the full shipped skill roster, and the
swarm READMEs describe /gitnexus-review's expert lenses instead of calling
it single-agent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: drift-guard workflow for skill copies; pin eval pip deps; track docs/plans
ci.yml ignores '**.md', so an md-only skill edit would merge without the
shipped-skills-sync test running — skill-sync.yml triggers exactly on the
guarded trees. The eval job's pip install is version-pinned, and
docs/plans/ is unignored so gitnexus-plan output can be committed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): keep the runner-invocation literal in gitnexus-review; add concurrency block to skill-sync
skills-steering requires skills with a stale-index hint to carry the exact
'node .gitnexus/run.cjs analyze' form — restore it with the fallback ladder
as a parenthetical instead of replacing it. skill-sync.yml gains the
top-level concurrency block the workflow-convention check enforces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): token-economy guidance for expert lenses
Merge lenses that ground in the same material into one reviewer, and use
cheaper model/effort tiers for mechanical lenses where the harness offers
them, reserving the strongest engine for adversarial judgment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(eval): isolate transcript home on Windows
Ensure workflow_bench transcript tests set USERPROFILE alongside HOME so Path.home() resolves to the temporary test home on Windows.
* docs(skills): fold PR #2522 execution learnings into review/work/plan
Eight incident-backed hardenings from running the full skill cycle
(review -> plan -> work, 28-finding fix series) on PR #2522:
gitnexus-review:
- Expert lenses execute the code under review on candidate failing shapes
(empirical probe outranks source reading — every HIGH the language
lenses found came from a probe, not a read).
- Step 7 re-runs the exact CI check for refreshed baselines/fingerprints
(a stale committed artifact is invisible in the diff; caught a red
benchmarks arm).
- Step 8 treats version/invalidation constants as review surface
(INCREMENTAL_SCHEMA_VERSION class recurred verbatim from #2494).
gitnexus-work:
- Step 4 proves regression tests discriminate against the pre-fix tree.
- Step 5 rebuilds executed build output before every verification run
(parse workers load dist/; a correct fix 'failed' until rebuilt).
- Step 6 makes stage -> detect_changes -> commit one unbroken sequence.
gitnexus-plan:
- Phase 0 seeded-evidence mode: plan FROM a completed review's verified
findings instead of re-running the graph ladder.
- Template §7: fingerprint/golden-guarded output rebaselines once, at the
series tip.
All distribution copies resynced; shipped-skills-sync + skills-steering
24/24 locally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(eval): close the skill-evolution loop with an automated proposer driver
workflow_bench.evolve adds the three arrows the README described as manual:
a proposer session that turns loser trajectories (results.jsonl rows,
transcripts, patches, the learning queue) into ONE bounded candidate
overlay, a driver that iterates propose -> paired benchmark -> deterministic
gate up to --generations, and an --apply step that copies a promoted
overlay onto the canonical skills and shipped mirrors as a working-tree
diff. The trust boundary is unchanged: overlays re-validate through
candidate_overlay_files before any benchmark or apply consumes them, and
committing, CI, and the PR merge stay human.
learnings.jsonl is gitignored: it is machine-local evidence, like the
session transcripts it complements.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): route live-task friction into the evolution learning queue
Each family skill gains a short 'Skill feedback' section: on friction with
the skill's own instructions, append one JSON line to
eval/workflow_bench/learnings.jsonl (GitNexus repo only) — never self-edit
the skill from a live task. The proposer in workflow_bench.evolve consumes
the queue as hints; a learning reaches a shipped skill only by beating the
incumbent on the paired benchmark. All shipped mirrors re-copied byte-
identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(tests): run the evolve helper tests in the eval pytest job
test_evolve.py needs only pytest+pyyaml, same as the harness tests the job
already runs — without this line the new module had no CI coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ci): comment-triggered GitNexus review agent for PRs
'@gitnexus review' from a maintainer (OWNER/MEMBER/COLLABORATOR; the action
re-validates write access) runs the repo's gitnexus-review skill headlessly
against the PR and posts the review as a sticky comment — remote triggering
with no local setup. Read-only by construction: contents: read token,
Write/Edit and web tools disallowed, Bash allowlisted to git reads and the
gitnexus CLI; analyze parses PR code with tree-sitter, never executes it.
Requires the ANTHROPIC_API_KEY repository secret; activates once the file
is on the default branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ci): dispatch lane + existing OAuth secret for the review agent
Align with claude.yml: same action pin and the CLAUDE_CODE_OAUTH_TOKEN
secret the repo already carries — no new secret to configure. Add a
workflow_dispatch lane (PR number input) so the agent can be triggered from
the Actions UI and tested before the issue_comment trigger reaches the
default branch. Allowlist gh pr view/diff and gh api, which the review
skill uses to pin PR SHAs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): close a fork-PR RCE vector in the review agent's tool allowlist
A live headless run of the exact workflow session against PR #2431 (66
turns, full gitnexus-review pass) surfaced a real HIGH-severity confused
deputy: .gitnexus/ is gitignored, not blocked — a fork PR can commit its
own .gitnexus/run.cjs, issue_comment checks out PR-head content, and the
skill's runner ladder tries 'node .gitnexus/run.cjs analyze' first. That
would execute fork-controlled JS inside a job holding
CLAUDE_CODE_OAUTH_TOKEN and a write-scoped GITHUB_TOKEN — the opposite of
the 'PR code is read, never executed' claim in the workflow's own header.
Fix: drop the run.cjs allowlist entry so analyze always resolves through
npx gitnexus (npm registry, not the checked-out tree); the skill's
documented fallback mode covers the resulting graceful degradation. Also
drop 'gh api' (not read-only — accepts -X POST/PATCH/DELETE) and downgrade
pull-requests: write to read (comment posting only needs issues: write;
the prompt already forbids formal review submission).
Same session flagged a latent evolve.py bug: select_evidence's cost sort
used dict.get's missing-key default, which doesn't cover an explicit JSON
null in a foreign --seed-results row and crashes proposer setup with
TypeError. Guarded with 'or 0.0' and added a regression test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: harden PR review and evolution trust boundaries
* ci: follow workflow concurrency convention
* fix(eval): make terminating error paths explicit
* fix: unblock hardened review runtime checks
* test: make containment canaries deterministic
* test: expose Claude canary tool failures
* fix: adapt clean shell environment for Claude
* fix(eval): accept the runner's transcript source key in evidence preflight
The proposer evidence preflight required transcript-artifact metadata to be
exactly {path, sha256, bytes}, but the runner stamps a fourth provenance key
(source=parent-captured-stream-json). Any --seed-results or generation>=2 run
therefore aborted with SandboxError before proposing or promoting. Pin the
producer literal as PARENT_EVENT_STREAM_SOURCE and validate it in the metadata
check, and round-trip real producer output through sum_sessions into the
preflight so the schema can't drift again.
* fix(eval): treat an unmeasured session cost as unavailable, not $0
well_formed validated only the nested usage block, so an otherwise-successful
session missing total_cost_usd was recorded as cost_usd=0.0 — and cost_usd is
the default promotion metric (lower wins), so a cost-less session scored as
free and could win promotion it never earned. Extract cost via measured_cost()
(None on absent/garbage, a measured 0.0 preserved), propagate None through
sum_sessions/aggregate/savings/report, and have the gate refuse to rank on a
metric that was not measured on every run in both arms.
* fix(eval): warn when ranking on the main-loop-only num_turns metric
num_turns comes from the CLI's top-level usage (main-loop session only), like
output_tokens, but selecting it emitted no metric_warning — so a subagent-heavy
candidate could look artificially efficient. Add num_turns to
MAIN_LOOP_ONLY_METRICS and broaden the warning to cover turns.
* fix(eval): fail closed when an overlay adds a file with no committed base
An overlay adding a new .md under gitnexus-{plan,work} passes the structural
overlay checks but has no committed base for committed_destination_base_digests
to bind against, so it raised an uncaught ValueError that crashed the evolve
driver (and runner --candidate-overlay) mid-run. Catch it at both call sites:
evolve reports NOT PROMOTED and exits, runner routes it through parser.error.
* feat(eval): circuit-break the runner sweep on a systemic outage
A sustained upstream outage used to pay out every remaining --timeout window
one session at a time. Track consecutive session/infra/cleanup failures via a
pure systemic_outage_streak helper; after --outage-streak (default 5) in a row,
stop the sweep, still write report.md/promotion.json from partial evidence, and
exit non-zero so evolve.py halts instead of proposing from truncated evidence.
A task's own resolved=False never trips the breaker.
* fix(cli): report a dirty working tree as stale in gitnexus status
status --json (and the human output) computed up-to-date from commit + runner
identity + completeness only, so a repo with uncommitted source changes at a
matching HEAD was reported up-to-date while analyze would still re-index it.
A graph-backed agent gating on that JSON could skip re-analysis on a stale
graph. Extract analyze's dirty-tree check into a shared isWorkingTreeDirty()
in storage/git and fold it into the status freshness decision.
* fix(ci): use single-slash deny globs in the review agent's disallowedTools
github.workspace already expands to an absolute path, so Read(/${{ github.workspace }}/**)
and Read(//proc/**),(//sys/**),(//dev/**) produced double-slash patterns that a
normalizing matcher may not match — silently no-opping the deny layer. Not
exploitable (the allowlist is the primary control and never grants those
paths), but the globs should be well-formed. Update the pinned test strings.
* ci: install gitnexus-shared with npm ci from the committed lockfile
The gitnexus-shared build floated its deps via npm install in three workflows
(skill-sync, ci-tests, and — most importantly — the release publish.yml) while
every other install step uses npm ci. The lockfile is committed and in sync, so
switch all three to npm ci for reproducible, locked installs.
* test(cli): make the shipped-skills drift guard reject symlinks
listFilesRecursive walked with readdirSync and snapshotDir read with
readFileSync, both of which follow symlinks — so a mirror file symlinked to the
canonical tree passed the byte-compare (and a symlinked mirror dir would be
followed too). Reject a symlinked root via lstat and any symlinked entry via
Dirent.isSymbolicLink, with negative tests (skipped on Windows).
* test(eval): guard the candidate-skill vs mirror-root coverage invariant
MIRROR_SKILL_ROOTS omits the Cursor tree, safe only because no candidate skill
is cursor-shipped. Pin that invariant: every CANDIDATE_SKILLS entry must exist
under canonical + every mirror root and must not ship to Cursor, so adding a
cursor-shipped skill to the candidate set (the PR #2488 asymmetric-sync class)
fails loudly instead of syncing three of four trees.
* docs(ci): describe the review agent's staged post-merge rollout
The DoD asked for a dry-run or triggered run before merge, but an issue_comment
(or newly added workflow_dispatch) workflow only ever executes the default-branch
copy, so it cannot be exercised from the PR that introduces it. Reword the DoD
and the activation checklist to a staged rollout: merge registered-but-disabled,
validate same-repo and fork execution post-merge, then enable the variable.
* fix: pin plugin skill mcp.json to the release version via #2445 tooling
The ten plugin skill mcp.json launched `npx -y gitnexus@latest mcp` on every
skill connect — non-reproducible and a supply-chain surface, and (unlike the
persisted setup config) never pinned. Extend sync-plugin-manifests.mjs with an
mcp surface kind that stamps the gitnexus@<version> launch arg, pin all ten to
1.6.9 now, and keep them byte-identical so the drift guard stays green. The
release lifecycle + publish.yml --check now re-stamp them like the four manifest
surfaces; only READMEs stay on @latest as docs.
* test(eval): prove the proposer's built-in file tools are confined
The real-Claude canary only exercised Bash + MCP, so it proved process/MCP
containment but not that the proposer's built-in file tools stay inside their
mounts. Add a canary over the exact PROPOSER_ALLOWED_TOOLS surface and the same
read-only /evidence mount as run_proposer (allowlist extracted to a shared
constant so it can't drift): Read reaches /evidence, a Write into the read-only
evidence mount is denied, and a Write lands in the output tree.
* fix(eval): apply the candidate overlay after task setup for fair arms
The candidate overlay was applied before the task's untrusted setup ran, so
setup could observe candidate prose and the incumbent/candidate arms started
from different pre-overlay state. Reorder within the sandbox: capture the base
(pre-overlay) skill digest, run setup against the base skills, verify setup did
not tamper them, then apply the overlay and capture the post-overlay digest the
model must preserve. apply_candidate_overlay stages path-specific overlay files,
so setup's uncommitted changes stay out of the baseline and churn is unchanged.
Graph freshness for the review arm is handled by the status dirty-tree fix plus
the review skill's stale-triggered re-index, not by reordering the cached
per-task-sha graph materialization (which is mechanically blocked).
* test(eval): end-to-end containment proof of the autonomous proposer
Drives the real run_proposer through bubblewrap with a deterministic scripted
model (no paid API): it reads the read-only evidence bundle and writes a
candidate gitnexus-plan skill edit plus a rationale into the sandbox output
tree; run_proposer enforces the trust boundary and copies only the validated
overlay + proposal out. This exercises the autonomous-proposal stage of the
self-evolution loop end-to-end in the eval/containment CI job (the gate and
apply stages are covered by test_workflow_bench_evolution and
test_promotion_apply). Env-gated on GITNEXUS_REQUIRE_CLAUDE_CANARY, so it runs
only where the pinned Claude binary and user namespaces are available.
* fix(eval): let the proposer author its overlay via Bash
Running the end-to-end proposer canary in the containment CI job surfaced a real
bug: run_proposer starts the session with --bare, which hard-disables the
Write/Edit tools ("Write exists but is not enabled in this context"), yet
allowlisted Edit/Write and omitted Bash. The proposer therefore had no working
way to write its candidate overlay — the self-evolution loop could never produce
a candidate. The sandbox settings already pre-authorize Bash
(autoAllowBashIfSandboxed) and confine writes to workspace/tmp/home, so switch
PROPOSER_ALLOWED_TOOLS to Read/Grep/Glob/Bash and tell the proposer to author
files with Bash. The end-to-end test now drives the real run_proposer through
bubblewrap and asserts a validated overlay + proposal are produced (this also
replaces the earlier file-tool canary, whose Write/Edit premise was moot).
* test(eval): author the proposer overlay with newline-free Bash content
The nested shell-sandbox prefix mangles embedded newlines, so the multi-line
overlay content never landed. Use single-line content for the deterministic
proposer canary.
* test(eval): drop the unverifiable end-to-end proposer canary
The scripted proposer overlay never materialized in the containment job across
runs, and the model tool-result content is not visible in CI logs, so the test
cannot be finalized without an environment where the sandbox can actually run.
Keep the verified production fix (Bash-authoring in run_proposer); the proposer
sandbox/containment stays covered by the existing Bash+MCP and process-tree
canaries.
* test(cli): drop run-analyze.ts from the windowsHide spawn-family list
U7 moved run-analyze.ts's only child_process call (the git status --porcelain
dirty check) into storage/git.ts (already covered by this test, with
windowsHide). run-analyze.ts no longer imports a spawn-family function, so the
windowsHide-regression test's 'must have >=1 spawn call' invariant failed for
it. Remove it from SRC_FILES.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Zander Raycraft <zanderjraycraft@gmail.com>
Co-authored-by: Azizur Rahman <azizur100389@gmail.com>
* feat(java): JLS 13.1 immediate-host naming for anonymous bodies + v9 schema window (#2555, step 1)
`synthesizeJavaAnonymousClassName` generalizes to both anonymous-body
shapes (`object_creation_expression` with a `class_body`; `enum_constant`
with a `body:` field) and switches from topmost-host naming to JLS 13.1
binary names: the `$`-joined chain of enclosing host types
(`EnumWrap$Mode$1`), numbered per IMMEDIATE host in source order across
both shapes (javac's shared counter). Every existing fixture's immediate
host is its top-level type, so existing names are unchanged — proven by
the 11 #2550 tests passing untouched, not assumed. The owner walk's
anonymous branch also fires on `enum_constant` now (the synthesis returns
undefined for body-less constants, so the walk continues to
`enum_declaration` as before).
Identity window: INCREMENTAL_SCHEMA_VERSION 8→9, parse-cache SCHEMA_BUMP
18→19, U-C5 pin extended with the v8-stamp rejection (enum-constant
methods re-key `E.hook`→`E$1.hook`; nested-host anons re-key
`EnumWrap$1`→`EnumWrap$Mode$1`).
Enum-constant Class-node emission and scope-side ownership land in the
next commits per
docs/plans/2026-07-18-gitnexus-plan-enum-constant-bodies.md (plan is
local — docs/ gitignored).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(java): model enum constant bodies as first-class instances (#2555, steps 2-4)
`enum E { A { void hook(){} } }` — javac's other anonymous-class shape —
joins the #2550 instance model:
- Structure: `(enum_constant body: (class_body)) @definition.class` in
JAVA_QUERIES; `enum_constant` in javaClassConfig.typeDeclarationNodes
with extractName synthesis. The shouldSkipClassCapture guard now also
covers enum_constant — without it, extract()'s name fallback would
fabricate a Class node from the constant's own identifier (`A`).
- Scope: `(enum_constant body: (class_body) @scope.class)` + synthesized
`@declaration.class`/`@declaration.name` anchored on the body, so the
constant's methods are owned (`ownerId`) and re-keyed
(`Method:...:EnumConst$1.hook#0`).
- Inheritance: a body-anchored `@reference.inherits` naming the HOST
ENUM (javac semantics: E$N extends E) — `mroFor(E$N) ∋ E`, so bare
calls from the body to enum helpers pass the ownership gate's MRO arm
while the same-file bare-call leak for constant-body method names is
closed (discrimination evidence: the #2549 review's archived S1b probe
showed the identical shape resolving `local-call` pre-fix).
- Nested-host JLS naming verified end-to-end: `EnumWrap$Mode$1` (not
`EnumWrap$1`).
- Bench: java scope-capture fingerprint rebaselined (new captures + two
fixtures), `measure.mjs --check` PASS across all 14 languages.
Verified: full java.test.ts 230/230 twice sequentially; TS 254 + JS/
Kotlin 289 (shared-file spot set); schema/scope/owner unit suites 90.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(java): exempt $-chain anonymous class defs from nested-class qualification (#2555 review)
Review lens probe caught a HIGH collapse: same-named methods across
sibling enum constant bodies attributed to the FIRST body's Method node
(`M3$1.hook -> M3.log` where the log() call lives in C's body; the
same-target sibling edge vanished entirely under dedup).
Root cause: `populateClassOwnedMembers`'s qualifier chains a
constant-body class def to `M3.M3$2` — its Class scope's parent is the
enum's Class scope, unlike OCE anons whose parent is a Function scope —
and its methods to `M3.M3$2.hook`. The structure-phase node id encodes
`M3$2.hook`, so the graph-bridge's qualified key misses and falls to
the file-wide simple-name lookup: first-write-wins.
Fix: `qualify()` now skips CLASS-LIKE defs whose name already carries a
`$` chain — a synthesized anonymous binary name is complete by
construction (JLS 13.1). Narrowly scoped: `$`-named MEMBERS (legal and
real in JS/TS) still qualify against their class, and named nested
classes (`Outer.Inner`, #1978) are untouched.
Discriminating regression test: same-name/distinct-target sibling
bodies must each own their edge, and the misattributed cross-edge must
not exist.
Verified: full java.test.ts 231/231; Python+Kotlin 459 (heaviest
populateClassOwnedMembers consumers) — zero assertion failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ci): prettier formatting + java bench rebaseline at the final corpus (#2555)
Two CI reds from the review-fix commit landing AFTER the bench
rebaseline: (1) prettier reformat of the new java.test.ts describe;
(2) the java scope-capture fingerprint drifted again because the
review fix added the java-enum-constant-same-name fixture to the
corpus — rebaselined at the true final corpus (196 fixtures,
ce104a76…, scaling 1.05 < 1.5), local `measure.mjs --check` PASS
across all 14 languages. Lesson honored going forward: the bench
rebaseline is the LAST artifact step — any post-review fixture
addition reopens it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(java): strict JLS 13.1 chaining through anonymous enclosing types (#2555)
Per review discussion: anonymous enclosing types now chain into the
binary name instead of flattening to the nearest NAMED host — the
immediately enclosing type per JLS 13.1 may itself be anonymous:
- anon inside an anon: NestHost$1$1 (was NestHost$2)
- anon inside an enum constant: N$1$1 (was N$2)
- named nested hosts (unchanged): EnumWrap$Mode$1
`nearestJavaAnonHost` becomes `nearestJavaEnclosingType` (named hosts OR
anonymous bodies); an anonymous enclosing type's prefix is its own
synthesized name (memo-bounded recursion); numbering is per immediately
enclosing type in source order. Top-level-hosted names are untouched —
the full existing suite passes unchanged.
New coverage: anon-in-anon chain, anon-in-constant-body chain (with
ownership), and a bodied constant in a NESTED enum (EnumWrap2$Mode$1 —
the one host combination previously untested). Rides the unreleased v9
identity window (doc wording tightened); java bench fingerprint
rebaselined at the final corpus, `--check` PASS across 14 languages;
prettier clean.
Verified: full java.test.ts 234/234 (one worker-crash flake rerun green
in isolation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(lbug): bound the LadybugDB buffer pool instead of the native 80%-of-RAM default (#2557)
createLbugDatabase passed bufferManagerSize=0, which the native runtime
sizes at 80% of physical RAM. A long-lived gitnexus mcp process (or a
large incremental analyze) could balloon to that ceiling — 19.5 GiB
observed against a 105 MiB on-disk index — and OOM-kill the host session.
Resolve the pool at call time: default min(2 GiB, max(64 MiB, 80% of
totalmem)), overridable via GITNEXUS_LBUG_BUFFER_POOL_SIZE (bytes);
0 deliberately restores the native unbounded default; invalid values
warn and fall back, mirroring GITNEXUS_WAL_CHECKPOINT_THRESHOLD.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): document GITNEXUS_LBUG_BUFFER_POOL_SIZE and GITNEXUS_LBUG_MAX_DB_SIZE (#2557)
Both env tables gain the new buffer-pool ceiling variable and the
previously code-comment-only GITNEXUS_LBUG_MAX_DB_SIZE, with the
mmap-vs-memory distinction the issue had to discover from source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(lbug): keep parseBufferPoolSize module-private
Review finding: the export had zero importers — parseWalCheckpointThreshold
earns its export via the CLI flag validation, but the buffer-pool CLI flag
was deliberately deferred. Re-export when a consumer exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(scope-resolution): stop platform builtins resolving to unrelated same-file symbols (#2545)
An unqualified call to a platform/language builtin (e.g. TypeScript's
global fetch()) could resolve to an unrelated same-file declaration
sharing that name, most visibly a Cloudflare Worker's
`export default { async fetch(req) {...} }` handler. Two contributing
gaps, both fixed:
- Object literals had no scope boundary in the TS/JS grammar queries,
so a method's/property-arrow's name auto-hoisted past the literal
into whatever lexically enclosed it (scope-extractor.ts's auto-hoist
logic had nowhere to stop). Give object literals a Block scope, like
6 other languages already do for lexical blocks.
- Independently, finalize's per-file bindings bucket
(materializeBindings in gitnexus-shared) flattens every local
declaration in a file onto its module scope for cross-file import
resolution, regardless of true nesting -- so free-call-fallback's
scope-chain walk could still hit the leaked binding at module scope.
Guard free-call resolution: when a match for a known builtin name
(LanguageProvider.isBuiltInName, already populated for TS/JS but
never consulted by this pass) has no binding reachable via the true
lexical scope chain, leave the call unresolved instead of emitting a
false CALLS edge.
Verified against the full TS/JS resolver suites plus every other
language populating builtInNames (Python, Go, C/C++, C#, Dart, Kotlin,
PHP, Ruby, Rust, Swift, Vue) -- 2333 tests, no regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(scope-resolution): extend the #2545 scope-leak fix to Kotlin and Java
Anonymous object-expressions (Kotlin `object { ... }`) and anonymous
class bodies (Java `new Runnable() { ... }`) have the same missing
scope-boundary gap that caused #2545 in TypeScript/JavaScript: a method
declared inside has no scope of its own to stop the auto-hoist at, so
its name leaks past the container into the enclosing scope.
- Kotlin: `(object_literal) @scope.class` (distinct from the already-
scoped named `object_declaration`/`companion_object`). Kotlin already
populates `builtInNames`, so free-call-fallback's isBuiltInName guard
(added for #2545) fully closes the equivalent leak here too --
verified with a `println`-shadowing regression test.
- Java: `(object_creation_expression (class_body) @scope.class)`,
matching PHP's existing `anonymous_class` handling. Java has no
`builtInNames` list, so the isBuiltInName guard doesn't engage --
the scope-tree fix is still correct and necessary (the anonymous
class's own methods are now owned by the right scope), but an
unqualified call to an unrelated same-file method sharing the
anonymous class's method name can still resolve via finalize's
per-file module-scope bucket (materializeBindings, shared/
language-agnostic, intentionally not touched by this PR). Documented
in the test as a known residual gap, same as TS/JS/Kotlin's own
non-builtin-name collisions.
Audited every other language for the same shape (a value/container
node with no @scope.* capture hosting a would-be-auto-hoisted named
declaration): PHP and Vue already handle it correctly (PHP scopes
anonymous_class; Vue's <script> delegates to the now-fixed TS/JS
query). Ruby, Python, Dart, C#, Swift, Go, Rust, and C/C++ have no
query pattern that treats a literal/container value position as a
named declaration in the first place, so the bug shape can't occur
there.
Verified: full Kotlin + Java resolver suites, 468 tests, no
regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(scope-resolution): dedicated Object scope kind for object literals (#2545, #2551)
Review of the #2545 fix surfaced two defects, both fixed here:
1. The isBuiltInName guard suppressed genuine cross-file imports whose
name matches a builtin (`import { fetch } from './fetch-polyfill'`
silently stopped resolving -- verified regression vs. main). The
leak the guard targets is inherently same-file (finalize's flat
bucket is per-file), so the guard now also requires
`fnDef.filePath === parsed.filePath`. New regression test covers
the polyfill-import shape.
2. The sibling-property case of the reported bug was still broken and
masked by a tautological assertion (`c.reason` -- a property that
doesn't exist; the real path is `c.rel.reason` -- so the test
passed regardless of behavior). In
`export default { fetch() {...}, handler: () => fetch(...) }`,
`handler`'s bare `fetch()` still resolved to its sibling. Reusing
the `Block` scope kind was the root cause: correct for a real
lexical block (a nested closure legitimately sees a sibling
`let`/`const` from an enclosing `if`/`for`), wrong for object
literals, whose members are reachable only via property access --
never as bare identifiers, not even by sibling property bodies.
Fix: a dedicated `Object` ScopeKind (gitnexus-shared) -- a hoist
boundary whose own bindings scope-chain walkers never consult while
still traversing past it to the parent. TS/JS object literals now
emit `@scope.object`; the four chain walkers in
scope-resolution/scope/walkers.ts (walkScopeChain,
findAllCallableBindingsInScope, findCallableBindingsAndAdlBlocker,
findExportedDefByName) and free-call-fallback's
hasGenuineLexicalBinding skip Object scopes' bindings. Kotlin's
anonymous `object {}` keeps `@scope.class` -- unlike JS object
literals it has real implicit-this sibling dispatch.
Verified with the full resolver matrix run sequentially (TS 254, JS/
Kotlin/Java/Python/Go + TS variants 960, C/C++/C#/Dart/PHP/Ruby 1049,
Rust/Swift/Vue/Cobol + route/flow/unit suites 828, scope-extractor/
scope-tree units 51). Worker-pool crashes under parallel suite load
reproduced on unrelated files and pass in isolation (known flake, not
caused by this change).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* feat(java): model anonymous class bodies as first-class Class nodes (#2550, step 1)
`new Runnable() { public void run() {} }` now emits a synthesized
javac-style `Class` node (`Worker$1`, `$N` = source order within the
top-level class) and owns its methods: the enclosing-owner walk
attributes `run` to `Worker$1` (re-keyed `Method:...:Worker$1.run#0`,
HAS_METHOD from the anonymous class) instead of the lexically enclosing
named class.
- `synthesizeJavaAnonymousClassName` (ast-helpers): single naming
authority for every layer that keys the anonymous class; returns
undefined for `object_creation_expression` without a `class_body`
child, which also keeps it a no-op for C#'s same-named node type.
- `findEnclosingClassInfo`: anonymous-body branch before the generic
container walk.
- JAVA_QUERIES: `(object_creation_expression (class_body))
@definition.class` (no @name); `getLabelFromCaptures` now lets a
nameless `definition.class` through — the parse-worker's existing
`!nameNode && !extractedClassSymbol` gate still drops any nameless
class the extractor cannot name, so other languages are unaffected.
- `javaClassConfig.extractName` synthesizes the name on the extractor
path (worker node emission).
- Node identities move on unchanged files: INCREMENTAL_SCHEMA_VERSION
7→8 and parse-cache SCHEMA_BUMP 17→18 (the v5 Route-identity
precedent) force full re-analyze / cache invalidation.
Verified: new #2550 identity tests + resolve-enclosing-owner and
has-method suites (53 tests) green.
Prep for step 2/3 (scope-side ownership + receiver typeBinding) and the
free-call instance-ownership gate per
docs/plans/2026-07-18-gitnexus-plan-java-instance-scoped-freecalls.md
(plan file is local — docs/ is gitignored by repo policy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(java): instance-scoped free-call resolution for anonymous-class methods (#2550, steps 2-4)
Completes the #2550 instance model on top of the Worker$N identity
commit:
- Scope-side ownership (java/captures.ts): synthesize
`@declaration.class` + `@declaration.name` (`Worker$N`) anchored on
the anonymous `class_body` — same range as its `@scope.class`, so the
def lands in that Class scope's ownedDefs, `populateClassOwnedMembers`
stamps `ownerId` on the anonymous class's methods, and the name
auto-hoists exactly like a named class declaration.
- Receiver typeBinding (java/captures.ts + type-extractors/jvm.ts):
`Runnable handler = new Runnable() { ... }` binds `handler` to the
ANONYMOUS class (`Worker$1`), not the declared JDK interface — in both
the scope-side TypeRef channel (receiver-bound Case 4) and the worker
typeEnv. `handler.run()` now resolves through the receiver path
(reason 'global', target `Worker$1.run#0`) instead of depending on
the free-call finalize-bucket leak — which is why the prior gate
attempt broke it (the #2550 landmine, now explained and structurally
removed).
- Instance-ownership gate (free-call-fallback.ts + contract + run.ts +
java opt-in): with `ScopeResolver.freeCallsRequireInstanceOwnership`,
a free call may resolve to a `Method` only when the caller's
enclosing class chain (self + MRO via `scopes.methodDispatch.mroFor`)
contains the method's owner. Same-file matches only — the
`materializeBindings` leak is per-file; cross-file Method matches come
through genuine import channels (suppressing them broke the
arity-narrowing parity suite, verified). Suppressions recorded as
`'free-call-instance-ownership'` outcomes. Java opts in; every other
language is byte-identical (flag off).
Result on the #2545 fixture: `process()`'s bare `run()` emits NO edge
to the unrelated anonymous method (the #2550 bug, closed), while
`handler.run()`, same-class implicit-this dispatch, and bare inherited
calls (MRO arm) all keep resolving.
Verified: full java.test.ts 223/223 twice sequentially (landmine gate);
cross-language matrix (TS/JS/Kotlin/Python/Go/C/C++/C#/Dart/PHP/Ruby/
Rust/Swift/Vue/Cobol + callable-value-flow + java-class-impact + core
units) — zero assertion failures; worker-crash flakes re-verified green
in single-file isolation.
Known deferral (documented): EXTENDS/IMPLEMENTS edges from the
anonymous class to its constructed type are not yet emitted, so a
same-file inherited-but-not-overridden member called ON the anonymous
instance does not resolve through the anon MRO; tracked as the
follow-up in #2550.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* fix(java): anonymous-class inheritance, host coverage, and phantom-node guard (#2550 review)
Self-review of the instance model (gitnexus-review with empirical lens
probes) surfaced three defects, all fixed:
1. HIGH — the ownership gate suppressed TRUE bare calls to inherited
methods inside an anonymous body extending a same-file class
(`new Base() { void extra() { work(); } }` lost `extra -> work`):
the anon class had no inheritance edge, so `mroFor(Worker$N)` was
empty and the MRO arm could never pass. The synthesis now emits an
`@reference.inherits` for the constructed type, anchored on the
`class_body` so the reference's enclosing class resolves to the
SYNTHESIZED def (anchoring on the type node would sit outside the
anonymous scope and attribute the edge to the wrong class). Anon
classes now get real EXTENDS/IMPLEMENTS edges and inherited bare
calls pass the gate.
2. MEDIUM — hostless anonymous bodies materialized a phantom Class
node named after the CONSTRUCTED type (`Class:...:Runnable`) via
extract()'s extractTypeNameFromNode fallback. New
`shouldSkipClassCapture` in javaClassConfig drops the capture when
no name can be synthesized.
3. MEDIUM — enum/interface/record-hosted anonymous bodies silently
fell back to the pre-#2550 model (mis-attribution + open leak).
The topmost-host walk now accepts all four host type declarations
(JAVA_ANON_HOST_TYPES), so `EnumHost$1` etc. are modeled; the
phantom-node shape disappears for those hosts as a side effect.
Also: per-parse-tree WeakMap memo for the `$N` numbering — the helper
is called from four independent layers per anonymous body and each call
re-scanned the host subtree (`descendantsOfType`), quadratic on
anon-heavy files (old-style listener-per-widget Java); and the
scope-capture bench fingerprints rebaselined for java/typescript/
javascript/kotlin (`measure.mjs --check` now passes all 14 languages —
it failed for every scope query this PR touched; drift notes added per
the file's convention).
Verified: full java.test.ts 225/225; all 11 #2550 tests including the
new anon-extends-base and enum-host scenarios; bench --check PASS.
Known remaining (documented, unchanged-old behavior): enum CONSTANT
bodies (`A { ... }`) stay unmodeled; nested-host naming is top-level-
anchored (`EnumWrap$1`, not javac's `EnumWrap$Mode$1`).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* test(storage): update the INCREMENTAL_SCHEMA_VERSION pin to v8 (#2550)
The U-C5 reuse-gate test deliberately pins the exact schema version so
a bump cannot land without consciously extending the gate expectations.
Extend for v8 (Java anonymous-class node identities, #2550): a v7 stamp
now fails the strict-equality reuse gate — a pre-v8 index would strand
old `Worker.run`-keyed Method nodes alongside the re-keyed
`Worker$N.run` ones on unchanged files — and v8 passes.
Caught by CI (tests/ubuntu coverage shard 2/3 on PR #2549); the local
matrix had not included this unit file. All 7 schema-referencing unit
suites verified green (109 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(analyze): degrade FTS search instead of aborting analyze on index-build failure
createSearchFTSIndexes re-tokenizes every stored row on every analyze run
(full or incremental). A native LadybugDB tokenizer error on a single
pre-existing row (e.g. "Failed calling LOWER: Invalid UTF-8") previously
propagated uncaught out of run-analyze.ts's main FTS phase, discarding an
otherwise-successful run's graph/embeddings work every time analyze ran
thereafter.
Add buildSearchIndexesOrDegrade(), which catches build/verify failures and
lets analyze finish with keyword search degraded for that run instead —
mirroring the existing sibling degrade path for a missing FTS extension.
The dedicated --repair-fts path is untouched and still fails loudly.
Fixes#2544, #2546.
* fix(analyze): keep capabilities.fts/ftsSkipped honest when index build degrades
ftsSkipped and capabilities.fts.status were keyed only on ftsAvailable
(extension loaded), which the new degrade path leaves true even when the
index build itself failed. Track that outcome in ftsReady and use it for
both, and update run-analyze-fts-repair.test.ts's coverage of this path
from asserting the old throw to asserting the new degrade contract
(ftsSkipped, log message, meta.json capabilities.fts.status).
* docs(plans): add provider-hook value-refs plan (#2437)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(plans): deepen #2437 plan to USES + property-dispatch design
Design revised after prior-art research (Kythe ref vs ref/call, Joern
METHOD_REF, Feldthaus field-based call graphs, CodeQL impliedReceiverStep):
registration sites emit reference-class USES, invocation is recovered by a
field-based property-dispatch pass synthesizing CALLS at member-call sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(scope-resolution): model provider-hook value references (#2437)
Functions referenced as object-literal property values (provider hooks like
emitScopeCaptures: emitCppScopeCaptures) previously produced no edge at all,
so impact/context reported a false-safe 0 upstream dependents.
Two coordinated halves, per prior art (Kythe ref vs ref/call, Joern
METHOD_REF, Feldthaus ICSE'13 field-based call graphs, CodeQL
impliedReceiverStep):
- Registration -> USES: new ReferenceKind 'value-ref'; TS/JS queries capture
pair values and shorthand properties (with @reference.property-key);
emitted as a reference-class USES edge, reason 'scope-resolution:
value-ref'. Resolution is callable-gated so plain values emit nothing.
- Dispatch -> CALLS: new shared pass emitPropertyDispatchCalls synthesizes
CALLS (reason 'property-dispatch', confidence 0.7, per-key fan-out cap 32
calibrated on this repo's 16-provider hook tables) from member-call sites
to every function registered under the same property key.
Deviation from plan: the pass owns value-ref resolution entirely via the
post-finalize findCallableBindingInScope walker — the shared registries only
see pre-finalize local bindings, so imported hooks (the c-cpp.ts case) were
unresolvable through lookupForSite; Reference.propertyKey passthrough
dropped as unnecessary.
SCHEMA_BUMP 13 -> 14: ParsedFile gains value-ref sites + propertyKey.
Verified end-to-end: impact(emitCppScopeCaptures, upstream) now reports 8
impacted / HIGH with extractParsedFile (true dispatch caller) at d=1 via
property-dispatch and the c-cpp.ts registration via USES.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(scope-resolution): cover value-ref registration and property dispatch (#2437)
Integration: same-file/cross-file/aliased/shorthand registrations emit USES;
non-callable and destructuring values emit nothing; dispatch sites gain
property-dispatch CALLS (incl. JS twins and per-language partitioning);
fan-out-capped keys are dropped entirely; factory-call values unchanged.
Unit: capture-shape pins for @reference.value-ref + @reference.property-key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): surface dropped property-dispatch keys in stats (#2437)
Review finding: skippedKeys was returned but discarded — a hook table
larger than the fan-out cap silently reopened the #2437 gap for those
keys. Log dropped keys and fold value-ref USES + dispatch CALLS into
referenceEdgesEmitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(plans): add callable reference-flow implementation plan
* fix(scope-resolution): close property-dispatch review gaps
* feat(scope-resolution): add callable flow facts
* feat(scope-resolution): resolve callable value flow
* feat(scope-resolution): resolve callable references across providers
* fix: harden callable reference flow resolution
* fix(scope-resolution): preserve callable binding semantics
* docs(plans): add pr-2522-review-fixes plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): bump INCREMENTAL_SCHEMA_VERSION for callable-value-flow edges
Callable-value-flow CALLS/USES edges (#2437) can connect two files whose
content did not change, but the incremental write set only covers changed
files — a top-up against a pre-v7 index would silently omit the new edges
for every unchanged file pair, indefinitely. Force the one-time full
re-analyze (review finding 1, #2522).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): sanitize callable-flow sites per-site at load, log drops
The load-time validator rejected the WHOLE ParsedFile when one site was
malformed or over-bound, with no logging — and C++ legitimately emits
empty-string parameterTypes entries ('' = unknown, the
ReferenceSite.argumentTypes convention) for cv-only/ERROR-recovered types,
so real repos fell into a permanent, silent warm-cache-miss reparse loop
through the #1983-sensitive main-thread path (review finding 7, #2522).
Now: '' entries are valid in type arrays; a malformed/over-bound site drops
only itself (counted, warned once per load); only non-array garbage —
evidence the serialization itself is untrustworthy — rejects the file.
Deviation from plan §6 wording: validator-side tolerance replaces emit-side
clamps — smaller diff, same asymmetry closed at the single chokepoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): keep declarations in the union for reassigned callable cells
The binding-lookup suppression for fact-constrained cells was wholesale:
reassigning a declared function through its own name (greet = other;
greet()) deferred the call to the solver, which then refused the lexical
lookup that resolves the declaration — an unresolvable RHS yielded zero
CALLS for a call that resolved pre-flow (review finding 8, #2522).
Suppression now applies only to cells bound by FORMAL facts — its actual
purpose (a parameter whose grammar emits no declaration binding must not
adopt a same-named outer function). Copy/alias/store/load destinations keep
their declaration as an inclusion seed (Andersen-style union).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): count forfeited deferred sites in the budget-bailout warning
On work-budget exhaustion the deferred invoke sites end the run with zero
CALLS — free-call fallback and reference emission already skipped them —
but the warning said 'ordinary graph emission remains untouched', which is
false for exactly those sites. The warning context now carries the
unresolved deferred-site count and the comment states the real cost
(review finding: budget-bailout honesty, #2522).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(scope-resolution): surface dropped property-dispatch keys in stats and warn payload
The over-cap warning carried only a count; the dropped key NAMES were
discarded and RunScopeResolutionStats had no field, so the PR-body claim
'includes them in resolver statistics' was unimplemented (review finding,
#2522; reviewer ask on the fan-out cap). The warn payload now names up to
20 dropped keys and the stats carry propertyDispatchSkippedKeys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(scope-resolution): drop producer-less ownerQualifiedName from formal sites
No capture emitter anywhere produces @callable-flow.owner-qualified-name —
the solver branch consuming it was unreachable in production, yet the field
was typed, parsed, validated, and unit-tested with hand-built input (review
finding 16, #2522; YAGNI). Re-add with a real producer if C++ qualified
member declarators ever need it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(scope-resolution): drop dead callable-flow knobs
CallableFlowPassingMode 'callable-object' had no producer and no consumer
distinguishing it, and CallableFlowCaptureOptions.extractCallArguments had
no language providing it (unlike its live sibling extractCallCallee) —
review finding 17, #2522 (YAGNI). The invocation-kind 'callable-object'
is a different, live concept and stays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): bind subscripted callable cells to the container, not the index
terminalIdentifier iterates children in reverse, so tbl[i] = handler seeded
the INDEX variable's cell (polluting a same-named formal) and tbl[i](7)
looked up the callee under i in a different scope — no join, no CALLS edge
for the classic function-pointer-array dispatch (review finding 12, #2522).
Subscript nodes now recurse into their container field only, in both
bindingIdentifier and terminalIdentifier, across the fielded grammars
(C/C++/JS/TS/Python/Go/Java).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): make cross-function file-scope callable bindings resolvable
Two stacked gaps killed the canonical C callback-registration pattern
(fp assigned in init(), called in run()) — the exact #2437 false-safe this
PR exists to fix (review finding H1, #2522):
1. isVisibleValueBinding only consulted assignment regions and formals, so
a call in a function OTHER than the assigning one emitted no invoke
fact. A declared callable-typed binding is now a value binding wherever
its declaration is visible (visibleCallableSignature).
2. The C scope query had no @declaration.variable pattern for function-
pointer declarators — void (*fp)(int); created no scope-tree binding,
so the seed (init) and invoke (run) cells canonicalized to different
keys and never joined. Both bare and initialized forms now bind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(c): detect variadic parameters via the named variadic_parameter node
tree-sitter-c materializes '...' as a named variadic_parameter node; the
anonymous-token checks never matched, so variadic function-pointer
signatures were emitted with a wrong fixed arity and no '...' sentinel
(review finding, #2522). C++ is unaffected ('...' stays an anonymous token
there); the token checks remain for such grammars.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): emit invoke facts for field-stored callable member calls
The C ops-vtable pattern (o->run = handler; o->run(1)) captured the store
but never the call — the member path in emitCallFacts bailed for languages
without protocol methods, and the value-binding index recorded the member
store under the OBJECT's name ('o'), not the member's ('run') (review
finding 11/M3, #2522). Member destinations now also record their terminal
member name, and a member call whose name-cell has a visible store emits an
indirect invoke — gated on the store so plain accessor calls (map.get)
stay inert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cpp): disambiguate (obj->*ptr)() ERROR recovery by token order
tree-sitter-cpp groups the recovered '->*' two ways depending on
error-recovery cost (identifier lengths): [identifier, ERROR '->*m'] or
[ERROR 'obj->*', identifier]. The recovery assumed the first shape, so the
second silently swapped receiver/member and dropped the call site — the
committed test passed only by name luck (review finding H2, #2522). The
identifier's position relative to '->*' inside the ERROR now decides roles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cpp): class members are never file-local in hasFileLocalCallableLinkage
The name-keyed file-local set is populated from every static declaration,
so an in-class 'static void make();' (external linkage — in-class static
means no-instance) and any member sharing a name with a static free
function were over-marked, refusing legitimate cross-file
declaration/definition joins (review finding 13/M2, #2522). Method and
Constructor defs now bypass the name-set, per the hook's own linkage-only
contract.
Deviation from plan step 13: the regression is a unit-level contract pin
rather than an end-to-end join test — C++ merges out-of-line member
definitions onto the member node by qualified identity, so the graph shape
cannot discriminate the join refusal for members.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cpp): classify parameter passing mode from the declarator chain only
A whole-subtree scan for reference_declarator inverted copy vs alias:
void reg(void (*cb)(int& out)) marked the by-value pointer cb as
'reference' because of the NESTED parameter's int&, making the solver
back-propagate formal targets into every caller's argument cell — alias
semantics for a copy (review finding 14/M5, #2522). The chain walk never
descends into nested parameter lists; a reference anywhere ON the chain
(int& x, void (*&cb)(int)) still aliases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ruby): bare identifiers are calls, not callable references
Ruby parses a receiver-less zero-arg method call identically to a variable
read, so 'action = process' — which CALLS process and stores its return —
seeded action with the callable and minted a wrong CALLS edge from any
dispatch through it, confirmed end-to-end (review finding 15/HIGH, #2522).
New provider knob bareNamesAreCalls: a bare name that is not a provably
local value binding and not an explicit reference form (method(:x),
lambda/proc) emits no flow fact, on both the assignment and argument paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(go): pair multi-value := positionally instead of cross-wiring
The shared field fallback took the FIRST LHS identifier and the LAST RHS
identifier of Go's expression_list pair, cross-wiring 'a, b := f, g' and
synthesizing a garbage comma-joined qualified name — the real relationships
were silently dropped (review finding 16, #2522). extractAssignment may now
return multiple pairs; Go pairs list entries positionally and emits nothing
for a length mismatch (multi-return call RHS).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(java): drop get/test from callableProtocolMethods
'get' and 'test' collide with ubiquitous non-functional-interface APIs
(Map/List/Optional/Future.get), so every ordinary container access emitted
a spurious callable-object invoke fact — high-volume misleading graph facts
with a cross-wiring risk on receiver-name reuse (review finding 17, #2522).
Supplier.get/Predicate.test dispatch is deliberately traded away until the
check can gate on the receiver's declared type.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(rust): pin the qualified-name no-degrade guard as a hard invariant
Rust's scoped_identifier callable-reference capture over-includes unit enum
variants and associated constants (Shape::Square seeds as if callable);
they stay edge-free only because resolveSeedCandidates refuses to degrade
an unresolved qualified name to a simple-name lookup (review finding 18,
#2522). Capture-side type filtering would false-negative on tuple-variant
constructors, so the guard IS the contract: documented as a hard invariant
(Go's mis-shaped multi-value forms also rely on it) and pinned end-to-end.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(php): remove nonexistent optional_parameter node type
tree-sitter-php has no 'optional_parameter' — defaults ride on
simple_parameter — so the entry was dead weight the #1920 literal gate
does not cover for capture-option Sets (review finding 19, #2522).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cobol): detect procedure pointers on fixed-format sources
Two stacked defects made the feature a no-op on classic sequence-numbered
fixed format (review finding 20/H3, #2522):
1. parseDataItemClauses' USAGE alternation knew POINTER but not
PROCEDURE-POINTER/FUNCTION-POINTER, so the dataItems filter was dead.
2. The raw-line fallback scanned UNCLEANED text, where the sequence number
satisfied the leading digits and the LEVEL NUMBER got captured as the
pointer name. It now scans preprocessed lines and requires a letter-
initial name (COBOL data names must contain a letter).
161 COBOL preprocessor/copy-expander tests stay green; free-format matrix
case unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cobol): skip comment lines in SET seed/copy scans
A commented-out SET (indicator-column '*'/'/' or free-format '*>')
produced a live seed and a false CALLS edge from dead code (review
finding 21/M1, #2522). The scan now skips indicator-column comment lines
and strips inline '*>' tails before matching.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(architecture): document callable-flow-only mode and skipped-key reporting
The Callable-value flow section omitted scopeResolutionEdgeMode:
'callable-flow-only' — a real emit-pipeline branch that suppresses all
ordinary emission for standalone providers (review finding 22, #2522) —
and predated the skipped-key names/stats surfacing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(scope-resolution): correct value-ref resolution attribution and stale pdg-gating comments
The value-ref contract comment claimed MethodRegistry resolution — the
mechanism is the post-finalize findCallableBindingInScope walker owned by
emitPropertyDispatchCalls (resolveReferenceSites skips these sites). Three
'only under --pdg' calleeIdSink comments were falsified by the #2437 gating
change (callee-id-sink.ts's header was updated; these copies were missed).
Review finding 23, #2522.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(ingestion): direct unit coverage for synthesizeCallableFlowCaptures
The 1,100-line shared synthesizer had no test naming it — only downstream
consumers were covered (review finding 24, #2522). Pins seed/invoke/
formal/argument emission, subscript container binding, store-gated member
invokes, produced-value guards, and the bareNamesAreCalls knob over a
minimal options object so assertions target the synthesizer's own
semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(resolvers): deepen shallow-language coverage; fix Kotlin/Swift reassignment gaps it exposed
Adds the COBOL SET x TO y copy-branch scenario and conditional-assignment
scenarios for Kotlin, C#, Swift, and Dart (10 languages previously had one
generic case each — review finding 25, #2522). The new scenarios exposed
two real capture gaps, fixed here:
- tree-sitter-kotlin's 'assignment' node is fieldless, so nested
reassignments (chosen = ::target inside a block) produced no flow facts;
Kotlin's extractAssignment now decomposes it positionally.
- tree-sitter-swift fields its assignment as target:/result:, neither in
the shared fallback's field lists; both added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(infra): literal-validation gate for callable-capture option Sets
The #1920 gate validates query literals and exported configs but not the
module-private *_CALLABLE_CAPTURE_OPTIONS Sets consumed by the shared
synthesizer — a typo'd node type silently captures nothing (PHP shipped a
dead 'optional_parameter'; review finding 26, #2522). Every <key>NodeTypes
Set literal is now validated against its language's grammar; name-carrying
sets (callableProtocolMethods, memberPointerOperators) are deliberately
outside the contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(storage): centralize corrupt-fixture casts into makeStoreEntry
The callable-flow store tests scattered 'as unknown as' double-casts per
fixture (review finding 27, #2522; standing no-as-any rule). One typed
helper now owns the single controlled escape hatch for building malformed
serialization-boundary payloads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(bench): refresh capture fingerprints after review fixes
python-scope: the committed baseline (8d5c3699) never matched this
branch's code — CI's benchmarks arm was red on the PR head (review
finding 2/HIGH, #2522); regenerated (a99e69ab), scaling 1.04 in budget.
scope-capture: ruby/cpp/swift/java/kotlin drifted from the review-fix
commits (bare-name suppression, passing modes + ->* recovery, assignment
fields, protocol narrowing, positional assignment); all 14 languages
re-verified PASS with ratios <= 1.18 against the 1.5 budget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(docs): untrack docs/plans working documents
docs/ is gitignored (local working docs); the plan files were force-added
past the ignore. Untracked from the index only — they stay on disk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(golden): regenerate captures goldens after callable-flow review fixes
The per-language digest guards (csharp/go/php/python/ruby/rust/swift)
locked the pre-fix capture output; the review-fix series intentionally
changed it — store-gated member invokes, subscript container binding,
Ruby bare-name suppression, Swift assignment fields, positional pairing.
Regenerated with UPDATE_GOLDEN=1; clean verification run 59/59; all other
parity/golden guards (pipeline-graph, spring-route, python parity) pass
untouched at 33/33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): prototypes are callees, not callable value cells
The cross-function visibility fix indexed EVERY signature-bearing
declaration as a value binding — including plain function/method
prototypes (void f(int);). Every call to a declared function then became
an indirect invoke, and with emitCanonicalInvokeReference (C/C++) minted a
free-call reference that resolved through the registry, bypassing the
precise passes' two-phase/ambiguity/subobject suppression — eight phantom
CALLS edges in the cpp resolver suite on CI.
Only declarations whose binding identifier sits under a pointer/
parenthesized declarator (callable-typed variables like void (*fp)(int);)
create value cells now. cpp resolver suite 331/331; callable-value-flow +
C/C++ suites 181/181 (the cross-function fp regression still passes); cpp
fingerprint rebaselined, both bench gates PASS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(wiki): allow explicit HTTP LLM hosts
Keep wiki LLM HTTP endpoints fail-closed by default while adding a narrow exact-host opt-in for LAN/self-hosted models.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(wiki): simplify insecure LLM flag name
Rename the wiki HTTP opt-in flag to --allow-insecure-connection per review feedback.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(wiki): simplify insecure connection env
Rename the wiki HTTP allowlist environment variable and align validation errors with the CLI flag naming.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
The busiest Windows platform shard reached 14m57s against the 15 minute
watchdog on the rc.19 green run and has timed out once since. CI now
sets GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES=20 (the job timeout stays
25), the stale comfortably-under comment reflects reality, and the
runner always logs status, signal, spawn code and elapsed time so the
next status-null death is diagnosable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RC path bumped only gitnexus/package.json, so every v1.6.10-rc tag
through rc.28 shipped the four plugin manifest surfaces frozen at 1.6.9
and failed its own unit suite. The npm version lifecycle script now
runs a fail-closed sync whenever npm version executes, in CI or on a
maintainer's laptop; publish.yml verifies the result and stages the
surfaces into the detached release commit, and the stable path refuses
to publish a tag whose manifests drifted. The sync is textual so a
release commit carries a one-line change per surface instead of
reformatting churn.
Design follows the proposal by @100yenadmin in #2445, moved onto the
standard npm version hook.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The skip-git ignore test asserted the error payload while relying on
exit 0; since the output() guard an error payload also exits 1, so the
test now captures the payload from the exec failure and pins both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zero-symbol File fallback from #2455 writes File embedding rows,
but the filePath-scoped delete sweeps joined through EMBEDDABLE_LABELS
only. Docs repos accumulated duplicate rows on re-analyze and deleted
files left orphans. Free for code repos: no File rows exist to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the #2469 guard from cypherCommand into output() so all seven
tool commands that print backend results share the exit semantics.
Adds query and context regression cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An fs failure while resetting a chunk generation now warns and
continues like the neighboring durable-store paths instead of failing
the analyze. Workers recreate the directory on write.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Composed with the read-only and repository policies in the CallTool
handler: read-only assert, then budget resolution, then scoped dispatch
with the transport arg stripped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The C#, Java and Go flag-off digests shift with the canonical community
projection from #2478 stacked on the walker sort from #2482. Regenerated
via vitest -u; mini-repo pipeline golden was already correct.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merged with the read-only policy: both filters compose in server.ts.
Review hardening: assertResourceUri compares the opaque host case
insensitively (GITNEXUS://GROUP bypass) and gains the fail-closed
fallback for unparseable URIs; the backend proxy now also intercepts
queryClusters/queryProcesses/queryClusterDetail/queryProcessDetail;
scrubGroupDescription is shared from read-only-policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crossDepth and subgroup are inert outside @group routing, but rejecting
them keeps the scrubbed schema and the dispatch contract in agreement.
Also documents that resource content scrubbing is cosmetic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the missing #2481 Rust regression test (importer before definer),
fails closed when a PHP namespace suffix matches directories under
different roots, and points the baselines note at #2481/#2482.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Non-loopback eval-server binds now require GITNEXUS_AUTH_TOKEN with an
exact Bearer header compared in constant time. Review fixes: loopback
binds defer unreadable env file errors to a warning, and help text
notes IPv4 hostname resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loopback binds now warn and start when .env or .env.local exists but
cannot be read; non-loopback binds keep the fail-closed error. Help
text notes that hostnames resolve to IPv4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Initial plan
* fix(mcp): read fresh metadata from disk in context resource to fix stale staleness banner and stats (#2438)
The context resource (gitnexus://repo/{name}/context) was serving stale
lastCommit and stats after an out-of-process `analyze --index-only` refresh.
The LocalBackend's in-memory RepoHandle is cached and only refreshes on
registry misses — never on a disk update from an external process.
Fix: read fresh metadata via loadMeta(repo.storagePath) on every context
resource read, mirroring the ensureInitialized hot-swap pattern. Use the
fresh lastCommit for the staleness check and fresh stats for the stats
block; fall back to cached values when the disk read fails.
Tests:
- 5 new unit tests in resources.test.ts covering fresh meta, fallback behavior
- 3 new integration tests in context-resource-staleness.test.ts covering
the exact reproduce sequence from the issue report
* fix(test): use @ladybugdb/core mock to prevent native addon load in integration test
The context-resource-staleness integration test used importOriginal() in
the vi.mock factories for pool-adapter.js and mcp/core/lbug-adapter.js.
That caused the real pool-adapter.ts to load @ladybugdb/core, which
tries to dlopen lbugjs.node — a native addon that requires postinstall
(not run with --ignore-scripts in CI).
Fix:
- Add a vi.mock('@ladybugdb/core') at the package boundary, matching the
pattern in lbug-pool-fts-load.test.ts and pool-wal-recovery.test.ts.
- Replace the two importOriginal()-based lbug mocks with direct factories
that provide all necessary exports (initLbug, executeQuery,
executeParameterized, closeLbug, isLbugReady) without loading the real
module.
All 3 integration tests now pass in CI (no lbugjs.node required).
* test(integration): run context staleness flow end-to-end without mocks
* Address PR review feedback (#2439)
- Restore libc selectors for optional native packages
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Some agents prefer repo-local .agents/skills/ over the global install. When
.agents/ is present, mirror the standard and generated skills written to
.claude/skills/ so those agents serve up-to-date copies. Opt-in via .agents/;
absent directory leaves the layout untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(web): use repo path identity in switcher
* keep repo URL project names stable
* fix server repo path resolution
* fix repo path miss resolution
* fix(server): guard clone-dir deletion with path ownership check
Deleting a registry entry derived its clone dir from the entry NAME with
no ownership check, so deleting a local repo that shares a display name
with a server-cloned sibling wiped the sibling's checkout. Gate the
removal on cloneDirBelongsToEntry (canonicalized path equality), the
same entry.path-driven rule the handler's step 2b already mandates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(server): fail closed on relative repo params and rate-limit GET /api/repo
Relative separator-containing ?repo= values (org/name, ./repo) were
canonicalized against the server CWD — an attacker-influenced
realpathSync probe on an un-rate-limited GET — before failing anyway.
Reject them immediately without touching the filesystem, drop the
redundant path.sep clause, document the resolver's two-tier contract,
and wire createRouteLimiter on GET /api/repo like its DELETE sibling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(server): lock repo resolver branches and register for Windows CI
Lock in the resolver's remaining branches: first-wins for ambiguous
bare names, Windows-shaped input as a fail-closed path claim, the
repos[0] default, and the case-insensitive name fallback. Register the
suite in cross-platform-tests.ts so windows-latest actually runs the
path-shape logic it exists to protect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(web): single repoIdentity helper with repoPath normalized end-to-end
The identity fallback chain was copy-pasted in Header and RepoLanding
while backend-client already owns BackendRepo and the repoPath
normalization. Export one repoIdentity helper, normalize fetchRepos
like fetchRepoInfo, and emit repoPath from GET /api/repos so the
scheme no longer silently relies on /api/repo.repoPath equalling
/api/repos.path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): persist and restore repo path identity in the URL
The URL persisted only ?project=<display name>, so refreshing after
switching to a duplicate-name repo silently restored the first
same-named sibling. Persist ?repo=<server-resolved path> alongside the
readable ?project= at both write sites, prefer it on restore (legacy
project-only URLs still work), keep failed path restores fail-visible
(no name fallback), and strip stale identity params when deleting the
active or last repo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): analyze completion connects by path identity
RepoAnalyzer's completion callback passed the display name, so
analyzing a repo whose basename collides with an existing one
reconnected the first same-named sibling. The SSE terminal payload now
carries the job's repoPath (both emit sites), the analyzer passes that
identity to onComplete while the done screen keeps showing the display
name, and old servers without repoPath degrade to today's behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): scope code-reference file reads to the active repo identity
The code viewer passed the display name as the repo scope, so with
duplicate-name repos it rendered the wrong repo's file contents under
the right filename. Pass the active path identity (currentRepo) with
the display name as fallback, and collapse the two dead repo fields
that were already shadowed by the readFile spread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): show display names instead of absolute paths in labels
The path-identity switch leaked raw filesystem paths into three
user-facing surfaces: the re-analyze progress label, the repo-switch
overlay, and the agent prompt's project name via loadGraphAnyway.
Resolve display names at render time (registry lookup, then basename
fallback) while state keeps holding the identity; loadGraphAnyway
passes the name explicitly because initializeAgent's empty-deps
closure would otherwise fall through to the literal 'project'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): stop initializeAgent from clobbering repo identity with display names
initializeAgent fell back to writing overrideProjectName (a display
name) into the repo identity, so any future name-only caller — the
pre-PR idiom — would silently kill the Active badge and re-admit the
duplicate-name ambiguity through the agent path. Only opts.repo may
write the identity now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): drop dead initializers flagged by CodeQL
pNameStr's and repoIdentity's initial values were never read: both are
assigned on the success path before any use and the catch returns
early. Bare declarations resolve CodeQL alerts 825/826.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style(web): fix tailwind class order per root prettier plugin
The worktree pre-commit hook resolved prettier-plugin-tailwindcss
through symlinked node_modules and sorted scrollbar-thin differently
than CI's clean-room install. Re-formatted with the root lockfile
environment; no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(web): e2e coverage for every #2419 duplicate-name ambiguity
Provision two live repos with the same basename under different parents
via POST /api/analyze, then drive a real browser through each item of
the issue's "Actual behavior" list:
- duplicate rows render and the ACTIVE one is identifiable before and
after switching (active-state must not compare repo.name)
- switching between duplicates swaps the loaded graph, verified by
per-repo marker files (onSwitchRepo must not receive repo.name)
- re-analyze targets the clicked duplicate's exact path (POST body),
tracks progress on that row only, and the completion reconnect
requests that same path — never the same-named sibling
- delete requests target exactly the chosen duplicate's path; the
sibling stays registered and loaded
- backend ?repo= resolution is path-first: landing selection loads the
exact repo, ?repo= survives F5, and a stale path fails closed to the
repo picker instead of retargeting the sibling
Adds four data-testids to Header (switcher trigger/row/reanalyze/
delete, rows expose data-active) so the spec has stable selectors, and
broadens the post-analyze reconnect retry in App to any BackendError:
the server may still be reinitializing when the SSE complete event
fires, and that surfaces as transient 5xx/binder errors, not only 404.
The re-analyze and delete tests deliberately assert identity at the
request level and tolerate two pre-existing server races that are
unrelated to the #2419 identity contract (freshly-analyzed DB briefly
unreadable after SSE complete; registry validate-prune clobbering a
concurrent unregister) — see the in-test comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* test(web): isolate repo-path-identity e2e onto a spec-owned backend
The spec is the only e2e file doing write operations (analyze,
re-analyze, delete). Running its force re-analysis against the shared
CI backend while parallel workers held connections took the whole
server down (run 29145679019: the jobId poll died with ECONNRESET and
every later test in every file failed to connect).
Spawn a dedicated `gitnexus serve` on port 4799 with an isolated
GITNEXUS_HOME in beforeAll instead: writes can no longer perturb the
other suites, a crash is contained to this spec (its output is captured
and printed, which CI otherwise loses), and the registry is hermetic by
construction — the previous leftover-purge and shared-registry cleanup
are gone. Every page is pointed at the spec backend through
useBackend's supported localStorage override, which covers both the
probe-driven landing flow and the ?server= auto-connect. Verified
self-sufficient (6/6 with no shared server running) and non-interfering
(full suite 39/39 with the shared server up).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* stabilize repo path identity e2e
* fix(server): don't report analyze complete before the index is settled
The analyze worker reports `complete` over IPC before its on-disk
finalization (LadybugDB checkpoint, native handle release, metadata
write) is visible at the storage path — observed up to ~6.5s behind the
IPC message. The launcher's "reinitialize backend BEFORE marking
complete" ordering was meant to make the repo queryable by the time the
client sees the SSE complete event, but it never verified that: clients
reconnecting on that event read a database still being written. Locally
that surfaces as "Binder exception: Table CodeRelation does not exist"
or a silently empty graph, and the open can quarantine the in-flight
WAL; on slow CI runners the native layer racing the rewrite has killed
the whole server (signal exit, no output — run 29146867959).
Gate the complete transition on the index actually settling: LadybugDB
file and metadata both rewritten by THIS job (mtime >= job start — bare
existence is not enough, a re-analysis leaves the previous index in
place while it works) and no transient WAL/shadow/checkpoint sidecars
remaining. Bounded (60s) and proceed-on-timeout, so a job whose
analysis legitimately rewrites nothing cannot wedge. Also evict the
server's cached DB handle before reinitializing — same invalidation
DELETE /api/repo performs — so post-completion reads cannot be served
from a pre-rewrite handle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(web): assert re-analyze completion identity at the request level
The strict form (Ready + marker on the re-analyzed duplicate) still
trips a deeper pre-existing storage race that makes a freshly
re-analyzed database transiently unreadable to the reconnect even with
the settle gate in place — unrelated to the #2419 identity contract
this test covers. Keep the identity assertions (the reconnect targets
the exact duplicate's path and never the same-named sibling) and leave
a pointer to tighten once the storage race is fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(server): resolve the settle-gate path from the registry, not the request
CodeQL flagged the settle gate's stat/exists probes as js/path-injection:
the probed path derived from the user-provided analyze `path`. Resolve
it from the repo's registry entry instead — the user value is now only a
comparison key, and the probes run against the server-owned storagePath
record, which is also the authoritative path readers resolve through.
Re-resolved each poll round because the worker registers the repo as
part of the same finalization the gate is waiting out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(tree-sitter): recover from embedded NUL bytes
Normalize embedded NUL bytes only in parser input so tree-sitter keeps recovering through the full source shape. Pass the file label through the worker for diagnostics and cover both direct-string and callback parse paths with regression tests.
* fix(review): align safe-parser contract count
* test(tree-sitter): cover worker NUL diagnostics (#2430)
* fix(hook): emit MCP query hint when server owns DB lock (#2396)
When the GitNexus MCP server holds the lbug write lock, the PreToolUse hook's CLI `augment` cannot run (LadybugDB is single-writer) and previously skipped silently — disabling graph augmentation in the most common deployment (server online). Since the same session already has the MCP `query` tool live, the owner branch now emits an additionalContext hint pointing the agent at mcp__gitnexus__query for that pattern, via the same sanctioned stdout channel the augment-success path uses (Codex-safe, #2369).
Rejected the alternative of having the hook query the server: it runs over stdio (no port/pipe from the separate hook process) and cross-process read-only access can't coexist with the write lock — both are large architecture changes. Applied to all three gated hook copies (claude .cjs, claude-plugin .js, antigravity .cjs); the cursor hook has no owner gate and is untouched. The stderr `augment skipped: MCP server owns DB` diagnostic stays GITNEXUS_DEBUG-gated (#1913). Owner-path tests flipped from stdout-empty to hint-present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hook): reword MCP-query hint to be conditionally truthful (#2396)
The #2396 owner branch emits the hint on every DB-owner path — a confirmed
`gitnexus mcp` owner, a `gitnexus serve` owner, and the fail-closed/timeout
paths (the probe collapses timeout and owned to one boolean). The old text
claimed "Knowledge graph is live via the MCP server" and named
mcp__gitnexus__query unconditionally, which is untrue on a fail-closed probe
where no server is confirmed and misdirecting for a serve-only owner
(review C2/C4).
Reword the hint (byte-identical across all three hook copies) to state that
local augment is unavailable and to condition the MCP call on the tools
actually being live ("if the GitNexus MCP tools are live in this session").
This is truthful on every owner path; the needles the assertions rely on
(mcp__gitnexus__query, query, search_query, the pattern) are preserved.
Fix the 10 stale owner/fail-closed unit tests that still asserted empty
stdout (review C1, the macOS platform-sensitive 2/3 blocker): flip them to
assert the hint via parseHookOutput, keep their stderr/GITNEXUS_DEBUG
expectations, and rename the two 'SILENTLY' titles. The GITNEXUS_DEBUG=''
owner-hint case is restored (the PR's new loop only covered '0'/'false').
Probe and its white-box tests untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hook): de-orphan the JSDoc in the claude hook copy (#2396)
The #2396 change inserted buildMcpQueryHint between the pre-existing
"PreToolUse handler" JSDoc and handlePreToolUse, orphaning that doc onto the
helper and leaving handlePreToolUse undocumented (review C5). Move the helper
(with its own doc) above the handler doc so the "PreToolUse handler" comment
again precedes handlePreToolUse, matching the clean plugin copy. Pure move; no
behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hook): throttle the MCP-owner hint to once per repo per window (#2396)
Previously the hint emitted on every qualifying search while a GitNexus process
owned the DB, so an owner-locked session (the common deploy) was nudged toward
the MCP query tool on every Grep/Glob/Bash — context bloat and ~2x query
amplification (review C3).
Add shouldEmitMcpHint(gitNexusDir) to all three hook copies: a per-repo
.gitnexus/.mcp-hint-shown mtime marker emits the hint at most once per window.
Window via GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables).
Best-effort — any fs error falls back to emitting, so the hint is never lost to
a marker failure. The stderr skip diagnostic still fires regardless (only the
hint is throttled).
Tests: hookEnv disables the throttle by default (gitNexusDir is shared across
the suite, so a marker would otherwise throttle sibling owner tests); a dedicated
macOS-lane test sets a real window and asserts emit-then-throttle with the marker
gating it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(hook): README reflects the MCP-owner query hint, not a silent skip (#2396)
The 'Hook augmentation/notifications are silently skipped' section still
described the MCP-server-owns-DB path as a silent augmentation skip (review
docs finding). That path now hands the agent a conditional MCP-query hint via
additionalContext (throttled per repo). Reword the section to describe the hint
and its GITNEXUS_MCP_HINT_THROTTLE_MS throttle, and keep the GITNEXUS_DEBUG
stderr-diagnostic guidance. No CHANGELOG edit (owned at release time).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(hook): guard hint-copy drift + pattern JSON-escaping (#2396)
Two gaps the review flagged (R7):
- Drift guard: buildMcpQueryHint and shouldEmitMcpHint are triplicated across
the three hook copies with no shared module. A source-level byte-identity
check (runs on every platform, unlike the macOS-only owner tests) fails if any
copy diverges — the institutional pattern the repo already uses for mirrored
hook metadata.
- Escaping: an adversarial Grep pattern (embedded quote + newline) must not
break the additionalContext JSON envelope. A macOS-lane owner test drives the
real hook with such a pattern and asserts parseHookOutput still yields valid
JSON containing the literal characters (JSON.stringify escapes them).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
* feat(routes): add pure Python string-constant resolver (#2391 U1)
* feat(routes): extract Python module constants from tree (#2391 U2)
* feat(routes): capture non-literal FastAPI decorator args + per-file constants, bump parse-cache schema (#2391 U3)
* feat(routes): resolve composed decorator route constants in parse-impl + skip floor (#2391 U4)
* feat(routes): resolve composed FastAPI route constants in group HTTP-contract layer (#2391 U5)
* test(routes): multi-hop, ingestion↔group parity, and warm-cache regression locks (#2391 U6)
* docs(routes): mark the language-agnostic seam for cross-language const resolution (#2391)
* refactor(routes): extract language-agnostic constant-fold core; Python becomes a binding (#2391)
The fold, cycle guard, and depth cap now live in constant-resolver.ts and take a
pluggable ImportResolver. python-const-resolver.ts supplies the Python import
semantics + tree extractor and re-exports the same surface, so no call site
changes. A Spring/Kotlin/C# binding can now reuse the core with its own resolver
(proven by constant-resolver.test.ts driving it with a Java-style resolver).
* fix(routes): treat the constant-fold cycle guard as a recursion stack (#2391)
The `visited` set in `foldName` was added-to but never removed on unwind, so a
constant referenced more than once in a single fold — `A + A`, a reused
separator (`SLASH + PATH + SLASH`), or a diamond `X = P + Q` where P and Q share
a base — tripped the cycle guard on its second occurrence and the whole route
was silently dropped by the skip floor. Pop the guard in `finally` so it tracks
the ACTIVE resolution stack, not every name ever seen: a true cycle (a name
still on the stack) is still caught, but a name that already resolved and popped
folds again. Re-computation stays bounded by MAX_RESOLVE_DEPTH, so no blowup is
reintroduced.
Locked in constant-resolver.test.ts (A+A, reused separator, shared-base
diamond); the pre-existing real-cycle and depth-cap cases still return null.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): make module-constant binding writes mutually exclusive (#2391)
`extractPythonModuleConstants` kept `literals`, `exprs`, and `imports` as three
independent maps: `setName` cleared literals+exprs but never `imports`, and an
import never cleared a prior literal/expr. Since `foldName` checks
literals > exprs > imports regardless of source order, a name that was both
imported and locally (re)assigned kept both bindings and the wrong one won —
`from .c import ROUTE; ROUTE = os.getenv(...)` resolved the STALE import instead
of dropping, a confidently wrong route path (the exact skip-floor invariant
this feature is meant to uphold).
Treat the three maps as one logical namespace: any write to one clears the
other two for that name (via `imports.delete` in `setName` and a `bindImport`
helper), so last-binding-in-source-order wins, matching Python. An import both
imported and dynamically rebound now drops. Folding `+=`/`+` onto an imported
base remains deferred (it drops safely, never a stale value).
Locked in python-const-resolver.test.ts: dynamic-rebind drops, literal-shadows-
import, import-shadows-literal, and `+=`-on-import drops.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): widen the group cost-gate to catch literal-leading concats (#2391)
`NONLITERAL_ROUTE_DECORATOR_RE` required the first decorator argument to START
with an identifier, so a string-literal-leading concat like
`@router.get("/api" + SUFFIX)` never tripped `hasComposedRoute`. When such a
route was the ONLY composed shape in a repo, the group layer left `constantsByFile`
empty and dropped the route, while the ingestion side (which has no gate)
resolved `/api/users` and emitted a Route node — an R4 provider/graph parity break.
Widen the gate to also fire on a string-literal-leading `+`-concat, detected by a
`+` before the closing paren on the decorator line. Gating on the `+` (not merely
a leading quote) keeps a plain literal route `@router.get("/x")` OFF the gate, so a
literal-only repo still pays no parse pass.
Locked in fastapi-composed-provider.test.ts: a sole literal-leading concat now
resolves (parseCalls>0 + provider emitted), plus previously-uncovered
`@app.<verb>(CONST)` EXPR-branch resolution; the literal-only no-parse gate case
still passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): correct package-init and over-deep relative import resolution (#2391)
Two edges in `resolvePythonImport`:
- `from . import X` (empty module after the dots) resolved to a sibling
`<dir>.py` instead of the package `<dir>/__init__.py`. Resolve the bare-package
case to `__init__.py`.
- An over-deep relative import (more extra dots than the importing file has
directory levels) silently clamped `dirOf('')` to `''` and could match an
unrelated root-level `<name>.py` — a wrong file. Guard with `walk > depth →
null` so an import that escapes above the repo root drops (skip floor).
Both preserve the exact-match / ambiguity→null behavior for ordinary relative and
absolute imports.
Locked in python-const-resolver.test.ts: `from . import` → `__init__.py` (and
null when absent), and an over-deep import returns null even when the clamped
target file exists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): bound parseConstOperands recursion depth (#2391)
`parseConstOperands` recursed on `binary_operator` children with no depth bound.
A stack overflow is not currently reachable (tree-sitter caps expression nesting
below the JS stack limit, so it throws on a deep `+`-chain before this runs), but
add a depth guard (cap 64, mirroring the fold engine's MAX_RESOLVE_DEPTH) as
defense-in-depth: a pathological chain now floors to null (skip) rather than
relying on tree-sitter's limit. The `depth` parameter defaults to 0, so all
existing callers are unaffected.
Locked in python-const-resolver.test.ts: a 100-term `+` chain yields no binding
(null) instead of throwing; ordinary short chains still fold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(routes): read each .py once in buildPythonRepoContext (#2391)
The group repo-context builder read every `.py` file from disk twice: once in
the `include_router` cross-file pre-pass and again in the #2391 constant
cost-gate loop — an unconditional 2x read on every Python repo, on every group
extraction. Hoist a single read pass that populates one `pyContents` map (and
computes the composed-route cost gate); both the include_router pre-pass and the
constant-map pass now consume the cached content. Behavior-preserving — a
literal-only repo still does one read and zero parses.
Covered by the existing group unit + integration suites (R4 parity and
include_router prefix joins unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(routes): tidy constant-resolver docs and declaration order (#2391)
Three no-behavior nits from the PR #2393 review:
- Name `conditional_expression` (`x if c else y`) in the `parseConstOperands`
jsdoc list of shapes that deferred to null.
- Move `NONLITERAL_ROUTE_DECORATOR_RE` above `buildPythonRepoContext`, which
references it — it read as a forward reference before (runtime-safe, but
confusing).
- Correct the integration-test comment that called `/v2/api/v1/widgets/get`
"ingestion-only garnish": the group side emits it too (asserted separately);
the four paths in that block are the shared-parity set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(routes): fold `X += "…"` onto an imported base constant (#2391)
Previously `from .c import BASE; BASE += "/v1"` dropped (the extractor could not
represent "the imported prior value" as an operand without self-referencing X and
tripping the cycle guard). Preserve the imported prior under a synthetic `$imp$N`
key — `$` can never appear in a Python identifier, so it cannot collide with a
real name — and reference it, so the augmented assignment folds to
`<imported BASE>/v1`. Extractor-only: no change to the `Operand` type, the fold
core, or the cache shape, so no SCHEMA_BUMP. An imported base that is itself
unresolvable still drops (skip floor preserved — never a wrong path).
Locked in python-const-resolver.test.ts: single and chained `+=` fold onto an
imported base; an unresolvable base still drops.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(routes): resolve bare decorator constants via the by-name entry (#2391)
The group `resolveExprArg` hand-built `[{ kind: 'ref', name }]` and called
`resolveOperands` for a bare-constant decorator argument — exactly what the
language-agnostic core's `resolveConstant(file, name, repo)` seam does. Call it
directly for the identifier case. This gives the previously test-only by-name
entry point a real production caller (it is the documented reuse seam for future
JVM/other bindings), drops the synthetic operand construction, and lets the now-
unused `Operand` type import go. Behavior-identical — the `+`-concat path still
parses to an operand list and folds via `resolveOperands`.
Guarded by the existing group provider suite (bare-constant and concat cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(routes): parse each .py once in buildPythonRepoContext (#2391)
The repo-context builder ran two parse loops — the include_router prefix pre-pass
and the #2391 constant-map pass — so an include_router file in a composed repo was
tree-sitter-parsed twice. Merge them into a single pass that parses each `.py` at
most once and feeds both extractions from the same tree; a file that needs neither
pass is still not parsed at all (cost gates unchanged). Complements the earlier
single-read-pass change (this is the single-parse counterpart).
Behavior-preserving (prefixes, R4 parity, and cost gates verified by the group +
integration suites). Locked with a parseCalls assertion: a file needing both
passes is parsed once, not twice.
Note: a cross-run (cross-process) constant-map cache — the other deferred perf
idea — remains out of scope; it needs disk persistence + invalidation and would
add hashing/IO cost on the common path, so it fails the minimal-change bar this
single-parse dedup meets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): bound constant-fold work and output to prevent OOM (#2391)
The `finally`-popped cycle guard (recursion-stack semantics) correctly folds
diamonds/repeated refs, but popping the guard removed the accidental work cap the
old seen-ever set provided: a wide shared-descendant DAG re-folds each child once
per reference, and a self-multiplying concat (`X = A + A; A = B + B; …`) builds a
genuinely exponential string. Reviewers reproduced ~16.8M folds escalating to
`RangeError: Invalid string length` and heap OOM — and neither fold call site is
wrapped in try/catch, so it crashed the whole phase rather than dropping the route.
Two complementary bounds, both flooring to null (skip), never a wrong value:
- a never-popped `memo` in `foldName` caps recomputation at O(nodes) (successes
only — a null may be transient on a cyclic branch);
- a `MAX_FOLD_LENGTH` (8192) cap in `foldExpr` drops a fold whose output grows
past any real route path, bounding the string size the depth cap does not.
Corrects the prior "≤ 2^8 folds" comment (output grows multiplicatively, not
additively). Locked with a 64^4-fanout construction that now drops in ~ms instead
of OOMing; diamonds/cycles/depth-cap behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): snapshot assignment RHS refs at the assignment line (#2391)
`ROUTE = BASE` was stored as a lazy `ref(BASE)`, resolved against BASE's FINAL
binding. So `ROUTE = BASE; BASE += "/v1"` (or `ROUTE = API; API = "/other"`)
resolved ROUTE to the MUTATED value — a confidently wrong path, since Python
assigns by value at the `ROUTE =` line. This was latent for local constants at
the base of this feature and the `+=`-on-import work extended it to imports.
Snapshot each assignment/`+=` RHS reference to a bound name into that name's
current frozen value at the assignment line (`freeze`/`snapshot`): a literal
value, a copy of the current expr (whose refs are already frozen), or an import
preserved under a `$imp$N` alias. Unbound refs (forward references) stay lazy.
A later rebind of the aliased name can no longer change the earlier binding.
`freeze` also unifies the previous `currentOps` + inline import-alias logic.
Locked in python-const-resolver.test.ts: aliased-import-then-`+=`,
aliased-local-then-`+=`, aliased-local-then-rebind all resolve to the pre-mutation
value; normal reference chains still fold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): fold group identifier args via resolveOperands for parity (#2391)
Resolving a bare-constant decorator arg through `resolveConstant` entered
`foldName` at depth 0, whereas the ingestion side folds `routePathOperands`
through `resolveOperands([{ref}])`, entering at depth 1. At the MAX_RESOLVE_DEPTH
boundary the group tolerated one more hop than ingestion, so a deep alias/re-export
chain resolved in the group provider set but dropped from the graph Route nodes —
an R4 parity break. Restore the operand-list path in the group so both subsystems
share identical fold-entry depth. (`resolveConstant` reverts to the documented
agnostic-core seam.)
Locked in constant-resolver.test.ts: a 4-hop chain that `resolveOperands([ref])`
drops but `resolveConstant` resolves, documenting why the group must use the
operand-list entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): match multiline literal-leading concats in the cost gate (#2391)
`NONLITERAL_ROUTE_DECORATOR_RE` used `[^)\n]*` so it only saw a literal-leading
`+`-concat when the `+` was on the same line as the opening quote. A
Black-formatted `@router.get(\n "/api"\n + SUFFIX\n)` therefore failed the gate,
and when it was the only composed route in a repo the group dropped it while
ingestion (which parses the tree, not the raw line) resolved it — an R4 parity
break. Drop the `\n` exclusion: `[^)]*` spans the wrapped argument but stays
bounded by the decorator's own closing paren, so a plain literal route still
never trips the gate.
Locked in fastapi-composed-provider.test.ts with a multiline concat fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routes): bump SCHEMA_BUMP for changed extractor output + E2E snapshot lock (#2391)
`extractPythonModuleConstants` now emits DIFFERENT `moduleConstants` for the same
source (binding mutual-exclusivity clears stale imports; RHS refs are snapshotted;
`$imp$N` aliases). That output is cached verbatim in the parse cache, so a warm
shard built at the pre-fix version would replay stale — in one case actively
wrong — folded values, and the correctness fixes would silently no-op on upgrade.
Bump SCHEMA_BUMP 11→12 to force re-extraction (same warm-cache-replay class the
original 10→11 bump addressed for the field addition).
Also adds the first end-to-end coverage for the new behavior through the real
ingestion pipeline: app/snapshot.py aliases a constant (`SNAP = API_V1`) then
mutates the source (`API_V1 += "/mutated"`), and the test asserts the Route node
is `/api/v1`, never `/api/v1/mutated` — a case the pure-function unit tests
covered but the pipeline did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lbug): classify FTS extension load errors with Windows missing-dependency guard (#2374)
Add classifyExtensionLoadError() — a pure-string, lbug-free four-way
classifier (missing_file / corrupt_file / missing_dependency / unknown).
The Windows catch-all guard keys missing_dependency strictly on the
error-126 signal, never LadybugDB's generic 'Failed to load library …
needed by extension' wrapper, so 127/5/1114 and truncated (193) files
route correctly.
* feat(fts): surface classified missing-dependency remedy in doctor, repair-fts, and degrade warnings (#2374)
Route the FTS load reason through classifyExtensionLoadError at all four
surfaces (doctor, --repair-fts error, analyze degrade log, ftsDegradedWarning).
For the Windows missing-dependency class, emit the runtime-install remedy
(VC++ redist, then OpenSSL) instead of the wrong reinstall-over-network
guidance; other classes keep their existing routing. Path redaction preserved
on the client-facing warning.
* test(fts): assert doctor surfaces the classified remedy end-to-end (#2374)
Extend the broken-file e2e: doctor now prints the corrupt-file re-download
remedy through the real CLI, and the Windows missing-dependency remedy
(VC++/OpenSSL) must not misfire on a corrupt file — the catch-all guard,
verified end-to-end. Also assert the repair path does not misfire.
* style(fts): apply prettier formatting to #2374 diagnosis files
* feat(fts): language-independent hedged fallback for Windows load failures (#2374)
The Windows OS-error tail is localized, so matching only en/zh 126 text left
other locales on the generic 'run doctor' remedy. lbug's 'Failed to load
library' wrapper is English on every platform and present for all load
failures, so use it as a fallback: when the localized tail matches no specific
class, emit a hedged remedy that points the user at their own OS error and
offers both branches (install runtime / --repair-fts) without prescribing the
wrong single fix. Precise en/zh 126 keeps its definite remedy.
* feat(fts): language-independent structural classifier via binary inspection (#2374)
Add diagnoseExtensionLoad: pull the extension's file path out of lbug's own
English wrapper and inspect the binary header (PE/ELF/Mach-O magic + arch)
directly, so corrupt-vs-valid is decided by the file itself, not the localized
OS-error tail. A valid binary that still failed to load ⇒ missing_dependency
(runtime dep), decided in any OS display language and on all three platforms.
Falls back to the string classifier (with its hedged fallback) when the file
can't be read. Wire all four surfaces to it. Event Viewer / GetLastError-via-FFI
were dead ends (lbug catches the failure — no crash event; no native FFI dep).
* test(fts): exercise the structural classifier on real binaries (#2374)
Add an integration suite that runs inspectExtensionBinary/diagnoseExtensionLoad
against genuine binaries — the running node executable, the real lbugjs.node
addon, and the installed FTS extension (valid); a truncated real binary and a
real text file (corrupt). Registered in cross-platform-tests PLATFORM_LOGIC so
it runs on the Windows + macOS matrix, proving the PE and Mach-O header parsing
on real PE/Mach-O files (ubuntu covers ELF).
* fix(fts): honor a corrupt_file verdict over a structurally-valid header (#2374)
The structural probe in diagnoseExtensionLoad inspects only the first 4 KB, so a
download truncated after its header reads 'valid' and was routed to the "install
VC++, reinstalling will NOT help" remedy — the exact loop #2374 exists to kill,
for the truncated-download case the module docstring claims it handles. Honor the
loader's own corruption report ("file too short" / Windows error 193
"not a valid Win32 application") before defaulting to the dependency remedy;
localized corrupt tails stay hedged missing_dependency, preserving
language-independence.
Addresses PR #2383 review finding F1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(fts): return indeterminate for a PE header beyond the read window (#2374)
The structural probe reads only BINARY_HEADER_BYTES (4 KB). A valid PE with a
large DOS stub whose e_lfanew points past that window was wrongly called
'corrupt', routing a fine DLL to "re-download". A garbage e_lfanew from a truly
corrupt file is indistinguishable from here, so widen the header verdict with
'indeterminate' and return it in that case; the caller then defers to the
loader's own report instead of asserting a false verdict. Fat Mach-O stays valid
(LadybugDB ships thin per-arch binaries). Also covers the unmapped-arch and
garbage-PE-signature branches.
Addresses PR #2383 review finding F1-secondary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(fts): drop contradictory reinstall guidance from the analyze degrade log (#2374)
For a missing runtime dependency the extension file is present, so appending
FTS_UNAVAILABLE_MESSAGE (which tells the user to install it "with network
access") to the remedy ("reinstalling will NOT help") produced self-contradictory
guidance on the main analyze surface. Lead the missing_dependency degrade log
with the class-neutral sentence (FTS_UNAVAILABLE_LEAD) and append only the
classified remedy; other classes keep FTS_UNAVAILABLE_MESSAGE unchanged.
Addresses PR #2383 review finding F2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(fts): cache the load diagnosis so the degraded warning does no per-request I/O (#2374)
ftsDegradedWarning() runs on every degraded /api/search response and MCP query,
and it was calling diagnoseExtensionLoad — a synchronous openSync/readSync of the
extension file — on every call. Compute the diagnosis once at mark-unavailable
time (the single load-failure sink, run per Database not per request), cache it on
ExtensionCapability, and have the warning read the cached result (falling back to
the pure, no-I/O string classifier if it is absent). Loader capability-shape
assertions relax from toEqual to toMatchObject for the new optional field.
Addresses PR #2383 review finding F3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(fts): cover the missing_dependency remedy on the --repair-fts path (#2374)
The repair-fts error interpolates the classified remedy, but no test reached the
missing_dependency branch — only the corrupt/invalid-ELF path. Add a Windows
error-126 case asserting the thrown error carries the VC++ redistributable remedy
and omits the old "retry the network install" tail, and that no index is dropped.
Addresses PR #2383 review finding F6a (--repair-fts surface).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(fts): share the VC++ redistributable install hint (#2374)
The Microsoft Visual C++ redistributable name and aka.ms URL were duplicated
verbatim in WINDOWS_MISSING_DEPENDENCY_REMEDY and
STRUCTURAL_MISSING_DEPENDENCY_REMEDY. Factor a single VC_REDIST_INSTALL_HINT
constant so the pointer cannot drift between them; the composed remedy strings are
byte-identical (existing exact-text assertions unchanged). Also adds a test
covering the previously-unexercised structural remedy branch.
Addresses PR #2383 review finding F5a.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(fts): guard FILE_CORRUPTION_SIGNATURES parity with the installer script (#2374)
The corruption-signature list is deliberately duplicated between
extension-load-error.ts and scripts/install-duckdb-extension.mjs (the .mjs cannot
import the .ts), with nothing guarding against drift — a one-sided edit would
desync the FORCE-INSTALL verb from remedy classification. Export the array from
both and add a parity test that compares regex source + flags element-wise.
Addresses PR #2383 review finding F5b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(test): run extension-binary-real in the sequential lbug-db vitest project (#2374)
extension-binary-real.test.ts imports @ladybugdb/core but ran in the parallel
`default` project, contrary to TESTING.md's rule that native-LadybugDB tests live
in the sequential `lbug-db` project. Add it to the lbug-db include list and the
default exclude list; it now runs under lbug-db and no longer under default.
Addresses PR #2383 review finding F6c.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(fts): fail loud, not silent-skip, on missing FTS artifacts under REQUIRE_FTS=1 (#2374)
The real-binary structural tests gated on raw .skipIf(!lbugNative) /
.skipIf(!installedFts), so under GITNEXUS_REQUIRE_FTS=1 a missing artifact would
silently vanish from a green CI run (the #2299 trap). These tests inspect the
extension file directly and need its path, not a loaded connection — so
skipUnlessFtsAvailable (which needs an initialized LadybugDB) does not fit. Add
requireFtsResourceOrSkip: skip gracefully offline, throw under REQUIRE_FTS=1. The
always-on process.execPath assertion still runs everywhere.
Addresses PR #2383 review finding F6d.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(fts): apply prettier formatting to the #2383 fix files (#2374)
Line-wrapping only; the quality/format CI check flagged three files. 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>
* 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>
* feat(setup): install Codex PreToolUse/PostToolUse hooks (#2328)
Codex CLI supports lifecycle hooks with Claude Code's exact
{hooks: {Event: [...]}} JSON schema, stdin payload, and
hookSpecificOutput response contract, registered in a dedicated
~/.codex/hooks.json (https://developers.openai.com/codex/hooks).
Parameterize installClaudeCodeHooks into installClaudeSchemaHooks
(claude | codex): both runtimes share the installer, the bundled
gitnexus-hook.cjs adapter, and its helpers. A codex HookTarget in
editor-targets.ts makes uninstall and the setup-uninstall round-trip
tripwire cover the new surface with no uninstall.ts changes.
SessionStart is deliberately not registered: Codex reads AGENTS.md
natively, which already carries the GitNexus context block.
Closes#2328. Closes#244 (Codex setup support is now complete:
MCP + skills + hooks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(plugin): make the GitNexus plugin installable from Codex (#1131)
Codex's plugin system (https://developers.openai.com/codex/plugins/build)
reads a .codex-plugin/plugin.json manifest and a repo-root
.agents/plugins/marketplace.json registry. The existing
gitnexus-claude-plugin/ is already Codex-compatible as-is — Codex sets
CLAUDE_PLUGIN_ROOT for hook-command compatibility, loads the same
SKILL.md skills, hooks/hooks.json, and .mcp.json — so a second manifest
in the same folder replaces PR #1131's duplicated plugin tree with zero
copied skills or hooks. The .gitignore .agents/ scratch rule narrows to
re-include only the registry file.
Install: codex plugin marketplace add abhigyanpatwari/GitNexus
Supersedes #1131.
Co-authored-by: jublin <1799126+jublin@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: document Codex full support (MCP + skills + hooks + plugin)
Promote Codex to Full in both editor tables, document the
~/.codex/hooks.json hook install, and add the Codex plugin
marketplace install path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(release): extend the version-lockstep guard to the Codex manifests
The always-on drift guard asserted only the Claude plugin manifests
against gitnexus/package.json, so a release could ship stale versions in
.codex-plugin/plugin.json and .agents/plugins/marketplace.json without
CI noticing. Mirror the Claude lockstep test for the two Codex files and
extend the CONTRIBUTING §Releases lockstep list to match.
Verified guard semantics: a deliberate local version mutation of the
Codex marketplace entry turns the new test red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin): quote the hook command path for space-containing plugin roots
Both plugin hook commands ran `node ${CLAUDE_PLUGIN_ROOT}/hooks/...`
unquoted, which breaks whenever the substituted plugin root contains a
space — the common case on Windows user profiles. Both Claude Code and
Codex substitute the placeholder before shell execution, and Claude
Code's plugin docs mandate the double-quoted form in shell-form hooks.
No commandWindows entry: Codex source (codex-rs hooks engine) falls back
to `command` on Windows with identical placeholder substitution, so an
identical-content override would be pure duplication.
Verified: space-in-root smoke test (old form exits 1 MODULE_NOT_FOUND,
quoted form exits 0), `claude plugin validate` passes, and a local
`codex plugin marketplace add` parses the marketplace + plugin cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(setup): pin fail-closed behavior for unreadable/corrupt Codex hooks.json
The non-ENOENT suite covered Claude settings.json (EACCES) and Codex
config.toml (EACCES) but not the new ~/.codex/hooks.json surface, and the
mergeHooksJsonc "is corrupt" branch had zero coverage for either editor.
A future refactor dropping the isEnoent rethrow or the parse gate could
silently rewrite a user's hooks.json gitnexus-only with no CI tripwire.
Two regression tests: EACCES leaves hooks.json byte-identical and reports
"Codex hooks: EACCES"; corrupt content is preserved and reported via
"Codex hooks: hooks.json is corrupt".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): add the Codex plugin-marketplace install path to the npm README
The root README documents the one-step plugin route but the package
README (what npmjs.com renders) only showed the setup-CLI path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(setup): rename claudeHook to hookCfg in installClaudeSchemaHooks
The local held a codex HookTarget on the codex branch since the installer
was parameterized, so the claude-specific name misled. Pure local rename,
no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): document Codex SessionStart exclusion, /hooks trust gate, and install-route choice
Three behaviors were only recorded in code comments and the PR body:
SessionStart is deliberately not registered (Codex reads AGENTS.md
natively), setup-installed hooks need one-time /hooks approval in Codex,
and the setup CLI and plugin are alternative install routes whose hooks
load alongside each other if both are used.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(test): share one logLines helper across setup.test.ts describes
The corrupt-hooks.json test inlined the console.log-flattening
expression that the non-ENOENT describe already defined locally. Hoist a
single file-scope logLines so the two stay in sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(setup): add CodeBuddy and Qoder coding-agent integrations
Adds Tencent CodeBuddy and Alibaba Qoder to gitnexus setup/uninstall,
fitted to the editor-targets registry and --coding-agent selection.
- CodeBuddy: MCP entry written into the first existing file of its
documented priority chain (~/.codebuddy/.mcp.json recommended,
~/.codebuddy/mcp.json deprecated, ~/.codebuddy.json legacy) so a
populated deprecated config is never shadowed; skills to
~/.codebuddy/skills/ (https://www.codebuddy.ai/docs/cli/mcp)
- Qoder: MCP entry in ~/.qoder.json, skills to ~/.qoder/skills/
(https://docs.qoder.com/cli/using-cli, /extensions/skills)
- editor-targets gains optional legacyFiles; uninstall sweeps them
- roster strings updated (CLI help, i18n en/zh-CN, READMEs); en/zh-CN
setup descriptions were stale (missing Antigravity) and are refreshed
Supersedes and credits PR #1030 by @zykai0302, re-fitted to the
post-#2168 selective-agent architecture with documented config paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(cli): assert stable zh-CN setup-description fragment
* fix(setup): surface non-ENOENT config read/stat failures instead of clobbering
* fix(setup): report corrupt legacy MCP files informationally during uninstall
* test(setup): cover multi-candidate uninstall sweep combinations
* fix(setup): detect CodeBuddy/Qoder installs via existing MCP config files
* fix(setup): skip empty and non-file candidates in the MCP config chain
* docs: add CodeBuddy and Qoder manual MCP configuration sections
* test(ci): run the setup-uninstall round-trip in the cross-platform matrix
* fix(setup): never claim "not configured" when uninstall recorded errors
* refactor(cli): share the isEnoent predicate via editor-targets
* refactor(setup): share chain-file install detection between CodeBuddy and Qoder
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: flat workspace index follows the checked-out branch (#2354)
A plain `gitnexus analyze` now always targets the flat workspace slot,
updating it incrementally across branch switches instead of auto-routing
non-owner branches into `branches/<slug>/` sub-indexes (disk bloat) or
nagging with the primary-inversion "run gitnexus clean" warning. No new
CLI flag or config key: the smart behavior is the default.
- Placement: only explicit `--branch` consults resolveBranchPlacement;
plain runs resolve to the flat slot, `meta.branch` becomes an
informational "last analyzed branch" label restamped each run.
- Fast path: a same-commit clean-tree branch flip restamps the label and
registry entry (adoptFlatBranchLabel, no-op for unregistered repos).
- Shadow cleanup: when the flat slot adopts a label that has a pinned
sub-index, the now-unreachable `branches/<slug>/` dir and its registry
summary are removed together.
- MCP: applyBranchScope always falls back to the on-disk flat meta before
throwing "not indexed", so long-lived servers resolve a freshly
restamped workspace branch.
- status: no more "current branch not indexed" dead end — falls through
to the workspace index with an informational line and the usual
commit-based staleness verdict.
- Deleted primaryInversionWarning; explicit `--branch` pinning, the
checkout-mismatch guard, detached-HEAD/CI behavior, and `clean
--branch` are unchanged.
Supersedes the flag-based approaches in #2358/#2359.
Closes#2354.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): check registry before deleting shadowed sub-index (#2364 review F2)
adoptFlatBranchLabel ran the branches/<slug>/ rm before its own
unregistered-repo no-op check, so a repo in the #2264 half-finalized
state (up to date but unregistered) lost its pinned sub-index on a
same-commit branch flip while the run still failed. The registry
lookup now precedes the deletion, making the no-self-heal rule
(#2264/#1169) cover disk as well as registry state.
The 'never self-heals' unit test now materializes a sub-index dir and
asserts it survives; the run-analyze #2354 fast-path test registers
its repo under an isolated GITNEXUS_HOME (deletion is only legitimate
for registered repos) with a new unregistered variant pinning
dir survival.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): keep branch summary when sub-index rm fails (#2364 review F4)
The shadow-cleanup fs.rm swallowed every error while the registry
summary was dropped unconditionally. On Windows an lbug held open by a
live MCP server fails the rm with EBUSY/EPERM, and once the summary is
gone 'clean --branch' can never target the leftover dir (it resolves
solely via the recorded summary) — stranding the exact un-cleanable
disk bloat adoptFlatBranchLabel exists to prevent.
The summary is now dropped only when the directory is verifiably gone
(post-rm existence check); on failure the summary is retained, a
warning names the path and errno, and the informational branch label
still restamps. Later adopts retry the rm.
New repo-manager-rm-failure.test.ts uses the delegating fs/promises
mock idiom (vi.spyOn cannot intercept ESM namespace exports).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(core): restamp fast path adopt-first and tolerate read-only storage (#2364 review F3)
The fast-path label sync stamped meta before adoptFlatBranchLabel, so
a crash or adopt failure between the two flipped the retry guard
(existingMeta.branch !== branchLabel) and locked in the partial state:
every subsequent same-commit run skipped the cleanup and branch-scoped
queries kept routing to the stale pinned sub-index. The block also sat
outside any try/catch, so a same-commit branch flip on a read-only
.gitnexus mount (the documented Docker :ro workflow, #1549) failed a
byte-for-byte-current analyze over a purely informational label sync.
Adopt now runs first and saveMeta last — any partial failure leaves
the guard true and the next run self-heals — and the whole sync is
best-effort: read-only errors warn citing #1549, anything else warns
and retries next run. Safe because the block only fires on a
same-commit clean tree, where the flat DB content is byte-valid for
both labels. isReadOnlyFilesystemError is now exported.
New run-analyze-adopt-failure.test.ts covers retry-after-partial-
failure, adopt-before-stamp ordering, and EROFS/EACCES/EPERM (gaps 4
and 7); a detached-HEAD fast-path pin lands in run-analyze.test.ts
(gap 6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): make flat meta authoritative in applyBranchScope (#2364 review F1)
applyBranchScope trusted two pieces of cached state before its flat-
meta disk fallback, and the handle cache only refreshes on a resolve
miss — never on a hit. Post-#2354 that stale window is the routine
case: (i) the handle.branch early-return served the flat handle under
the OLD label after a workspace flip, silently returning the new
branch's content as the old branch (the pool staleness reinit hot-
swaps content without updating handle.branch); (ii) a stale cached
branches[] summary routed to a branches/<slug>/ dir that
adoptFlatBranchLabel had already deleted (raw 'LadybugDB not found' or
POSIX ghost reads with staleness detection blinded).
The on-disk flat meta is now read before any cached-state trust. A
branches[] summary is served only when its sub-index lbug actually
exists (the lbug is what the pool opens — serviceability truth); the
cached label is trusted only when no readable flat meta contradicts it
(#2106 R4 legacy shapes preserved). One refreshRepos() fires on
detected staleness so subsequent calls see fresh handles. Safe against
mid-analyze reads: dirty stamps spread the existing meta, preserving
the old label until the end-of-run atomic write.
Fixtures now materialize the pinned sub-index lbug; new regressions
cover the stale-old-label error, adopted-summary fall-through to flat,
and the dangling-summary partial-failure window (test gaps 1-2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(core): make end-of-run branch-label sync best-effort (#2364 review F5)
The end-of-run adoptFlatBranchLabel sat inside the pipeline try whose
catch rethrows, so a registry write failure (ENOSPC, ~/.gitnexus
perms) after a successful multi-minute analyze failed the whole run —
even though the index was complete and registered, the neighbouring
parse-cache save is deliberately wrapped for exactly this reason, and
adopt retries unconditionally on the next plain analyze. It now warns
and continues, mirroring the parse-cache wrapper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): correct branch-not-indexed guidance for workspace index (#2364 review F6)
The error told users to 'Run: gitnexus analyze --branch <X>', but
post-#2354 that command hard-errors unless X is checked out — and this
message is now the common goodbye for a formerly-indexed branch whose
sub-index the workspace slot adopted. The guidance now explains that
the workspace index follows the checked-out branch and leads with the
checkout; the '(primary only)' fallback becomes '(workspace only)'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: align primary/workspace vocabulary with the #2354 inversion (#2364 review F7)
The review flagged pre-inversion 'primary/non-primary' wording that
now misleads readers about the placement model: the isPrimaryBranch
JSDoc (field name kept — public API surface), the two branches? JSDoc
comments in local-backend, the base_ref gate comment in cli/analyze,
and four branch-scope test names. Comment/JSDoc/test-name edits only;
'Registry-primary' and 'primary key' senses untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): clarify workspace index status wording (#2364 review F8)
'gitnexus analyze follows this branch' was ambiguous about WHICH
branch analyze follows — the recorded one on the line or the current
checkout. Both locales now say a re-run follows the current branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): re-read registry after the shadow rm in adoptFlatBranchLabel
The F2 reorder moved the registry read to the top of the function, so
the whole-file writeRegistry at the bottom persisted a snapshot taken
BEFORE the recursive rm of an entire sub-index — widening the unlocked
read-modify-write window from microseconds to the duration of a multi-
hundred-MB delete. A concurrent registerRepo/removeBranchIndex writer
in that window was silently clobbered (the #2106 R9 lost-update class;
registerRepo re-reads before writing for exactly this reason).
The top read is now a cheap membership gate only (the F2 no-op
guarantee); the mutate re-reads its own fresh snapshot after the rm.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: treat only provably-absent errno as gone in the new existence probes
Both probes added by this series inverted the codebase's provably-
absent polarity (listRegisteredRepos validate prunes only on
ENOENT/ENOTDIR): adoptFlatBranchLabel's dirGone check read ANY
fs.access failure — including a transient EACCES/EIO on a surviving
dir — as 'verifiably gone' and dropped the summary, recreating exactly
the stranded-bloat bug F4 fixed; applyBranchScope's sub-index check
read the same transient errors on a healthy pinned lbug as 'adopted/
deleted', producing a false 'not indexed' error. A resolved force:true
rm now proves absence without a probe; on failure the probe treats
only ENOENT/ENOTDIR as gone, and a non-missing lbug serves the handle
so the pool open surfaces the real error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): harden applyBranchScope stale-state coherence
Four residual gaps in the new arm structure, found by post-fix review:
- The stale-label error listed the just-contradicted cached label as
indexed ('not indexed: main. Indexed branches: main'). The message
now derives the flat label from the authoritative meta and excludes
the requested branch from the hint list.
- A branch pinned AFTER the server cached its handle never triggered a
refresh (resolve hits skip the miss-refresh), erroring until restart.
Every miss now fires exactly one best-effort refreshRepos() before
the error, so the next call resolves; a refresh-once guard keeps
doubly-stale resolutions to a single registry re-scan.
- A registry entry claiming the branch both as flat label and pinned
summary (the rm-failed adopt-degraded state) could serve the stale-
vintage pin under a label the flat slot owns; the summary arm now
requires handle.branch !== branch and the degraded state errors
honestly.
- The flat-meta match path returned the cached handle's pre-restamp
branch/commit/stats; the meta that decided routing now also supplies
the metadata.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(core): keep the real error visible in restamp warnings; correct the end-of-run retry claim
The fast-path catch replaced the actual error with 'storage is
read-only (#1549)' for any EACCES/EPERM — mislabeling ownership
problems and transient Windows locks and discarding the only
diagnostic signal. The warning now carries the real message with the
#1549 hint appended.
The end-of-run best-effort comment claimed adopt 'retries
unconditionally on the next plain analyze'; same-commit runs take the
fast path whose guard compares the already-stamped meta label, so the
retry actually lands on the next content-changing run. The comment now
states the true retry semantics and why the interim state is safe
(flat meta stamped first; applyBranchScope trusts it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: unique tmpdir for the branch-scope fixture; drop redundant dynamic imports
The branch-scope describe materialized its sub-index stub under a
FIXED os.tmpdir()/gnx-2106-multi path — concurrent vitest runs on one
host (the documented parallel-agents workflow) could rm each other's
stub between beforeEach and the resolve under test, flaking the
pinned-branch tests. The fixture root is now mkdtemp-unique per run
with afterAll cleanup.
run-analyze.test.ts dynamically imported repo-manager inside test
bodies despite the module being statically imported at the top of the
file (no vi.mock exists there to justify it); the three call sites now
use the static import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: fix skill reference drift
* docs: complete guide tool coverage and graph schema (#2356 items 5-6)
- add the 6 undocumented MCP tools to the guide's Tools Reference
(route_map, shape_check, api_impact, tool_map, group_list, group_sync)
- document the experimental @groupName cross-repo trace mode
- expand the Graph Schema section to the real node/edge type surface,
pointing at gitnexus://repo/{name}/schema as the authoritative list
- sync the packaged gitnexus/skills copy
Item 7 of #2356 (Codex host naming / duplicated filename) does not
reproduce on current main - no remaining copy contains it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): restructure for readability, fix stale facts
Reorganize the README so the visible page reads as a short narrative
(Quick Start -> Two Ways -> Why -> What Your Agent Gets -> Editor Setup
-> CLI -> How It Works -> Docker -> Enterprise) and move deep
operational detail into 13 collapsible <details> sections: env vars,
.gitnexusrc, Cosign/Kubernetes verification, manual MCP configs,
install troubleshooting, and extended tool examples.
Accuracy fixes verified against gitnexus/src:
- MCP tools: 17 (15 per-repo + 2 group), not 16/11+5; drop
group_contracts/group_query/group_status (CLI + resources now, not
tools); add check, trace, explain, pdg_query, route_map, tool_map,
shape_check, api_impact rows from src/mcp/tools.ts
- Agent skills: 6 installed (adds Guide + CLI), not 4
- Wiki default model: minimax/minimax-m2.5, not gpt-4o-mini
- Resources: add gitnexus://setup and gitnexus://group/{name}/...
- CLI: document group impact, doctor, and the direct terminal query
commands (query/context/impact/trace/cypher/detect-changes/check);
note the optional branch param on per-repo tools (#2106)
Structural cleanups: dedupe the two Codex config blocks, move Community
Integrations out of the MCP setup flow, move Star History to the
bottom. No content deleted - verbose material is collapsed, not cut.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fact-check and fix the remaining READMEs
Reviewed all 9 tracked non-root READMEs against the source; fixed the
four with stale facts, left the already-accurate ones untouched
(pr-swarm-review, .claude reviewer-swarm adapter, and the three
bench/ methodology docs).
gitnexus/README.md (npm package page):
- MCP tools table: 17 tools (15 per-repo + 2 group), was 7 rows
- Resources: add gitnexus://setup and gitnexus://group/{name}/...
- Skills: 6 bundled (adds Guide + CLI) plus --skills generated ones
- Languages: add Dart (14 total) to the list and feature matrix
- Wiki default model: minimax/minimax-m2.5, not gpt-4o-mini
- Requirements: Node >= 22 (package.json engines), not >= 18
- Claude Code hooks: PreToolUse + PostToolUse
- CLI: add --skills/--skip-skills/--skip-git/--workers, doctor,
trace, check, group impact
- Optional grammars note: include Proto, mention
GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1
gitnexus-cursor-integration/README.md:
- 17 MCP tools, was 16; skills list: all 9 bundled skills, was 5
eval/README.md:
- Model list matches configs/models/: Claude Haiku 4.5 (was
'3.5 Haiku'), adds MiniMax M2.5 and DeepSeek
- Node.js 22+ for GitNexus, was 18+
.devcontainer/README.md:
- Add a table of contents (364 lines, ~15 sections, no navigation)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: empty commit to retrigger CI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: add Spring DI resolver for @Autowired List<T> injection
Addresses all P0/P1 findings from tri-review (#2200):
- P0: Register INJECTS in RelationshipType union (compiles)
- P0: Rewrite execute() to emit consumer→implementation edges from graph data only
- P1: Register in VALID_RELATION_TYPES, single-pass O(N) indexes
- P1: Java-only gate with early exit on non-Java repos
- P1: Update FULL_ORDER golden test
- 8 unit tests covering all edge cases
* test: make VALID_RELATION_TYPES size assertion array-driven (no hardcoded count)
The security test hardcoded toBe(16) for the relation type count, but PR #2200
added INJECTS, bumping it to 17. Replace the magic number with an
EXPECTED_RELATION_TYPES array whose .length drives the size assertion,
so future additions only need to append to the list.
Fixes CI failure on PR #2200.
* fix(ingestion): thread raw generic field types onto Property nodes so Spring DI matching works (review 4616076037 P0)
Production declaredType is generics-stripped by design (extractSimpleTypeName:
List<Shape> -> "List"), so the spring-di phase's anchored regexes could never
match real extraction output — the phase was a silent no-op on every real Java
repository, while its unit tests passed against hand-built node shapes.
Add FieldInfo.rawDeclaredType captured verbatim from the field's type node
(.text, generics and qualifiers preserved — same precedent as the JVM method
extractor), thread it through both parse-worker Property sites, add it to the
shared NodeProperties contract, and match on rawDeclaredType ONLY (no
declaredType fallback: it can never match real data and would mask future
plumbing regressions as quiet no-ops).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): gate Spring DI on real injection annotations, honest edge reason (review 4616076037 P1)
Extract Java field annotations (shared extractAnnotations helper, moved
verbatim from the method extractor) onto Property nodes and require
@Autowired or @Inject before a collection field becomes an INJECTS
candidate. Previously every edge's reason string fabricated "@Autowired"
without any annotation ever being checked, and any plain collection field
would have fanned out false edges once matching worked.
@Resource is deliberately excluded: JSR-250 resolves by bean name first
(defaulting to the field name), injecting a single named collection bean —
the opposite of the collect-all-implementers fan-out INJECTS models. Pinned
by a test.
An annotated candidate missing rawDeclaredType now logs an isDev warning
(plumbing-contract breach signal) instead of vanishing silently.
SCHEMA_BUMP 9 -> 10: Property nodes gained rawDeclaredType + annotations;
warm parse caches must invalidate or the DI phase silently no-ops on
replayed pre-upgrade nodes (the #2038 trap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ingestion): framework-neutral di phase + language-scoped Spring matcher registry (review 4616076037 P1)
spring-di was the only pipeline phase naming a language in shared
core/ingestion code (DoD.md language rule; the maintainer's direction is a
generic DI solution). Split it:
- di-extractors/spring.ts: the Spring matcher (annotation gate, collection
type parse, @Resource exclusion rationale, framework-specific reason
payload) — language-scoped home, mirroring route-extractors/.
- di-extractors/index.ts: DI_MATCHERS, a single-valued
ReadonlyMap<SupportedLanguages, DiFieldMatcher> mirroring the
SCOPE_RESOLVERS registry shape sanctioned by AGENTS.md. Constructor
injection deliberately out of scope; widen to arrays only when a second
same-language framework lands.
- pipeline-phases/di.ts (renamed from spring-di.ts): framework-neutral —
routes Property nodes to registered matchers by node language via a typed
guard, then runs the unchanged reverse-index fan-out. Zero language or
framework names remain (grep-verified).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): language- and qualified-name-scoped interface resolution for DI fan-out (review 4616076037 P2)
The interface index was built from ALL Interface nodes regardless of
language, keyed by bare simple name with last-writer-wins overwrite —
a polyglot repo with a TS and a Java 'Shape' could fan Java INJECTS edges
into TypeScript classes, and two same-named Java interfaces in different
packages silently collapsed to whichever parsed last (documented GitNexus
bug class: #2054, PR #1956).
Resolution is now per-language with qualifiedName as the primary key
(Interface nodes already carry package-qualified qualifiedName); dotted
element types resolve via qualifiedName, bare names via a per-language
simple-name index that records ambiguity and fails CLOSED. Ambiguity skips
are observable: DIOutput.ambiguousSkipped + an aggregated isDev debug log,
so 'no DI fields' is distinguishable from 'all candidates ambiguous'.
Same-package tiebreaking is a pinned, documented follow-up.
Order-independence pinned by running collision tests in both insertion
orders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ingestion): depth-aware Spring collection-type parser for idiomatic generics (review 4616076037 P3)
The two anchored regexes silently skipped idiomatic Spring shapes:
Map<Pair<A,B>, IFoo> (nested-generic key broke the [^,]+ split),
List<? extends IFoo> / List<? super IFoo> (bounded wildcards),
java.util.List<IFoo> (qualified wrapper), and whitespace/multi-line
declarations.
Replace them with a small scanner: whitespace normalization, wrapper
matched by last dotted segment, depth-aware top-level-comma split, wildcard
bound stripping, and a final plain-dotted-type-name gate so anything else
(nested-generic elements, arrays, unbounded wildcards, embedded comments,
unbalanced brackets) fails closed. Every accept and reject is documented in
the module docstring and pinned by 27 table-driven cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(integration): prove Spring DI end-to-end through the real pipeline (review 4616076037 P1)
Both no-op incarnations of this feature shipped with a green unit suite
because every test hand-built the exact graph shape the phase expected —
no test ever ran real Java source through the actual extraction pipeline.
Add test/integration/spring-di-pipeline.test.ts: real .java fixtures via
runPipelineFromRepo, pinning (a) the extraction contract on the annotated
field's Property node (declaredType 'List', rawDeclaredType 'List<IFoo>',
annotations ['@Autowired']), (b) set-equality on ALL INJECTS edges
(exactly Consumer->FooA and Consumer->FooB; the non-annotated 'plain'
field of the same type contributes nothing; no self-edges), and (c) a
negative-control fixture with no injection annotations producing zero
INJECTS edges. Either historical regression fails at least one of these.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(incremental): register INJECTS across product surfaces + delete-before-writeback (review 4616076037 P2)
INJECTS was allowlisted in VALID_RELATION_TYPES but invisible or unhandled
everywhere else. Register it deliberately:
- REL_TYPES (gitnexus-shared schema-constants): web-side validRelType()
otherwise silently rejects INJECTS filters (CLI/web single source of truth).
- mcp/tools.ts cypher edge list (agent-facing schema discovery).
- isGraphWideRelType: INJECTS validity is a whole-program property — a
change to a THIRD file (the interface, or a new/removed implementer)
creates/invalidates edges between two untouched files (the TAINT_PATH /
#2084 M4 U6 class), so incremental extraction must always re-include the
full fresh set.
- deleteAllInjects (lbug-adapter): mirrors deleteAllInterprocTaintPaths —
COUNT-then-DELETE under withConnLock, benign missing-table carve-out,
re-throw otherwise (CodeRelation has no PK and there is no read-side
dedup; a fail-soft delete + re-add would silently duplicate rows).
- run-analyze.ts: the delete is UNCONDITIONAL, next to the Communities
delete — deliberately NOT inside the options.pdg block: the di phase runs
on every persisting analyze while the graph-wide re-include is
unconditional, so a pdg-gated delete would append without deleting on
every non-pdg incremental run (N runs = N copies).
- local-backend.ts comment: opt-in traversal by design (not in default
impact()/context() lists; no IMPACT_RELATION_CONFIDENCE entry per the
WRAPS/FETCHES precedent — edges carry their own 0.8).
- ARCHITECTURE.md: 14 -> 15 phases, DAG diagram, phase table, skip-list.
Note: the tools.ts edge list also predates WRAPS/QUERIES/USES — that drift
is pre-existing and left for a follow-up.
Idempotency pinned end-to-end: two successive incremental runs (real
runFullAnalysis + real LadybugDB, unrelated-file touches) leave the INJECTS
row count stable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: describe INJECTS' actual precondition; drop stale fixed-at-16 comments (review 4616076037 P3)
The shared-schema doc for INJECTS claimed an @Autowired precondition the
code (pre-fix) never checked, and hardwired Spring semantics into what is
now a framework-neutral edge type. Reword: precondition is an injection
annotation recognized by a per-language matcher in di-extractors/;
framework specifics live in the reason payload, not the type contract.
security.test.ts comments still said the allow-list size 'stays fixed at
16' (it is 17 and the assertion derives from EXPECTED_RELATION_TYPES).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: simplify DI surfaces — narrow matcher contract, dedup delete-alls, derive tools edge list
Post-implementation simplification pass (4 review angles):
- DiFieldMatch/CandidateField carried collectionType + matchedAnnotation
that no consumer read (the matcher bakes both into reason) — narrowed to
{elementTypeName, reason}.
- parseElementTypeName had two guard branches fully subsumed by the final
plain-dotted-type-name gate — deleted, rationale folded into the regex
comment.
- The three byte-identical delete-all-by-rel-type functions in lbug-adapter
(TAINT_PATH / CALL_SUMMARY / INJECTS) are now one parameterized helper +
thin wrappers with identical names, signatures, and message text
(character-diff verified) — the missing-table regex and abort policy now
live in exactly one place.
- The cypher tool's hand-maintained edge-type list (already missing
WRAPS/QUERIES/USES) is now derived from the canonical REL_TYPES — the
drift class is gone rather than patched.
- di phase: interface indexes are built only for languages that actually
have candidates; test builder gained a rawDeclaredType opt-out replacing
a hand-rolled node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: apply Tier-2 review findings — qualified-name fail-closed, honest cypher docs, pinned delete contract, hook isolation
- byQualifiedName was last-writer-wins on duplicate qualified names
(reproduced: order-dependent INJECTS edges with ambiguousSkipped 0 —
same package+interface duplicated across monorepo modules/source roots;
Java qualifiedName has no file-path component). Both indexes now share
the AMBIGUOUS fail-closed sentinel; order-flip test added.
- The REL_TYPES-derived cypher edge list advertised pdg-gated types with
no caveat (LLM queries on them silently return zero rows on default
indexes) — caveat appended, INJECTS example added, impact relationTypes
description now names the DI fan-out opt-in.
- The delete-all re-throw contract (only defense against duplicate
CodeRelation rows) was untested — error classification extracted to a
pure classifyDeleteAllError and pinned exhaustively.
- extractRawType/extractAnnotations hooks lacked the per-hook try/catch
the pipeline applies elsewhere (#2286 pattern): a throwing hook would
silently drop every remaining file in the language group. Hardened,
degradation tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: resolve Java cast-wrapped and this.method() call edges
Two fixes for missing call edges in Java method resolution:
1. compound-receiver.ts — cast expression handling:
- Strip (Type) cast wrappers from receiver text, tracking the
outermost meaningful cast type
- Resolve directly to the cast type class (not the field's
declared type), since the cast narrows the receiver type
- Add this.field chain walker for field-access receivers
- Replace text → workingText throughout the function body
2. scope-resolver.ts:
- Enable resolveThisViaEnclosingClass: true for Java
(activates Case 0.5 in receiver-bound-calls.ts)
Verified on a large-scale Java codebase with no regressions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(scope-resolution): format compound-receiver.ts with prettier (#2353 review F10)
Mechanical prettier --write from repo root — 6 brace-expansion sites and one
ternary re-join, zero logic changes. Clears the quality/format CI failure
that was blocking CI Gate on PR #2353.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(scope-resolution): pin working Java cast-receiver shapes (#2353 review F3)
Fixture-backs the cast resolutions PR #2353 gets right — simple cast,
nested/CFR cast, cast over this.field, and the deliberate declared-type
fallback for a resolvable-shape cast to an unindexed type — each with a
same-named decoy method on the receiver's declared type so later refactors
cannot silently regress them. No resolver changes; tests are green as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): resolve nothing for unparseable cast types (#2353 review F1)
A receiver paren-group that is type-shaped but unparseable — generic
(List<String>), array (Foo[]), fully-qualified (com.example.Foo) — is a
cast whose type cannot be looked up. Stripping it and falling through
resolved the pre-cast expression's own declared type, emitting a
confident wrong CALLS edge. Classification is now three-way per peel:
simple identifier → capture (outermost wins), type-shaped-unparseable →
resolve nothing (pre-#2353 behavior; noise casts after a captured type
still win), anything else → not a cast, text left untouched. Cast
candidates require a non-empty trailing expression, so plain
parenthesized receivers never capture a cast type.
Red-first: all four shapes reproduced the wrong edge before the fix;
golden digest byte-stable after.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(scope-resolution): delete duplicate this.field walker, seed literal-this chain heads (#2353 review F4/F5/F7)
A/B against the fixture corpus confirmed the generic per-segment walker
(head resolved via the synthesized this typeBinding) already covers every
method-body this.field chain — only initializer contexts (instance
initializer block, field initializer) were walker-dependent, since no
function scope exists there to carry a this binding. Deleting the
duplicate walker removes the naive chainRest.split('.') (F5) and the
widened fieldFallback use (F7) with it; the findEnclosingClassDef head
seed is the deliberate residue covering initializer contexts —
head-resolution only, the per-segment walk stays the single shared
implementation. Post-seed edge set is byte-identical to pre-deletion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(scope-resolution): gate cast stripping behind opt-in stripReceiverCastExpressions (#2353 review F2)
Cast handling in resolveCompoundReceiverClass now runs only for
languages that opt in via the new ScopeResolver toggle (default off);
Java is the sole opt-in. The peel loop is extracted into the pure,
exported stripCastWrappers helper (placed with the file's other pure
string helpers) so it can be unit-tested directly. Non-opting languages
see receiver text untouched — pre-#2353 behavior by construction
(golden digest unchanged, TS/C++/C# suites green, 796/796). Shared-code
comments are language-neutral per AGENTS.md; the contract JSDoc carries
the classifier grammar, the second-language escalation rule, and the
Case 3b/Case 4 pass-through non-goal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): cap cast-peel iterations in stripCastWrappers (#2353 review F8)
MAX_CAST_PEEL = 16 (each cast level costs at most two peels, so this
covers 8-level nesting with headroom — real cast nesting, including
decompiler output, is a handful of levels). Each peel rescans the
working text for its matching close paren, so pathological nested-paren
input was O(N²); the cap bounds it at O(N·16). Exceeding the cap bails
all-or-nothing with the original text (not-a-cast outcome). Adds the
helper's first unit tests: 14 scenarios covering capture, unparseable
shapes, redundant-paren unwrap, captured-type precedence, rawName
no-op, over/under-cap, and unbalanced-paren termination.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): revert Java resolveThisViaEnclosingClass, pin Case 4 bare-this dispatch (#2353 review F6/F9)
Remove resolveThisViaEnclosingClass from the Java scope resolver: the
toggle's own contract doc prescribes keeping it disabled where Case 4
(the synthesized this typeBinding) already handles this, and Case 0.5's
C++-authored semantics (hiddenByName arity-hiding, method-before-field)
provably bypass the interface-dispatch fan-out only Case 4 emits.
A/B gate (new java-this-dispatch pinning fixtures): flag-off 7/7 green;
flag-on 2/7 red (hiddenByName drops the this.greet overload site —
masked by a free-call-fallback 'local-call' edge — and the
interface-dispatch fan-out is missing). Corpus A/B over all 54 java-*
fixtures: 2 fixtures differ — java-this-dispatch (reason
'local-call'→'global' on the bare-this overload site; +2
interface-dispatch fan-out edges flag-off) and java-this-field-chain
(2 initializer-context bare-this ACCESSES reads emitted only by Case
0.5, which Case 4 cannot resolve — no synthesized this binding without
a Function scope; the corresponding CALLS edges are unaffected via the
F4 commit's literal-this head seed).
Also (F9): insert Case 0.5 into the I4 case-order listings (contract +
receiver-bound-calls header, now 8-case, marked gated) so the next flag
flip is visible at review time; the two 'sole C++ language' comments
are accurate again unedited.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(scope-resolution): restrict literal-this head seed to initializer contexts (#2353 review follow-up)
Final-review finding (two independent reviewer angles): the literal-this
chain-head seed landed ungated in shared code, so any language's
this-headed chain in a scope without a synthesized this typeBinding —
including contexts where the language DELIBERATELY leaves this unbound
(object-literal methods, nested plain functions) — would seed from the
lexically enclosing class. isInitializerContext now permits the seed
only when no Function scope sits between the site and its class, which
is precisely the field-initializer / instance-initializer shape the
seed exists for. Adds a TS guard fixture pinning that an
object-literal method's this.field.method() chain emits no fabricated
edge (mechanism did not empirically reproduce even ungated — the
restriction is conservative hardening, and the pin keeps it that way).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(scope-resolution): attach stripCastWrappers JSDoc, fast-path non-paren receivers (#2353 review nits)
Two final-review nits: a blank line detached the helper's 30-line
classification-contract JSDoc from the declaration (IDE hover showed
nothing at call sites); and the gate now skips the helper call plus
result allocation for the majority of receivers that cannot be casts
because they do not start with '(' — the helper's own check stays as
the safety net.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* bench(scope-capture): rebaseline Java fingerprint for new #2357 fixtures
The scope-capture correctness fingerprint hashes captures over the
java-* fixture corpus; the three fixture dirs added by this PR
(java-cast-receiver, java-this-field-chain, java-this-dispatch) extend
that corpus, so the fingerprint moves. Verified purely additive: with
the three new dirs parked, the fingerprint reproduces the prior
baseline byte-identically — no emit/capture behavior changed.
--check now passes for all 14 languages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: ww <ww@wwdeMacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs: add Kilo Code MCP workflow
* Fixed Space
Fixed 1 deleted blank line
* Fixed Readme Link
Fixed guide link from docs to documentation folder fixing 404 error
* Added Image and linked to .md file
* Removed Trailing Spaces
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix: consolidate icon imports, fix stale refs and package name collision
three components were importing directly from lucide-react instead of
going through the centralized @/lib/lucide-icons module like the rest
of the codebase. added the missing Keyboard and BarChart2 exports to
the icons module and updated the imports.
also:
- removed duplicate mermaid init comment in ProcessFlowModal
- replaced placeholder issue #XXX with a descriptive note in git.ts
- updated stale KuzuDB reference to LadybugDB in ARCHITECTURE.md
- renamed gitnexus-web package.json name from "gitnexus" to "gitnexus-web"
to avoid collision with the CLI package
* fix: complete package rename in lockfile and cite #2054 in git.ts comment
Address tri-review findings: package-lock.json name fields (top-level and
packages[""]) now match the renamed gitnexus-web package, and the
getCanonicalRemote doc comment cites #2054 instead of dropping the issue
reference.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(embeddings): use system-matched onnxruntime-node CUDA build so CUDA 13 hosts use the GPU
transformers.js exact-pins a CUDA-12 onnxruntime-node while gitnexus' own dep floats to a CUDA-13 build. npm/pnpm cannot dedupe an exact pin against a range, so npm i -g installs two copies and the gitnexus overrides block (root-only) is inert. On a CUDA-13-only host the nested CUDA-12 provider cannot load libcublasLt.so.12, the CUDA EP fails, and embeddings silently fall back to CPU (isCudaAvailable() also only probed .so.12).
Add onnxruntime-node-resolver.ts (module.registerHooks redirect to the host-matching build, no-op elsewhere) mirroring onnxruntime-common-resolver.ts; probe libcublasLt .so.12 OR .so.13 against the copy that actually loads; unit test with 12 cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(embeddings): wire CUDA-13 build-match resolver into MCP query embedder
The MCP query-time embedder (src/mcp/core/embedder.ts) has its own,
separate initEmbedder() used for semantic search — it only called
ensureOnnxRuntimeCommonResolvable() before importing transformers.js, so
the CUDA-13 build-matching redirect added for the analyze/CLI embedder
never applied here. A CUDA-13 host running MCP search with
--embedding-device cuda still loaded the mismatched default onnxruntime-node
build.
Wire ensureOnnxRuntimeNodeMatchesSystem() into the same call site, mirroring
the core embedder's ordering (registered after the common-resolver fallback,
before the dynamic transformers import).
* fix(embeddings): gate CUDA redirect decision on registerHooks availability
decide() computed the CUDA-major redirect independent of whether Node's
module.registerHooks API actually exists — only ensureOnnxRuntimeNodeMatchesSystem()
checked that. On Node 22.0-22.14 (allowed by this package's engines
floor; registerHooks needs >=22.15), isCudaAvailable() could therefore
report a redirect target that ensureOnnxRuntimeNodeMatchesSystem() then
silently failed to install, so transformers.js loaded the mismatched
default onnxruntime-node build while the embedder still requested
device:'cuda' against it — reintroducing the uncatchable native crash
this probe exists to prevent.
Move the registerHooks check to the top of decide() so the probe and the
loader can never disagree, and skip CUDA-major probing entirely in that
case (a redirect could never install anyway).
Also fixes a related test-helper bug found while writing this unit's
tests: loadResolver's destructuring default (`registerHooks = vi.fn()`)
silently substituted a real mock function even when a test passed
`registerHooks: undefined` to simulate Node < 22.15 — meaning the
existing 'no-ops... when registerHooks is unavailable' test never
actually exercised that path. Distinguish 'omitted' from 'explicitly
undefined' via an 'in' check.
* test(embeddings): drive decide() -> redirect:true and assert the resolve() closure
The PR's actual shipped behavior — the installed registerHooks resolve()
closure, and the full redirect-active decision path — had zero executed
test coverage. All 4 prior ensureOnnxRuntimeNodeMatchesSystem tests avoided
driving decide() into redirect:true because require.resolve/createRequire
were never mocked, so the two-distinct-directory comparison decide()
depends on always resolved against whatever's actually installed in this
test's real node_modules (a single real copy, not the PR's two-copy
scenario).
Extend loadResolver()'s existing node:module mock to also fake createRequire,
keyed by call origin, so resolveOurOrtNodeDir/resolveDefaultOrtNodeDir can be
driven to two distinct fake directories with distinct CUDA majors — reaching
redirect:true without adding any injection points to production code. Then
capture the installed resolve() closure (mirroring the sibling
onnxruntime-common-resolver.test.ts's captureResolve() pattern) and assert
its three branches directly: onnxruntime-node redirect, onnxruntime-common
redirect, and passthrough for any other specifier.
* fix(embeddings): distinguish ldd detection-failure from no-CUDA-provider
ortCudaMajor treated any execFileSync('ldd', ...) failure with no usable
stdout (missing ldd binary, permission-denied .so, sandboxed exec)
identically to 'CUDA provider genuinely absent'. The pre-PR detection
(hasOrtCudaProvider) only used existsSync, never ldd, so this is a
regression: a CUDA-12 host that worked fine before this PR can now
silently fall back to CPU if ldd itself can't run, even though the
provider .so and system CUDA libs are both genuinely present.
readSoNeeded now reports whether ldd produced any usable output at all,
distinct from 'ldd ran and just found no matching NEEDED entry' (the
existing, already-handled '=> not found' case). When detection genuinely
fails, log a warning so an operator can tell 'CPU fallback because
detection itself failed' apart from 'CPU fallback because no CUDA build
shipped' — the return value stays null either way (the type can't
distinguish a third state), but the two cases are now observably
different via the log.
* fix(embeddings): check ourDir independently of whether defaultDir resolved
decide()'s ourDir fallback lookup was nested inside
'if (systemMajor != null && defaultDir)', so a null defaultDir (transformers'
own onnxruntime-node resolution failing outright, e.g. a partial/broken
install) skipped checking ourDir entirely — getEffectiveOnnxRuntimeNodeDir()
returned null even when gitnexus' own matching CUDA-13 copy would have
resolved fine and worked.
defaultDir resolving is not a precondition for the comparison: an
unresolvable default already counts as 'the default doesn't match', so the
ourDir check now runs whenever systemMajor is known, regardless of whether
defaultDir resolved.
* fix(embeddings): prefer CUDA 13 globally across the env-var directory scan
detectSystemCudaMajor's CUDA_PATH/LD_LIBRARY_PATH scan returned on the
first CUDA-major match within a single dir/sub pair, so a stale .so.12
found early (e.g. a leftover CUDA_PATH entry from a prior install) shadowed
a genuine .so.13 found later in the search path, even though the scan's
own ordering (checking 13 before 12 within each pair) was clearly intended
to prefer 13 wherever possible.
Keep scanning the full search space once a 12 is found, only returning
early once a 13 is found (the best possible answer) or the space is
exhausted.
* fix(embeddings): have onnxruntime-common-resolver defer to the effective onnxruntime-node dir
onnxruntime-common-resolver.ts independently re-derived transformers'
default onnxruntime-node dir (its own copy of the 'resolve transformers'
main entry, then onnxruntime-node' walk) to compute which onnxruntime-common
to pair with — duplicating onnxruntime-node-resolver.ts's own walk, and
capable of disagreeing with it: when the CUDA-major redirect is active,
this hook would still pair onnxruntime-common with transformers' default
(unredirected) onnxruntime-node, not the redirected copy the other hook
just switched onnxruntime-node itself to.
Have it call the already-exported getEffectiveOnnxRuntimeNodeDir() instead
— the same decision the CUDA-major redirect hook uses — so both hooks
always agree on which onnxruntime-node they're pairing onnxruntime-common
against, and the duplicated resolve-walk is removed entirely rather than
merely factored out.
* fix(embeddings): cache the effective CUDA major to remove redundant subprocess spawns
isCudaAvailable() in embedder.ts re-invoked ortCudaMajor/detectSystemCudaMajor
directly even though decide() (via getEffectiveOnnxRuntimeNodeDir) had
already computed both to make its redirect decision — a second, wasted
ldconfig + up to 2 ldd spawns on every initEmbedder() call.
Add effectiveMajor to the memoized Decision, computed once inside decide()
alongside effectiveDir/systemMajor, and export a single
isEffectiveCudaAvailable() that reads straight from the cached decision.
embedder.ts's local isCudaAvailable() wrapper (and its now-unused
getEffectiveOnnxRuntimeNodeDir/ortCudaMajor/detectSystemCudaMajor imports)
is replaced by this one exported function.
* fix(embeddings): surface CUDA redirect state at info level and in doctor
A successful CUDA-build redirect logged only at logger.debug (filtered
by the default 'info' level), and gitnexus doctor's embeddings section
never mentioned the redirect at all — leaving no diagnostic path for
'why is my CUDA-13 host still on CPU' after this PR ships.
Log the successful-redirect line at info (no-redirect/failure paths stay
at debug, since those are the common, expected case). Add
cudaRedirectDoctorStatus(), a pure summary of decide()'s already-computed
decision mirroring doctor.ts's existing localEmbeddingDoctorStatus shape,
and print it as a new literal (non-i18n) 'CUDA:' line in doctor's
embeddings section alongside the existing 'Support:' line, matching that
line's established convention.
* test(embeddings): register onnxruntime-node-resolver.test.ts in the cross-platform subset
The new test file guards on process.platform (linux/darwin cases) but was
absent from cross-platform-tests.ts's PLATFORM_LOGIC list, which
TESTING.md says platform-sensitive tests should be added to — so it never
ran on the Windows/macOS CI matrix, only Ubuntu.
Note: the sibling onnxruntime-common-resolver.test.ts has the identical,
pre-existing gap (it predates this PR) — left as-is here, since fixing
unrelated pre-existing test-registration debt is out of scope for this
PR's own follow-up fixes.
* test(embeddings): strengthen weak assertions, add garbled-output and CUDA_PATH coverage
Three of the four ensureOnnxRuntimeNodeMatchesSystem tests only asserted
'doesn't throw' rather than a concrete outcome — including one literally
named 'idempotent' that never asserted a call count on its own spy.
Strengthen each to assert real outcomes (module stays functional after a
no-op; spy call counts; return-value shape), while keeping the true
install-once idempotency proof in the redirect-active test added earlier
(this file's no-redirect scenario can't exercise it, since registerHooks
is never called either way).
Add the missing edge cases flagged in review: a CUDA_PATH-only fallback
scan test (mirroring the existing LD_LIBRARY_PATH one), and garbled/
unrecognized ldconfig and ldd output cases for both detectSystemCudaMajor
and ortCudaMajor, confirming neither falsely matches a CUDA major on
unparseable input. Also parameterize the non-linux platform test across
both darwin and win32 rather than darwin alone.
Not changed: the process.env reassignment vs. Object.defineProperty
'inconsistency' flagged in review — process.env, unlike process.platform,
has no getter-only restriction, so plain reassignment is already correct
and switching it to Object.defineProperty would be unnecessary ceremony.
* docs(embeddings): note the npm link/symlinked dev-checkout resolution caveat
resolveOurOrtNodeDir/resolveDefaultOrtNodeDir anchor to this module's own
real (post-symlink) location via import.meta.url, so a linked local dev
checkout may resolve against its own node_modules rather than the
consuming app's. Narrow, dev-only blast radius (regular npm/pnpm installs
are unaffected) — document-only, no structural fix warranted.
* fix(test): point the windowsHide spawn-family registry at the file that actually spawns
hooks.test.ts's windowsHide regression check still listed
gitnexus/src/core/embeddings/embedder.ts as a child_process-spawning
file, but this PR itself already moved all execFileSync usage out of
embedder.ts and into the new onnxruntime-node-resolver.ts — without
updating this registry. The check was silently failing at the PR's own
head commit (confirmed: 0 spawn-family calls found in embedder.ts,
'expected 0 to be greater than 0'), a pre-existing gap this fix-pass
surfaced via a full-suite run rather than something introduced by any of
the preceding follow-up commits.
Swap the registry entry to onnxruntime-node-resolver.ts, which does
import execFileSync (ldd + ldconfig, both already correctly passing
windowsHide: true).
* fix(test): make onnxruntime-node-resolver.test.ts path comparisons OS-agnostic
Registering this file in cross-platform-tests.ts's PLATFORM_LOGIC (a
prior commit in this series) means it now runs on the Windows CI matrix,
not just Ubuntu — and several of the fakeDirs-based tests (redirect:true,
ourDir-independent, subprocess-count, doctor-status) compared the
resolver's real join()/dirname() output against hardcoded forward-slash
fixture strings via exact-match or .startsWith().
Node's module is bound to path.win32 (or path.posix) based on the
REAL host OS at process start — stubbing process.platform later, as these
tests already do for the resolver's own platform branching, has no effect
on it. So on a genuine Windows runner, join(effectiveDir, 'package.json')
backslash-normalizes even under a faked platform:'linux', silently
breaking every forward-slash comparison in this file: the createRequire
dispatch would route to the wrong fake require, throw MODULE_NOT_FOUND,
get swallowed by ensureOnnxRuntimeNodeMatchesSystem's outer try/catch, and
registerHooks would never fire — the redirect-active tests would fail
outright on Windows CI.
Normalize every comparison point (the createRequire dispatcher, and the
shared execFileSync/existsSync mocks) with a single toPosix() helper.
Added a forceWin32Path test option (using path.win32's real join/dirname
behavior) to prove this holds without needing an actual Windows runner —
confirmed by temporarily reverting the fix and observing the new test
fail with the exact predicted mismatch before restoring it.
* chore(autofix): apply prettier + eslint fixes via /autofix command
* fix(embeddings): keep CUDA auto-detect working on Node < 22.15 when the default build already matches
The registerHooks guard in decide() returned effectiveMajor: null
unconditionally, so on Node 22.0-22.14 / 23.0-23.4 (engines floor is
>=22.0.0) isEffectiveCudaAvailable() was always false and a CUDA-12 host
whose default onnxruntime-node build already matched — which needs no
hook at all to use the GPU — silently regressed from CUDA to CPU on the
auto device path (pre-PR isCudaAvailable() behavior).
Probe the system and the default copy regardless of registerHooks
availability; only the ourDir redirect branch stays gated on it, so the
probe still never reports a redirect target that cannot be installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: add --limit i18n, negative guard, correct property paths, and zh-CN translations
- Add i18n keys for context/impact/cypher/detect-changes --limit options
- Add zh-CN translations for all 4 --limit option descriptions
- Add Math.max(0, parseInt()) guard to prevent negative --limit
- Fix ALL property path mismatches discovered by audit:
- context: callers/callees → incoming.calls/outgoing.calls+accesses
- impact: upstream/downstream → affected_processes/affected_modules/byDepth
- cypher: rows → row_count cap (rows embedded in markdown string)
- detect-changes: affected_flows → affected_processes
- Change query command from required to optional positional arg with -q alias
- Update @ladybugdb/core from ^0.16.1 to ^0.17.1
- Update typescript from ^5.4.5 to ^5.9.3
* test: add E2E tests for --limit flag across all 5 CLI commands
Tests context, impact, cypher, detect-changes, and query with
--limit 1, baseline comparison, and --limit 0 (falsy/no-op).
detect-changes output is formatted text (not JSON), so those
tests count symbol lines matching 'Type name -> filePath' pattern.
14 tests, all passing. No regressions in 6455 existing tests.
* fix: address Copilot review feedback on --limit guards
- Add Math.max(0, ...) guard to queryCommand limit parsing
- Change if(limit) to if(limit !== undefined) in all 5 commands
(prevents --limit 0 from being treated as falsy/no-op)
- Make queryText parameter optional (Commander may pass undefined)
- Fix usage error strings: --search to -q, --query (en + zh-CN)
* chore(autofix): apply prettier + eslint fixes via /autofix command
* fix(cli): centralize --limit parsing, slice cypher markdown, fix usage text
Address PR review feedback on --limit handling:
- Add a shared parseLimit() helper (Number.isInteger(n) && n > 0), used by all
5 tool commands. Non-numeric / 0 / negative --limit now means "no limit"
instead of the `options.limit ? Math.max(0, parseInt(...)) : undefined` path,
where a string like "abc" is truthy and yields NaN -> slice(0, NaN) -> the
guardrail commands (impact/context/detect-changes) silently emptied results
with exit 0.
- cypher: slice the markdown table to --limit data rows so the reported
row_count matches what is actually printed (was capping row_count while
printing every row).
- Fix query usage string: [search_query] (optional positional) and
`--query <text>` invocation form, not the option-definition
`-q, --query <search_query>` syntax (en + zh-CN).
- Add an E2E regression test for non-numeric --limit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): escape newlines in cypher markdown cells
A multi-line cell value (e.g. a symbol's `content`) was rendered with raw
newlines via String(v), so one logical row spanned multiple physical lines.
That corrupts the markdown table and breaks `cypher --limit`'s line-based
slice (it kept the wrong number of rows, often zero, while row_count
over-claimed). Collapse newlines in formatCypherAsMarkdown so one physical
line == one row; the existing CLI slice is now correct and the pre-existing
un---limited corruption is fixed too. (#2310 review)
* test(cli): de-vacuum the --limit truncation tests
The truncation it()s used the repo-banned vacuous-pass pattern (early-return
on status===null, assertions guarded by if(Array.isArray), bounds-only
toBeLessThanOrEqual — DoD.md:82) against `validateInput`, which has only 1
caller, so context/impact/query --limit 1 compared 1>=1 and stayed green even
if the slice were deleted. Rewrite with unconditional, exact assertions and
target `logMessage` (2 callers, 4 processes) so the no-limit baseline truly
exceeds the limit; detect-changes now mutates two real function bodies (two
changed symbols). Adds a multi-line-cell cypher --limit regression. (#2310)
* test(ci): run cli-limit-e2e in the cross-platform matrix
The --limit E2E suite spawns the real CLI (child_process) but was not in
SPAWN_CLI, so it ran only on Ubuntu — the cross-platform check only fails on
listed-but-missing files, not the reverse (TESTING.md §Cross-platform). Register
it so the --limit regression guard also runs on Windows/macOS, where path
separators, CRLF and the formatted-output arrow differ. (#2310)
* fix(cli): document impact --limit affected-list cap, drop dead byDepth re-slice
`impact --limit` also caps affected_processes/modules, but the help only
mentioned the per-depth cap — so JSON consumers reading the affected lists got
a silently-truncated array. Update en + zh-CN + the command description to say
so. Also remove the client-side byDepth re-slice: the backend already
paginates byDepth to the same limit (paginationLimit = clamp(limit,1,10000),
offset applied backend-side), so the client slice was a guaranteed no-op. (#2310)
* fix(cli): reconcile detect-changes --limit summary, list, and overflow
formatDetectChangesResult computed the "... and N more" overflow from the
already---limit-sliced array length, so under `--limit` the header (true
summary total), the listed rows, and the marker disagreed — e.g. "2 symbols"
in the header but a list of 1 with no marker. Base the overflow on the true
summary.changed_count / affected_count instead, and add the same marker to the
affected-processes list, so header + list + marker stay consistent. (#2310)
* feat(cli): add -l shorthand to impact --limit
The PR added the -l alias to context/cypher/detect-changes but left impact on
the long --limit only, so `impact -l 5` errored while `context -l 5` worked.
Add -l for parity and update the help-i18n OPTION_DESCRIPTION_KEYS key to the
new `-l, --limit <n>` flag string so the description still resolves. (#2310)
* fix(cli): bound all context --limit array categories
context --limit sliced only incoming.calls / outgoing.calls / outgoing.accesses
/ processes, leaving the other relType buckets unbounded — notably
incoming.accesses (bounded on outgoing but not incoming) plus imports/extends/
uses/… and typed_properties. Replace the hardcoded slices with a generic loop
over every array-valued bucket under incoming/outgoing, plus typed_properties
and processes, so --limit caps the whole context payload. (#2310)
* refactor(cli): parse --offset with a parseLimit-style helper
impactCommand parsed --offset with the legacy parseInt/Number.isFinite idiom
while --limit had moved to parseLimit, leaving two parsing styles side by side.
Add a sibling parseOffset helper (non-negative — offset 0 is valid) and use it,
so both options share one idiom; as a bonus it now rejects negative/fractional
offsets instead of forwarding them to the backend. (#2310)
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump @ladybugdb/core to 0.18.0
Pins the release containing LadybugDB/ladybug#605 (TransactionManager
lock-order-inversion deadlock fix). Checked for known post-release
regressions specific to 0.18.0 via the Ladybug issue tracker — none found.
* fix(lbug): re-validate version-coupled comments and regexes for 0.18.0
Extends the LADYBUGDB-CONTRACT re-validation to two spots the marker
convention doesn't catch (bridge-db.ts's LBUG_OPEN_RETRY_PATTERNS,
conn-lock.ts's serialization rationale). Confirms via upstream source
diff (v0.16.1..v0.18.0) that every matched error-text string is
unchanged; conn-lock.ts's rationale is unaffected by #612/#623 since
neither addresses concurrent queries on one connection. Adds a
stemmer-sweep test proving the bundled 0.18.0 FTS extension accepts
every entry in SUPPORTED_FTS_STEMMERS, not just the default porter.
A live-trigger test for isMissingShadowSidecarError was attempted but
abandoned after empirical probing showed it isn't reliably
reproducible (even a SIGKILL-simulated crash didn't reproduce the
error on reopen) — documented as inspection-verified instead of
overclaiming test coverage that doesn't exist.
* test(lbug): add concurrent multi-connection deadlock stress test (#2338)
Directly validates LadybugDB/ladybug#605 — the TransactionManager
lock-order-inversion deadlock between a commit()-triggered checkpoint
and a concurrent beginAutoTransaction() — under a shape close to
GitNexus's real concurrent-writer load, independent of conn-lock.ts's
app-level serialization.
Comparison run against 0.17.1 (pre-fix): 1 of 4 runs hung for the full
60s timeout, a direct reproduction of the deadlock. 9 consecutive runs
against 0.18.0 (post-fix) all passed cleanly. Production is unchanged —
conn-lock.ts still serializes every write; this test validates the
engine-level fix without shipping multi-writer as a default.
* fix(test): address code review findings in multiwriter deadlock test
- Reuse lbug-config.ts's createLbugDatabase (via GITNEXUS_WAL_CHECKPOINT_THRESHOLD)
instead of a hand-duplicated 9-arg raw constructor call whose stated
justification (needing to bypass createLbugDatabase for the threshold
override) was incorrect — the env var already provides it.
- Close every QueryResult via the existing closeQueryResults helper
(write loop, read loop, verify query, setup query) instead of leaking
native cursors, matching lbug-adapter.ts's established pattern.
- Move all cleanup (timers, connections, db close, env var restore) into
the outer finally block so it runs on every exit path, not just the
happy path — a timeout or a writer exhausting its retry budget no
longer leaves dangling timers/connections/abandoned query loops.
Verified: 8 consecutive runs after the refactor, all passing cleanly.
Found via 8-angle parallel code review (medium effort); the two other
findings (isDbBusyError not recognizing LadybugDB's 'Only one write
transaction' message, and shadow-file poll timing sensitivity) are
noted in the PR description as residual — the first is a production-code
change beyond this validation test's scope, the second is inherent to
observing a transient native sidecar file and not cleanly fixable
without overengineering.
* fix(test): apply ce-code-review autofix findings
Fixes from an 8-persona parallel review round (correctness/testing/
maintainability/project-standards/reliability/adversarial/agent-native/
learnings):
- Extract the duplicated skipUnlessFtsAvailable/FTS_UNAVAILABLE_NOTE
helper (previously copy-pasted between lbug-core-adapter.test.ts and
fts-stemmer-sweep.test.ts) into a shared test/helpers/fts-availability.ts.
- Fix a native connection leak: verifyConn in the deadlock test's final
verification block is now pushed into the readers array the outer
finally already closes, so it's cleaned up even if the count query
throws.
- Fix a latent TypeScript type error (tsconfig.test.json catches it,
tsconfig.json doesn't): conn.query() types as QueryResult |
QueryResult[]; narrow to the single-result case before calling
.getAll() rather than assuming the array branch never happens.
- Replace repeated inline InstanceType<typeof import(...)> expressions
with local LbugDatabase/LbugConnection type aliases.
Verified: 12 consecutive runs of the deadlock test all pass, full
lbug-db project (336 tests) green.
Cross-reviewer-confirmed but left as residual (design judgment calls,
not mechanical fixes) for the PR description: isDbBusyError doesn't
recognize LadybugDB's 'Only one write transaction' message (pre-existing
production gap, confirmed independently by 3 reviewers); the deadlock
test's timeout path doesn't cancel in-flight writer/reader loops before
closing connections; the reader loop has no bounded retry for transient
errors during the race window; pinning @ladybugdb/core with a caret
range trades automatic patch updates for less re-validation certainty.
* docs: trim task-referencing JSDoc artifacts, add operator notes
The U2 re-validation pass left verbose 'Re-validated on the
0.17.0->0.18.0 bump (#2338): ...' paragraphs stacked onto 5 production
files' docstrings, alongside the already-updated version numbers. That
narrative (SIGKILL-probe methodology, diff commands run, issue
cross-references) belongs in the PR description, not in code comments
that will accumulate a new paragraph on every future bump and confuse
readers who just want the current fact. Trimmed each to state only the
durable, current-state fact:
- lbug-config.ts, sidecar-recovery.ts, lbug-adapter.ts, bridge-db.ts:
dropped the bump-narrative paragraphs; kept only genuinely durable
notes (e.g., which matchers are inspection-verified vs live-tested,
what upstream wording changed).
- conn-lock.ts: compressed a 12-line, 3-issue-number enumeration into
2 lines stating the current conclusion (no upstream 0.18.0 fix
addresses the same-connection-concurrent-query risk this lock
guards against).
Also added operator-facing notes to GUARDRAILS.md and RUNBOOK.md's
existing 'LadybugDB lock' sections: an isDbBusyError gap found during
this validation (LadybugDB's 'Only one write transaction...' message
isn't recognized by our busy/lock retry matcher) means that specific
error can surface unretried. Documented so it's recognized as the same
single-writer conflict, not a new failure mode.
* refactor(test): use gitnexus-shared's withRetry in multiwriter deadlock test
Replaces the hand-rolled writeWithRetry/sleep loop with the existing
gitnexus-shared retry helper (already used by embeddings/hf-env.ts)
instead of duplicating the pattern.
* fix(test): guarantee non-zero retry delay in deadlock test's writer loop
withRetry's isRetryable previously returned {retry: bool} with no afterMs,
so computeBackoffMs's exponential-jitter formula gave a deterministic
zero-delay on the first retry (floor(random()*1) is always 0 at attempt=0).
This contradicted the file's own documented tuning, which specifically
needs a non-zero 1-3ms delay to avoid tripping a different native guard.
Return an explicit afterMs override on the retryable branch instead.
* docs(test): remove dangling doc references from deadlock test JSDoc
The JSDoc pointed to a local-session-only docs/plans/2026-07-01-001-...
path (docs/ is repo-gitignored, so this never existed for anyone but the
implementing session) and to "the PR description" as a source of truth
that stops being current once the PR merges. Replace both with
self-contained prose and durable references (issue/PR numbers, commit
SHAs, GUARDRAILS.md/RUNBOOK.md) that stay resolvable after merge.
* fix(search): harden SUPPORTED_FTS_STEMMERS against external mutation
Type as ReadonlySet<string> to match this codebase's established
convention for exported validation allowlists (EVAL_SERVER_TOOLS,
STRUCTURAL_LABELS). Type-only change — no behavior change; both the
internal .has() check and the sweep test's spread-iterate pattern
continue to work unchanged.
* docs(guardrails): fold Known-gap note into the LadybugDB Sign's Why label
GUARDRAILS.md's own convention is strictly Trigger/Do/Why per Sign
entry (stated in the file's header, followed by all 5 other entries).
The new isDbBusyError gap note introduced a 4th label; fold it into
Why instead, which is what it's actually explaining.
* fix(test): run the multi-writer deadlock test on Windows too
itLbugMultiwriter mirrored lbug-core-adapter.test.ts's win32 skip, but
that pattern exists for a close-then-reopen-same-path lock lingering
bug (kuzudb/kuzu#3872). This test never reopens the database — it
holds connections open for the whole run — so the skip excluded the
one test validating issue #2338's deadlock fix from the platform
conn-lock.ts actually ships native bindings for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(indexing): keep full text file content searchable
* fix(indexing): flush CSV chunks by byte size
* fix(fts): flatten newlines/tabs in indexed content so multiline files are searchable (#2317)
The end-to-end FTS test (review follow-up F2) exposed that removing the 10KB
cap alone does NOT fix#2317: Ladybug's FTS tokenizer splits ONLY on the space
character — \n, \r, and \t are not delimiters. So multiline file/symbol content
indexes as a few giant cross-line tokens that no word query matches; full
content is stored but stays unsearchable. (Verified: identical 8KB content is
fully searchable when space-separated and entirely unsearchable when
newline-separated.) The existing fts-description-search test never caught this
because all its seed content is single-line.
Collapse \r\n\t -> single space in the FTS-indexed text (extractContent's File
and snippet content, plus the description column) via normalizeFtsText. This
rewrites the stored column too, so File content returned via the graph API is
space-flattened — an accepted trade for making file/symbol text searchable.
Add the real end-to-end guard test/integration/fts-fullfile-search.test.ts:
write a >16KB file, load it through the real streamAllCSVsToDisk -> COPY ->
createSearchFTSIndexes path, and assert searchFTSFromLbug returns a needle past
10KB (plus a short-content no-regression and a stored-cell-not-truncated
guard). It drives the COPY path a Cypher-seed test would bypass, reusing
withTestLbugDB's FTS-availability gating via a new before-FTS load hook.
* docs(lbug): note the deliberate File-unbounded / snippet-capped asymmetry
The File branch returns full content (whitespace-normalized for FTS, bounded
upstream by the walker cap) while the symbol snippet path 11 lines down stays
MAX_SNIPPET-capped. Comment the intent so the uncapped File branch doesn't read
as a forgotten guard. No behavior change.
* test(lbug): update #2203 overlap round-trip for FTS whitespace normalization
The newline/tab→space normalization (a170915a, #2317) flattens stored File
content, so the #2203 overlap test's "File content == original multiline
source" assertion no longer holds. The test's actual invariant — overlap path
== serial path, byte-for-byte — is unchanged and still asserted; BasicBlock
text (not FTS-indexed) still round-trips raw. Update only the File-content
expectation to the whitespace-flattened form and document why.
* fix(lbug): collapse CSV flush to a single byte threshold
BufferedCSVWriter flushed on row-count (FLUSH_EVERY=500) OR byte-count
(FLUSH_BYTES=8MB) — two independent triggers for one job. Byte count is
the only one tied to the actual risk (an unbounded buffer.join('\n')
string), so drop FLUSH_EVERY and make shouldFlushCSVBuffer single-arg.
Rather than tune FLUSH_BYTES by guesswork or expose it as an env knob,
derive its safety margin from constants the codebase already hard-enforces:
a single row is capped at TREE_SITTER_MAX_BUFFER (32MB, clamped regardless
of GITNEXUS_MAX_FILE_SIZE) and at most doubled by escapeCSVField's
quote-escaping, so the worst-case joined chunk (FLUSH_BYTES + 2 *
TREE_SITTER_MAX_BUFFER ≈ 72MB) sits >7x under Node's MAX_STRING_LENGTH
(~512MB) — the ceiling that throws RangeError: Invalid string length.
A new test pins that margin numerically so it can't erode unnoticed, which
covers the "configurable" alternative better than a knob would: there's no
evidence any deployment needs a different value, and an unbounded env var
would let an operator silently walk the margin back into the danger zone.
Also updates the two tests tied to the removed row-count path: the
FLUSH_EVERY-boundary integration test now crosses FLUSH_BYTES with real
oversized File content instead of relying on row count, and the
shouldFlushCSVBuffer unit test drops to the new single-arg signature.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* 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>
* 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>
* fix(mcp): stabilize api_impact response shape for same-URL multi-verb routes
After #2302 made Route identity method-aware, a same URL exposes one Route
node per HTTP verb, so a bare-URL api_impact lookup could silently flip from a
direct route object to the wrapped { routes, total } envelope. Surface each
route's `method` (via the shared fetch) so multi-verb results are
distinguishable, and add an optional `method` selector that narrows a
multi-verb URL/file to one verb and forces the singular shape. A verb that
matches no route returns a clear error. Document the match-count contract in
the tool schema.
Refs #2308
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(mcp): cover same-URL multi-verb api_impact contract
Regression coverage for #2308: bare-URL and bare-file lookups of a same-URL
GET+POST pair return the wrapped form with distinct per-route methods; the
method selector collapses to the singular shape (case-insensitively); an
unmatched verb returns a verb-not-found error; and verbless routes surface a
null method.
Refs #2308
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): apply autofix feedback
- tools.ts: correct api_impact contract docs — `method` narrows to one verb
but the singular shape only holds when exactly one route remains after
filtering (substring route/file matches can still wrap); cover file lookups;
enumerate verbs.
- local-backend.ts: surface `method` in route_map and shape_check output (the
shared fetch already returns it; agents discover verbs there before
api_impact).
- local-backend.ts: compute routeCountByHandler from the unfiltered match so a
method-scoped api_impact still flags a multi-verb handler's partial middleware.
- tests: add file+method and verbless-exclusion cases; assert unconditionally
via toMatchObject; lowercase the verb-not-found input to exercise error
uppercasing.
Refs #2308
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): treat wildcard '*' routes as matching any api_impact method selector (#2308)
Method-agnostic routes (Django function views) persist with Route method
'*', not null. The api_impact method selector used exact verb equality, so
'*' routes were excluded and api_impact({route, method:'POST'}) falsely
reported 'No routes found' for a route that handles every verb. Treat '*'
as matching any requested verb, and correct the comment + tool-description
strings that wrongly grouped Django wildcards with null/verbless routes.
* fix(mcp): harden api_impact method input against non-string and empty values (#2308)
The MCP envelope is not schema-validated, so a non-string `method` reached
`.toUpperCase()` and threw a TypeError. Widen the param to `unknown` and guard
it with a typeof check that returns a structured error (mirroring the
resolveAliasString pattern from #2175), and collapse empty/whitespace verbs to
no selector.
* fix(mcp): distinguish url-not-found from verb-not-found in api_impact error (#2308)
The verb-not-found error appended 'with method "X"' even when the URL/file
itself did not exist, implying the URL exists with other verbs. Gate the verb
clause on matched.length > 0 so a non-existent URL/file gets the plain message.
* fix(mcp): clarify api_impact middlewareNote wording for verbless siblings (#2308)
The partial-middleware note claimed 'other methods in this handler' even when
the co-located sibling is a verbless (null) route rather than another HTTP
verb. Refer to 'other route exports' instead, which covers both cases.
* docs(mcp): document and test the method field on route_map and shape_check (#2308)
The shared fetchRoutesWithConsumers change surfaced a method key on route_map
and shape_check responses too, but their tool descriptions never mentioned it
and no test covered it. Document the field on both descriptions and add unit
tests asserting it (shape_check rows carry responseKeys + a consumer so they
survive shape_check's keys-and-consumers filter).
* test(mcp): cover middlewareDetection 'partial' survival under a method filter (#2308)
The diff's core behavioral line counts verbs-per-handler from the unfiltered
match set so a method-scoped query still flags a multi-verb handler's partial
middleware, but no test exercised it (every verbRow hardcoded middleware:null).
Add a middleware param to verbRow and a test that fails if the count is taken
from the post-filter set instead. Verified via mutation: matched->routes fails it.
* test(mcp): add live-LadybugDB integration coverage for route method round-trip (#2308)
The new n.method query column was only unit-mocked. Add a self-contained
integration suite that seeds GET+POST /api/orders and a method-agnostic '*'
Django route, then asserts api_impact surfaces method, narrows by verb, and
matches the '*' route end-to-end (the U1 fix), plus route_map surfacing.
Own seed + no FTS so it neither perturbs api-impact-e2e nor silently skips.
* refactor(mcp): type the api_impact response shape instead of Promise<any> (#2308)
Replace apiImpact's Promise<any> with an explicit ApiImpactResult union
(single route | wrapped { routes, total } | { error }) and a typed
ApiImpactRoute. The results.map is annotated so the response builder is
checked against the declared shape. Behavior unchanged; sibling MCP methods
keep their Promise<any> convention.
* fix(mcp): express the route-or-file requirement in the api_impact schema (#2308)
The inputSchema left route/file as bare optionals, so the 'at least one of
route/file' rule the handler enforces was invisible to clients. Add an optional
anyOf to ToolDefinition (forwarded verbatim by the ListTools handler) and an
anyOf:[{required:[route]},{required:[file]}] on api_impact. Matches runtime
(both allowed, route wins); 'at least one' not 'exactly one'.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ingestion/routes): give Route nodes a (method, url) identity (#2289)
Route node identity was URL-only, so a same-URL multi-verb pair
(GET /x + POST /x) collapsed into a single node and the second verb's
handler and execution flow were silently lost. Route identity is now
(method, url) via routeNodeKey(method, url): a known, specific verb keys
as "METHOD url", while a method-less route (filesystem routes — Next.js /
Expo / PHP — and Laravel resource/apiResource) or a wildcard "*" route
(e.g. Django function views) falls back to URL-only. The fallback is
byte-identical to the previous URL-only ids, so only genuine
declaration-style multi-verb routes split into separate nodes.
The identity key is shared across the three phases that must agree on the
Route node id:
- routes phase: registry key + node id + handler-symbol lookup; the Route
node still carries the bare URL as its display name.
- call-processor: resolveRouteHandlerSymbols re-keyed by identity so each
verb resolves its own handler; a verb-less fetch() consumer matches by
URL and connects to every Route node at that URL (one per verb).
- processes phase: ENTRY_POINT_OF targets the identity-keyed node id.
Bumps INCREMENTAL_SCHEMA_VERSION 4 -> 5: persisted pre-v5 Route nodes use
the old url-only ids, so an incremental top-up would strand them alongside
new composite-keyed nodes — force a full re-analyze instead.
Part of #2280.
* fix(ingestion/routes): address PR #2302 review (P1/P2/P3)
P1 — Schema v5 fast-path bypass (run-analyze.ts):
Adds a schemaVersion-mismatch guard above the alreadyUpToDate early-return,
mirroring the pdgModeMismatch slot. Without it, a same-commit re-analyze on
a pre-v5 stamp returned alreadyUpToDate without ever reaching the
isIncremental gate, defeating the v5 schema bump's migration intent.
Regression test covers: analyze (stamps v5) → meta downgrade to v4 → same
commit re-analyze must NOT early-return and meta restamps to v5.
P2 — ENTRY_POINT_OF handler-aware linking (processes.ts):
Pre-fix routesByFile fanned every same-file Route to every same-file
process, cross-wiring same-file GET/POST handlers. Now reads handlerSymbolId
off the Route graph node (the source of truth routes.ts stamps) into
routesByHandlerId, with a routesWithoutHandlerByFile fallback — mirrors the
Tool linking precedent 10 lines below. Two regression tests: weak form
(only one handler has a process; sibling verb does not get spuriously
attached) and strong form (both handlers form distinct processes; each
Route links to exactly its own entryPoint, 2 edges not pre-fix 4).
P2 — Roundtrip composite-id (route-{method,handler-symbol}-roundtrip):
Both tests now seed the Route node with
generateId('Route', routeNodeKey('POST', '/api/orders')) and run the
Cypher MATCH against the composite id, exercising the literal-space-in-id
through CSV→COPY→HANDLES_ROUTE_QUERY. A space-in-id escape regression
would surface here instead of being silently swallowed by the extractor's
catch.
P3 — doc-drift + test if:
- route-path.ts:4 — header updated to "(method, url) via routeNodeKey"
- java.ts:684 — drop "Route nodes are URL-keyed"; #2289 closes that gap
- manifest-extractor.ts:196 — explicit that Route node *id* is composite
while route.name remains the bare URL
- multi-verb-route-identity.test.ts:88 — forEachRelationship+if rewritten
as a .filter().map() chain (no test-level conditional). New
route-process-linking tests are also if-free.
Validation: tsc clean, prettier clean, 9 touched suites / 43 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(ingestion/routes): drop routes.ts re-export, fix CI fast-path tests
Two follow-ups on PR #2302's CHANGES_REQUESTED review:
1. Drop `routes.ts` re-export of `normalizeExtractedRoutePath` /
`normalizeRouteMethod` / `routeNodeKey` (per @magyargergo's inline
comment at routes.ts:153 — the symbols already live in
`route-extractors/route-path.ts` and consumers should import them
from the source, not via a routes-phase indirection that was kept
only as a compat shim during the #2289 refactor). Updated the two
remaining callers (blade-template-routes / spring-route-extractor-
parity tests) to import directly from `route-extractors/route-path.js`.
`call-processor.ts` and `processes.ts` already import from the source.
2. Fix two `run-analyze.test.ts` fast-path tests that started failing
on CI after the schema-version mismatch guard landed (
"creates .gitnexus/.gitignore on the already-up-to-date fast path"
and "reports isPrimaryBranch false for an up-to-date non-primary
branch"). The test fixtures hand-built a RepoMeta with NO
schemaVersion field; with the guard now checking
`existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION`, that
pre-versioning shape was treated as a mismatch and forced a rebuild,
short-circuiting the fast path the tests exercise. Stamp the current
schemaVersion on those fixtures so they reflect the post-#2289 meta
shape production actually writes (`runFullAnalysis` always stamps
the field on git repos — see meta save site).
Validation: tsc clean, prettier clean, 11 touched suites / 80 tests pass.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(search): index description column for FTS so doc comments are keyword-searchable
Closes#2299. descriptionExtractor (#2286) populates the `description`
column for every symbol table, but FTS only indexed name+content on 5
tables, so doc-comment keywords (Javadoc/KDoc/godoc/Rust ///) were
invisible to BM25 keyword search.
- Add `description` to the Function/Class/Method/Interface FTS indexes
(File has no description column, left as name+content).
- Add FTS indexes for the remaining EMBEDDABLE_LABELS symbol tables
(Struct, Enum, Trait, Impl, Macro, Namespace, Constructor, TypeAlias,
Typedef, Const, Property, Record, Union, Static, Variable).
- createSearchFTSIndexes now drops-then-creates each index so the schema
change reaches existing DBs on incremental re-analyze and --repair-fts
(createFTSIndex is idempotent-by-name and would otherwise skip stale
indexes).
Tests: fts-schema column-subset + coverage guards; drop-before-create
order; e2e doc-comment keyword search (Java class + Rust struct found by
description-only terms). bm25-search assertions derive from FTS_INDEXES.
* fix(review): apply autofix feedback
- Guard the --repair-fts path on FTS-extension availability before
createSearchFTSIndexes drops-then-creates indexes (P1 regression:
without the gate, an unavailable extension could drop existing indexes
then fail to recreate them, leaving the DB index-less). Mirrors the
analyze path's ftsAvailable gate and fails loudly first.
- Add a re-analyze upgrade integration test: seed an old name+content-only
DB (no Struct index), run the real createSearchFTSIndexes(), and assert
description keyword search + the previously un-indexed Struct now resolve.
Proves drop-then-create upgrades a live stale index end-to-end.
* fix(ci): add loadFTSExtension to --repair-fts test mocks
The R3 review fix added a loadFTSExtension availability gate to the
--repair-fts path, but run-analyze-fts-repair.test.ts mocked the lbug
adapter without that export, so both repair tests threw `No
"loadFTSExtension" export`. Add loadFTSExtension to the two mocks
(returning true to preserve their original intent) and add a dedicated
test proving the guard fails loudly — and does NOT drop any index —
when the extension is unavailable.
* test(fts): run fts-description-search in the sequential lbug-db project
It was the only FTS-index-creating integration test left in the parallel
`default` vitest project; every other ftsIndexes-using test (search-core,
search-pool, augmentation, …) runs in the `lbug-db` project, which forces
fileParallelism: false to avoid LadybugDB native mmap file-lock conflicts
in parallel forks (Windows). Add it to the lbug-db include list and the
default exclude list to match the convention and remove the flake risk.
* test(ci): fail loudly when FTS extension is unavailable, never silently skip
FTS-dependent lbug integration suites (search-core, search-pool,
augmentation, fts-description-search, …) self-skip via ctx.skip() when the
LadybugDB FTS extension can't load, emitting only a console.warn while the
job stays green. That means a broken/missing FTS extension in CI would make
these integration tests silently vanish with no signal — false confidence.
withTestLbugDB now honors GITNEXUS_REQUIRE_FTS=1: when set and the extension
is unavailable, setup() throws instead of skipping, so the suite fails
loudly. The CI test jobs (ubuntu coverage + windows/macOS cross-platform)
set the flag; local/offline runs leave it unset and keep skipping
gracefully. (Verified the extension currently loads on all three runners,
so this is a guard against regression, not a behavior change today.)
* test(ci): run fts-description-search on macOS/Windows cross-platform jobs
The new FTS description-search suite was registered in the sequential
lbug-db vitest project (ubuntu/coverage) but absent from LBUG_NATIVE, so
the macOS/Windows platform-sensitive jobs (which run only the explicit
ALL_CROSS_PLATFORM allowlist via run-cross-platform.ts) never executed it.
The GITNEXUS_REQUIRE_FTS=1 hardening on those jobs guarded the old FTS
fixtures but not the new 20-index/description path. Add the suite to
LBUG_NATIVE so the new path is validated cross-platform too.
Refs #2299.
* fix(search): verify FTS indexes cover description, not just queryability
verifySearchFTSIndexes probed each index with QUERY_FTS_INDEX and treated
'queryable' as 'present'. A stale name+content-only index left on a
pre-#2299 DB stays queryable yet silently misses the description column, so
verification would pass green while doc-comment search stayed broken.
Switch to a single CALL SHOW_INDEXES() that exposes property_names per
index, and report an index as missing when it is absent OR does not cover
its configured columns. Return contract (string[] of table.indexName) is
unchanged, so both run-analyze.ts call sites are untouched. The per-index
string interpolation is gone, so the now-dead safeIdentifier helper is
removed.
The real caller of the live function in tests is bm25-search.test.ts (the
repair test mocks verifySearchFTSIndexes wholesale); its two probe-shaped
cases are rewritten to feed SHOW_INDEXES rows and now assert column
coverage, plus an absent-index case.
Refs #2299.
* test(search): assert description search via the public query surface
The #2299 integration suite only exercised the searchFTSFromLbug helper.
Add a third block that drives the public LocalBackend.callTool('query')
path — which resolves the repo via the registry and routes BM25 through the
pool adapter (a different connection context than the core-adapter helper) —
and asserts a description-only keyword returns the seeded class. Reuses the
existing description-only SEED and production FTS_INDEXES; partial-mocks
repo-manager so listRegisteredRepos points at the test DB while
cleanupOldKuzuFiles and the rest stay real.
Refs #2299.
* test(search): make lbug-core-adapter FTS gate honor GITNEXUS_REQUIRE_FTS
lbug-core-adapter.test.ts has its own per-test FTS gate (skipUnlessFtsAvailable)
that called ctx.skip() whenever the extension could not load — bypassing the
GITNEXUS_REQUIRE_FTS=1 hardening that withTestLbugDB already honors. Since this
file is in LBUG_NATIVE it runs on the ubuntu/macOS/windows jobs that all set
GITNEXUS_REQUIRE_FTS=1, so an FTS regression on a runner would have let these
FTS-primitive tests silently vanish from a green run — the exact gap #2299's
test-infra hardening set out to close.
Make the helper mirror withTestLbugDB: when GITNEXUS_REQUIRE_FTS=1 and the
extension is unavailable, throw (hard fail) instead of skipping. Offline/local
runs (no env var) still skip gracefully.
Refs #2299.
* feat: ✨ resolve Nuxt/Nitro auto-imports in TypeScript scope resolver
* fix: 🐛 skip self-referential edges in Nuxt auto-import emission
* fix: 🐛 address Sourcery review -- gate Nitro scan on imports.d.ts and pre-index explicit imports
* fix: scope Nuxt auto-import resolution
* fix: address Nuxt auto-import review follow-ups
* fix(ingestion): capture only LHS binding names in Nitro server-util exports
The Nuxt server-util export scanner ran a declarator regex over the whole
`export const …` right-hand side, so it registered RHS tokens as auto-import
names: arrow-function parameters (`export const f = (event) => …` → `event`),
object-literal keys (`export const c = { onError } ` → `onError`), and bare
operands. It also dropped generic-typed declarators
(`export const x: Map<a, b> = …`) because the type-annotation skip broke at the
comma inside the generic. Both produced wrong/missing auto-import CALLS edges.
Capture only the leading binding name of each top-level declarator via a
depth-aware comma splitter (tracks (), [], {}, <>), skipping destructuring
patterns. Nitro auto-imports only surface top-level binding names, so the RHS
is never parsed. Adds unit coverage for the param/object-key/operand/generic
and multi-declarator forms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
* fix(ingestion): stop Nitro server callers resolving client composables
`getNuxtAutoImportEntry` fell back to the client composable map when a
`server/api|routes|middleware` caller's name had no `server/utils` entry. But
Nitro only auto-imports `server/utils/**` into the server context — app
`composables/` are Vue-app-only — so that fallback minted CALLS/IMPORTS edges
Nitro never creates (e.g. a server route "calling" a composable it cannot see
without an explicit import).
Server callers now resolve the server map only. Restructure the barrel-directory
integration test to use a client caller (which legitimately auto-imports the
composable) so `index.*` resolution stays covered, and add a negative assertion
that `server/api/route.ts` emits no edge to `composables/*` while its real
`server/utils` call still resolves. Unit test locks that a server caller does
not fall back to a client-only name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
* fix(ingestion): let unresolved explicit imports shadow Nuxt auto-imports
The explicit-import suppression index only recorded import local names whose
edge resolved to a file (`edge.targetFile !== null`). An explicit import from an
unresolved external package — `import { useAuto } from '@vueuse/core'; useAuto()`
— therefore escaped suppression, and the post-resolution hook emitted a spurious
Nuxt auto-import CALLS edge for a name the file already imports explicitly.
Record the local name regardless of whether the import resolved: an explicit
import is authoritative shadowing intent. Adds an integration fixture importing
from an external package and a (non-vacuous) assertion that it emits no nuxt
edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
* fix(ingestion): let type-annotated params shadow Nuxt auto-imports
hasLocalBindingInScopeChain only consulted scope.bindings, but type-annotated
function parameters live in scope.typeBindings (the TS scope query records them
as `@type-binding.parameter`, not `@declaration`). A parameter named like a
composable therefore failed to suppress the auto-import, leaking a spurious
CALLS edge.
Also check scope.typeBindings for the name (same-file scopes only). typeBindings
holds value-space binders' type facts (parameter annotations, `self`, variable
annotations) and never a pure type that belongs to callable space, so this
cannot over-suppress a real auto-import.
Documents the residual: function-typed params (`p: () => void`), untyped params,
destructured locals, and catch-clause vars are captured by neither map and still
leak — closing that needs shared scope-query changes beyond this feature, left
as a follow-up. Also adds a no-vacuous-pass guard to the shadowing/noise test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
* fix(ingestion): treat server/plugins and server/tasks as Nitro runtime
isNitroServerRuntimeFile only matched server/api, server/routes, and
server/middleware. Nitro also auto-imports server/utils into server/plugins
and (since Nitro 2.6) server/tasks, so callers there were misrouted to the
client composable map. Extend the prefix set (now a named constant) to cover
them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
* fix(ingestion): merge duplicate JSDoc on collectImportsDts
Two consecutive JSDoc blocks preceded collectImportsDts; tooling (IDEs,
TypeDoc) attaches only the last one, silently dropping the descriptive block.
Fold the "returns true when read" line into the descriptive block as a
`@returns` tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
* fix(ingestion): contain .nuxt/imports.d.ts source resolution to the repo
A crafted `.nuxt/imports.d.ts` source such as `from '../../../../etc/passwd'`
passes the project-local relative-path check but resolves outside the analyzed
repo, causing fs.stat probes against arbitrary host paths. Skip any source that
resolves outside repoRoot before touching the filesystem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ingestion): add shared leading-doc-comment description extractor (#2270)
Add `extractLeadingDocComment` plus a language-neutral
`createLeadingDocDescriptionExtractor` factory and a shared
`DOC_BEARING_LABELS` set to `utils/ast-helpers.ts`. The helper pulls the
normalized text of a leading doc comment off a definition node's preceding
named sibling, covering both block doc comments (Javadoc/KDoc/JSDoc/PHPDoc/
Doxygen, opened by double-star or bang) and runs of line doc comments
(triple-slash, bang-slash, or caller-supplied prefixes such as Go's
double-slash or Ruby's hash). Grammar-agnostic by prefix match; widens
`getDefinitionNodeFromCaptures` to accept the optional-valued capture map.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Eh2UmA6f2p25F3ow75Hjzx
* feat(languages): surface leading doc comments as description for all languages (#2270)
Register the leading-doc `descriptionExtractor` on every documentable
provider so Javadoc/KDoc/JSDoc/Doxygen/godoc/RDoc/`///` doc text lands in the
`description` column and reaches the embedding metadata header — making
methods/types semantically searchable by doc-only terms, matching the
behavior Python (docstring) and PHP (Eloquent) already had.
- Java, Kotlin, TypeScript, JavaScript, C, C++, C#, Dart, Rust, Swift: default
config (block + triple-slash/bang-slash doc comments).
- Go: godoc double-slash leading comments.
- Ruby: leading hash (RDoc/YARD) comments.
- PHP: existing Eloquent metadata takes precedence, else PHPDoc docblock.
Field/property/variable/const docs are intentionally out of scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Eh2UmA6f2p25F3ow75Hjzx
* fix(review): apply autofix feedback (#2270)
Code-review autofix pass on the leading-doc-comment extractor:
- Enforce start-row adjacency in the line-comment run so a doc run stops at a
blank line (godoc/RDoc/rustdoc semantics). Prevents a Go license/earlier `//`
block or a Ruby shebang + `# frozen_string_literal:` magic comment, separated
by a blank line, from being absorbed into the first declaration's
description. Adjacency uses startPosition.row (reliable across grammars).
- Fix the degenerate empty comment `/**/` producing a spurious `/` description.
- PHP: compose createLeadingDocDescriptionExtractor() as the docblock fallback
instead of duplicating its body, and widen the param to CaptureMap to match
the LanguageProvider hook contract.
- Drop the factory's unused `labels` option (no consumer overrides it).
- Add tests: degenerate `/**/`, multi-line `///` run, `//!` inner doc, `/*!`
Doxygen block, Go/Ruby blank-line non-attachment + two-block adjacency, and
PHP Eloquent-metadata-wins-over-docblock ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Eh2UmA6f2p25F3ow75Hjzx
* fix(ingestion): resolve exported TS/JS JSDoc via export_statement wrapper
Exported TS/JS declarations dropped their JSDoc: the TS query captures the
inner function_declaration/class_declaration, whose previousNamedSibling is
null because the JSDoc precedes the wrapping export_statement (PR #2286 review,
reproduced). Add a wrapperNodeTypes option to extractLeadingDocComment (folded
into a LeadingDocCommentOptions object threaded through the factory); when the
captured node yields no doc and its parent type is a configured wrapper, retry
from the parent. TS/JS providers pass ['export_statement']. Language config
stays at the call site (RFC #909). Mirrors the existing walk-up in
languages/javascript/captures.ts for JSDoc params.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): bound DOC_BEARING_LABELS to embeddable labels
Module/Delegate/Annotation were doc-bearing but absent from EMBEDDABLE_LABELS,
so their descriptions were extracted and written to the DB yet never embedded
or searchable (PR #2286 review) — wasted work, and the factory JSDoc overstated
"becomes semantically searchable". Remove those three labels so DOC_BEARING_LABELS
is a subset of EMBEDDABLE_LABELS, narrow the JSDoc, and add a subset-invariant
unit test to guard against drift. Making those labels (and C++ `Template`)
searchable needs an embedding-pipeline/schema change and is left as a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): skip file-top license/header blocks as descriptions
A file-top /** … */ license/copyright/overview block has no package/import
sibling to shield it from the first declaration, so it was absorbed as that
symbol's description and polluted the embedding text (PR #2286 review). The
block-comment branch already cannot use a strict row-adjacency check (grammars
fold the trailing newline into the comment node), so match header markers
instead — SPDX-License-Identifier, @license/@file/@fileoverview, "Licensed
under", and copyright-with-(c)/year. Markers are specific enough not to fire on
an ordinary doc that merely mentions the word "copyright" (over-fire guard test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): ignore Go/Ruby directive & magic comments in doc runs
Go build/tool directives (//go:build, //go:generate, // +build, //nolint, //line)
and Ruby magic comments / shebang (# frozen_string_literal:, # encoding:, # -*-,
#!, …) sitting directly above a symbol were folded into its description and
polluted the embedding text (PR #2286 review). Add a lineDirectivePrefixes option;
a matching line is skipped in the doc run (skip-and-continue, so a real doc above
an interleaved directive is still collected — godoc/RDoc semantics). Go and Ruby
providers supply their own directive prefixes (RFC #909 — config at the call site).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): guard descriptionExtractor call against throws
A throw inside any provider's descriptionExtractor escaped processFileGroup to
the language-group catch, which treats any throw as "parser unavailable" and
silently drops every remaining file in the group (PR #2286 review). Wrap the
call in try/catch + reportWarning, mirroring the adjacent extractTemplateConstraints
guard. Defensive parity — no behavior change on the success path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): treat Rust //! and /*! as inner docs
Rust //! and /*! are INNER doc comments (they document the enclosing item/module),
not the following item, but the shared helper attached them to the next definition
(PR #2286 review; a test even enshrined the wrong behavior). Add a blockDocPrefixes
option (default ['/**','/*!']); the Rust provider opts out of both inner-doc markers
(lineCommentPrefixes ['///'], blockDocPrefixes ['/**']). Doxygen //! and /*! keep
working for C/C++ via the defaults. Flip the Rust //! test to a negative assertion
and add a Rust /*! negative case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): strip bidi/zero-width controls from doc descriptions
Doc-comment text is attacker-influenceable (any indexed repo) and is returned
verbatim to MCP clients, so a description could smuggle Trojan-Source-style bidi
overrides or zero-width characters (PR #2286 review). Strip U+202A–202E,
U+2066–2069, U+200B–200D and U+FEFF in the doc-comment normalization path
(block + line). Scoped to the description path only — global sanitizeUTF8 is
deliberately left alone (pre-existing, affects all fields). Implemented with a
code-point predicate so no literal invisible bytes live in the source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): doc-comment helper maintainability cleanups
PR #2286 review nits (no behavior change): drop the unused `export` on
DEFAULT_LINE_DOC_PREFIXES (no importer outside ast-helpers.ts); widen
getLabelFromCaptures' captureMap param to `Record<string, SyntaxNode | undefined>`
to match getDefinitionNodeFromCaptures (all accesses are truthiness-guarded); and
merge the split ast-helpers import statements in dart/ruby/rust into one each.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(architecture): document descriptionExtractor LanguageProvider hook
descriptionExtractor is now a near-universal LanguageProvider field (issue #2270)
but was missing from the architecture "Key fields" table (PR #2286 review). Add a
row describing it and the shared createLeadingDocDescriptionExtractor factory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): end-to-end description searchability for exported symbols
The unit tests stop at the descriptionExtractor hook; nothing proved a doc
comment survives the full parse pipeline into node.properties.description (the
field the embedding metadata header reads) — the exact gap that hid the exported
TS/JS regression (PR #2286 review). Add an integration test running the real
worker pipeline over an exported, JSDoc'd TS function and asserting its node
description carries the doc text. Verified locally against a built worker
(20s); runs in CI via pretest:integration build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(ingestion): prettier-wrap a long line in the doc-comment test
Formatting-only follow-up to the U3/U7 test additions so `quality / format` is
green. 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>
* feat(routes): extract Spring method-level array-form routes in ingestion + extractor parity test (#2138 follow-up)
ingestion's `extractSpringRoutes` (route-extractors/spring.ts) matched only a
single string literal on `@(Get|...)Mapping`, so the array form
`@GetMapping({"/a","/b"})` produced no graph Route node — while the group-layer
`java.ts` scan did match it. That divergence was the root of the #2265 array-form
parse-skip gap.
- spring.ts: add the array-form alternation
`[(string_literal) @value (element_value_array_initializer (string_literal) @value)]`
to the two method-declaration query branches (positional + `path=`/`value=`),
mirroring the group query. A multi-element array yields one match per element,
so the Phase 2 loop emits one route per path with no other change. Class-level
`@RequestMapping` array prefixes remain single-literal (rare; left to a
follow-up).
- test: spring-route-parity runs one shared Java fixture through BOTH extractors
(ingestion `extractSpringRoutes` + group `JAVA_HTTP_PLUGIN.scan`) and asserts
identical provider {method,path} sets — the parity guard the maintainer asked
for in #2078, so the two Spring extractors can't silently drift again
(verified: reverting the array branch turns the parity test red).
* fix(ingestion/routes): suppress wrong unprefixed route under class-array @RequestMapping; cover named-array + class-array parity
Addresses PR review on #2281:
- P2 class-array wrong-route: class branches now match the array form only to detect it; a method-level array route under a class-level array-form @RequestMapping is suppressed rather than emitted with a dropped prefix, so ingestion stays a strict subset of the group scan. Scalar method paths under an array class prefix are unchanged (pre-existing). Full class-array cross-product support tracked in a follow-up.
- P2 named-array coverage: added value={...}/path={...} parity cases, a consumes/produces array false-positive case, and a dedicated empty-provider-set assertion.
- P3 stale comments: updated the routeCoverage comment in java.ts and the route-parse-skip test note; narrowed the parity test drift claim.
routeCoverage stays 'partial'.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(mcp): tolerate adapter-materialized line:0 in impact callgraph mode (#2279)
Some MCP client/agent adapters serialize an omitted optional numeric
field as `0` rather than dropping it, so callgraph `impact` calls arrive
carrying a spurious `line: 0`. `line` is a PDG-only statement anchor and
is meaningless on the callgraph path, so the backend rejected the call
("'line' is only supported with mode:'pdg'") and strict clients rejected
it client-side against the advertised `minimum: 1`.
Treat a literal `line: 0` as omitted in `_impactImpl` when mode !== 'pdg'
and let the normal symbol→symbol BFS run. The coercion is deliberately
narrow: only the literal 0, only on the callgraph path. A genuine
positive `line` on callgraph still errors (real mode mistake), negative/
fractional values still error, and pdg mode is untouched — `line: 0`
there is still rejected (there is no 1-based source line 0 to anchor on).
Regression tests pin the full matrix: callgraph + line:0 runs the BFS and
is byte-identical to omitting line; pdg + line:0 still errors; positive
line on callgraph still errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): log swallowed best-effort query degradations at warn, not error
`logQueryError` is the shared handler for query failures that every caller
catches and degrades past with a safe fallback (the operation still returns
a result). It logged all of them at `logger.error` (level 50) — the same
severity as fatal failures — so a gracefully-handled degradation raised a
false alarm and drowned genuine errors. This surfaced as an ERROR-level log
firing during a passing unit test that intentionally injects a slice-callees
query failure to verify the degrade path.
Make the severity match reality:
- benign missing optional table/label/column (a repo analyzed without
processes/communities, or a pre-v3 PDG index lacking the `calleeIds`
column — a query that fails on every pdg-downstream impact for such an
index) → debug, the normal-configuration case.
- any other swallowed failure → warn (handled degradation, still observable).
- error is reserved for failures that actually abort an operation, which
log directly rather than through this helper.
Also fix the sibling bm25/FTS fallback, which logged its swallowed
"FTS indexes may not exist" degradation at error while its own import-failure
fallback already used warn.
The slice-callees degradation test now captures the log and asserts it lands
at warn (40), not error (50), pinning the severity against regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): relax impact `line` schema minimum to 0 for adapter compatibility (#2279)
Strict MCP clients/agents validate against the advertised input schema and
reject a request before sending it. With `line` declaring `minimum: 1`, a
client that materializes the omitted optional `line` as `0` rejects a
perfectly valid callgraph impact call client-side — so the backend tolerance
added in the previous commit never gets a chance to run.
Lower the advertised `line.minimum` to 0 and document that 0 (or omission)
means "no statement anchor" while mode:'pdg' still requires a positive line.
The advertised schema is advisory (the backend self-validates and is the real
gate), so this cannot loosen any enforced contract — it only stops strict
clients from pre-rejecting `line: 0`. Negative lines are still rejected at the
client boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): apply autofix feedback
Code-review autofix pass on the #2279 branch:
- Replace a newly-introduced `mode as any` cast in the #2279 it.each with the
narrow `mode as 'callgraph' | undefined` (strict-typing-no-any).
- Add a degradation test for the new logQueryError benign-missing-table → debug
branch (asserts no warn/error record surfaces, i.e. it routed to debug).
- Pin the bm25/FTS error→warn severity change with a _captureLogger assertion
in the existing #1489 test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): make swallowed-failure callers surface degradation; narrow benign-error match (#2283)
Tri-review (#2283) found the `error → {debug|warn}` rework reduced telemetry
for `logQueryError` callers that do NOT degrade safely, while the docstring
over-claimed "every caller degrades to a safe fallback". Address the substance
rather than only the log level:
- rename apply-edit: track failed writes and return status:'partial' with
`failed_files` instead of reporting `status:'success'` when a write was
swallowed. A partial rename is no longer indistinguishable from a clean one.
- detect_changes: a swallowed symbol/process query failure now sets
`partial:true` (rendered by the existing eval-server partial path) so the
pre-commit safety gate can't return a false-clean `risk_level:'low'` no-op.
- isBenignMissingTableError: scope the `not (defined|found)` arm to a schema
object (table/label/rel/column/property), mirroring lbug-adapter's
isMissingColumnError. An unscoped "not found" matched operation failures like
`rg: not found` / `Symbol not found` and silently demoted them to debug.
- logQueryError docstring: state the contract honestly — level reflects
telemetry severity, and mutating/safety-critical callers MUST also surface a
result-level degradation signal; `warn` alone is not a substitute.
- pdg dispatch: pass the normalized `effectiveLine` (not raw params.line) so
the validation gate and engine share one source of truth (identity today).
Tests:
- _captureLogger(level?) lets tests capture below info; the benign-missing-table
test now asserts the record IS emitted at debug (20), not merely absent —
no longer a vacuous pass if the call were deleted.
- new: a non-schema "not found" failure logs at warn (regex-narrowing guard);
rename write-failure degrades to status:'partial'+failed_files; line:-1 on
the callgraph path still errors (line:0 coercion is narrow); typed the
it.each tuple to drop a `mode as` cast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(mcp): fix impact `line` description contradiction for whole-symbol pdg (#2283)
The new `line` schema description said "mode:'pdg' requires a positive line",
which contradicted the top-level impact description ("Without 'line', pdg
returns whole-symbol inter-procedural reach plus local whole-symbol PDG
diagnostics"). A pdg call without a line is a valid (degraded whole-symbol)
call, not an error — the old wording could push an agent to avoid valid no-line
pdg calls or synthesize line:0 (which then hard-errors).
Reword to: omit line for whole-symbol pdg; a positive line anchors a statement
slice; literal 0 is tolerated only as an omitted-line compatibility sentinel on
the callgraph path and is rejected for mode:'pdg'. Update the schema test to
pin the new, non-contradictory wording and assert "requires a positive line" is
gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group): resolve Go inline provider handlers via line containment (#2276)
Widen the Go HandleFunc + framework-route handler capture to match
func literals and emit name:null + call-site line for them, so an
inline handler resolves to its containing/closure symbol instead of
file-level. Named identifier handlers keep resolving by name.
* feat(group): resolve Laravel closure provider handlers via line containment (#2276)
Capture the Laravel route handler argument; a closure (anonymous
function or arrow fn) now emits name:null + the registration line so it
resolves to its containing symbol (service-provider boot, controller
method) by containment. Named-controller routes keep the 'route' label.
File-scope closures stay file-level (PHP closures not yet indexed).
* feat(group): wire call-site line on FastAPI provider emits (#2276)
Set line on the FastAPI @app/@router provider detections (already
name:null) so the source-scan fallback resolves the decorated handler
by line-span containment. Best-effort: FastAPI routes are graph-backed
and the function span starts at def, so this lands the single-decorator
case. Flask add_url_rule already carried line.
* feat(group): wire call-site line on Kotlin/Java Spring provider emits (#2276)
Add line to the Kotlin and Java Spring @*Mapping provider detections
for parity with the consumer emits and a future inline DSL. Inert for
current resolution: a named Spring controller method resolves by name
and never falls through to line-span containment.
* fix(review): apply autofix feedback
Pin two documented limitations with tests: a file-scope Laravel closure
and a multi-decorator FastAPI handler both degrade to file-level rather
than mis-attributing (#2276 ce-code-review autofix).
* test(group): lock named gin framework-route resolves by name not registrar (#2276)
Reviewer verified named Go handlers still resolve by name across the
widened queries; the HandleFunc path was already pinned, this adds the
framework-route (gin/echo) path with a DB + enclosing registrar whose
span covers the registration line, proving the emitted line never
diverts a named provider to its registrar via containment.
* test(group): end-to-end inline Go provider resolution against real LadybugDB (#2276)
Closes the validation gap that all prior coverage mocked CONTAINING_QUERY:
runs the real pipeline over a Go file with an inline http.HandleFunc
func-literal handler, persists into a real LadybugDB, and runs the
production HttpRouteExtractor against the real executor — proving the
emitted call-site line lands inside main()'s real 0-based span and yields
source_scan_resolved, not the file-level fallback.
* fix(test): use fs.mkdtemp to satisfy CodeQL insecure-temporary-file gate (#2276)
The new integration test created its temp base via a predictable
os.tmpdir()+name join, which CodeQL flags as js/insecure-temporary-file
(1 high). Switch to fs.mkdtemp for an atomic, randomly-named base dir.
* fix(group): anchor Go provider @handler to the trailing argument (#2276)
The widened framework-route and HandleFunc handler captures
(`[(identifier) (func_literal)] @handler`) were unanchored, so a variadic
middleware route `r.GET("/x", mw, func(){})` produced two provider
detections — one for the middleware identifier and one for the closure.
The contractId-only merge then kept the middleware detection and
mis-attributed the route to it (and the pre-existing `mw, namedHandler`
shape had the same defect), silently neutralizing the inline-handler
containment resolution from #2276.
Add a trailing tree-sitter anchor (`@handler .`) so the handler binds the
LAST argument of the call, leaving middleware args before it unconstrained.
Verified against tree-sitter-go: the multi-arg shapes now yield exactly one
detection (the real handler) while every 2-arg case is unchanged. Adds two
regression tests pinning that a middleware + inline closure resolves to its
containing function and a middleware + named handler resolves by name.
* test(group): cover FastAPI @router inline-handler containment (#2276)
The @router/APIRouter provider emit gained a call-site `line` in #2276 but
only the @app path was tested; the existing @router tests call
`extract(null, …)` so the resolver/containment path never ran for @router.
Add two tests mirroring the @app cases: a single-decorator @router handler
resolves to its function via source_scan_resolved (which fails if `line` is
dropped), and a multi-decorator one degrades to file-level.
* fix(group): treat synthetic 'route' label as anonymous in cross-trace (#2276)
After #2276 an unresolved file-scope Laravel closure emits name:null, so its
persisted symbolName falls back to 'handler' — which providerLabel already
anonymizes to '<contractId handler>'. But an unresolved named-controller
route still carries the synthetic 'route' placeholder, which the sentinel
did NOT cover, so group_trace/group_cross_impact rendered it as the literal
'route' while equivalent closures showed '<... handler>'.
'route' is only ever the synthetic Laravel placeholder (php.ts), never a
resolved handler name, so add it to the unresolved-generic sentinel set
alongside 'handler'/'fetch'. The resolved branch is untouched, so a real
symbol genuinely named 'route' still displays its name. Adds a cross-trace
test pinning the anonymized label.
* fix(group): gate Spring provider line on a present method name (#2276)
The Java/Kotlin Spring @*Mapping provider emits set `line` unconditionally
while the method name is typed string|null. The 'a named provider never
reaches containment' guarantee held only because the grammar always captures
a method name — the type did not enforce it. A (grammar-impossible) null name
would emit name:null + line and resolve by containment to the enclosing class
body instead of staying file-level.
Emit `line` only when the method name is truthy, so a nameless provider
degrades to file-level (the safe no-mis-attribution outcome). Behavior is
unchanged for every real Spring route (name is always present), but the
inertness is now enforced rather than incidental.
* feat(group): resolve cross-file named HTTP handlers via unique repo-wide lookup
U1 of #2275. When a provider's named handler is defined in a file other than its
route registration (e.g. router.get('/x', listUsers) with listUsers imported),
the registration file's symbols don't contain it, so resolution fell back to the
file-level boundary. Add a repo-wide name query (RESOLVE_BY_NAME_QUERY, the
label-union pattern from manifest-extractor) consulted only after the file-scoped
lookup misses, and honored ONLY when exactly one Function/Method/CodeElement
carries that name (zero/many → keep the file fallback, no wrong-symbol
attribution). Provider-only, cached by name. 4 unit tests; 743 group tests pass.
* test(bench): cross-file named handler scenario (end-to-end proof of #2275)
U2 of #2275. Adds a fifth bench scenario: a backend route whose handler
(listUsers) is imported from another file than its registration, with a frontend
consumer. Asserts the provider resolves to the handler via the repo-wide unique
name lookup (sym=listUsers, uid set) and that the cross-repo trace is symbol-
precise (no file-level fallback). verify.mjs now 12/12 on the real pipeline.
* fix(review): apply autofix feedback
ce-code-review (autofix) — no correctness/security findings; applied test-coverage
+ robustness fixes: repo-wide query throw -> empty (no exception); by-name lookup
cache fires once across same-named handlers; consumers never consult the repo-wide
lookup; same-file-wins now asserts the global path is bypassed; bench provider find
scoped by contractId; clarified the uniqueness-guard comment. 167 extractor tests.
* fix(group): tri-review fixes for cross-file handler resolution
Two-engine PR tri-review (Claude swarm+ce, Codex gpt-5.5 swarm+ce+adversarial)
on #2277. Correctness/security clean (injection refuted, bind-param). Fixes:
- Named-provider wrapper-attach (Codex swarm P1 + Claude ce-adversarial,
cross-engine): a named handler that fails both name lookups no longer falls
through to line-span containment, which attached the route to the enclosing
registrar (e.g. a setupRoutes() wrapper) instead of leaving it empty.
Containment now applies only to consumers and inline-arrow providers.
- CodeElement/ORM empty-file nodes (Claude ce-adversarial reproduced +
ce-maintainability): RESOLVE_BY_NAME_QUERY gains 'AND n.filePath <> ""' so a
handler name colliding with a synthetic ORM model node (orm.ts emits
filePath:'') neither resolves to an edge-less node nor inflates the uniqueness
count and masks the real handler; + a defensive empty-filePath guard in
resolveSymbolByNameUnique. Added LIMIT 2 (Codex swarm P3 + ce-maintainability)
to bound homonym materialization (count guard stays exact).
- Documented the aliased-import limitation (Codex adversarial): the route-site
identifier is the local alias, fix deferred to #2275 import narrowing.
- README expected verdict 9/9 -> 12/12 (Codex swarm+ce P3).
Tests: +3 (wrapper-no-attach, empty-filePath reject, empty-registration-file
resolves) covering the cross-engine gaps. 170 extractor / 748 group+integration
pass; bench 12/12 end-to-end.
* feat(group): import-pinned handler resolution (fixes deferred alias case)
Resolves the tri-review's deferred item: cross-file named handlers are now pinned
to their import's target module instead of resolved by name alone, so aliases and
names that collide with a local symbol resolve correctly.
- node.ts builds a local-binding -> {declared name, module} map from the file's
named imports; the express handler emits the DECLARED name + a handlerImport
{name, module} (HttpDetection gains the optional field).
- resolveDetectionSymbol gains an imported-handler rung: resolveImportedSymbol
pins to the import's target file via RESOLVE_IN_MODULE_QUERY
(n.name= AND filePath STARTS WITH the resolved module path), unique-match
only. An imported handler never uses file-scoped lookup (it is defined
elsewhere); on a module miss it falls back to a unique repo-wide name match on
the DECLARED name, then null. Relative imports only; bare/non-relative imports
keep the repo-wide fallback. Cached by (module-prefix, name).
- Closes the Codex-adversarial alias finding: import { listUsers as handleUsers }
+ an unrelated handleUsers no longer mis-resolves — the route resolves to the
imported listUsers in its module, and the alias is never looked up.
- Shared toResolvedSymbol helper (dedups the row->symbol + empty-filePath guard).
Tests: alias-resolves-to-declared-name + module-pin-resolves-ambiguous-name unit
tests; same-file-wins reworked to a genuinely LOCAL handler. Bench scenario 6
(aliased import with a decoy) proves it end-to-end. 172 extractor / 751
group+integration pass; bench 14/14.
* feat(group): import-pinned resolution for Python aliased handlers
Extends the JS/TS import-pinning to Python. The Python analog of express
router.get(path, handler) is Flask's imperative add_url_rule(view_func=...),
whose view is often an imported (aliased) symbol.
- New Flask add_url_rule provider pattern (path + view_func handler + methods;
default GET, methods=[...] honored). High Flask-specificity keeps false
positives low — unlike bare path()/Route(), which the plugin deliberately
leaves to graph Route nodes.
- buildPythonImportMap resolves 'from .mod import name as alias' (and plain
'from mod import name') to the declared name + raw module spec.
- resolveModuleBase generalized to two relative-import dialects: path-style
(JS './h/users') and dotted (Python '.handlers.users', '..pkg.users' — leading
dots are package levels). Bare/absolute imports keep the repo-wide fallback.
- Django stays graph-resolved (handlerSymbolId); FastAPI/Flask decorators stay
same-file (decorated function). This only adds the imperative imported-view
case Python lacked.
Tests: Flask aliased add_url_rule unit test (relative dotted module pinned, alias
never queried) + bench scenario 7 (end-to-end, 16/16). 173 extractor / 752
group+integration pass.
* fix(lang-kotlin): support `fun interface` extraction via tree-sitter-kotlin re-vendor
Vendored tree-sitter-kotlin@0.3.8 (fwcd) parsed `fun interface Foo` as an
ERROR node and dropped the declaration plus its abstract method, so functional
(SAM) interfaces were never extracted. The fix landed upstream in
fwcd/tree-sitter-kotlin#169 (closes#87), merged to main 2025-04-25, but is not
in any npm release (latest tag 0.3.8; main is the unreleased 0.4.0).
Re-vendor the grammar from the unreleased fwcd main commit c8ac3d26:
- refresh src/{parser.c,scanner.c,node-types.json,tree_sitter/*.h} and
bindings/node/index.js; bump the vendor version 0.3.8 -> 0.4.0; record the
pinned SHA + rationale in _vendoredBy and the vendor README.
- switch the prebuild workflow's kotlin registry kind 'npm' -> 'vendored' (the
fix is unreleased on npm, so prebuilds must build from the vendored C source,
like swift/dart/proto).
- add a hold to .github/vendored-grammars.json so the weekly auto-update
monitor does not strict-inequality-revert the pin to the broken npm 0.3.8
(isNewer compares 0.3.8 != 0.4.0).
- add 3 regression tests + a fixture asserting fun interfaces extract as
Interface nodes with their abstract methods, and that plain-interface
heritage still resolves.
Existing KOTLIN_QUERIES need no change: the new grammar models `fun interface`
as a class_declaration with an "interface" keyword child (plus an extra "fun"
modifier child), which the existing interface rule already matches. Full Kotlin
suite green against the new grammar (300 unit/cfg/resolver + 233 integration).
NOTE: prebuilds/ are intentionally not in this commit. The version bump
auto-triggers .github/workflows/build-tree-sitter-prebuilds.yml, which
regenerates all 6 platform binaries from the vendored source in a separate PR.
Until that lands, CI loads the committed 0.3.8 prebuild, so the new kotlin
tests are red and the grammar change is inert at runtime. Merge the prebuild PR
first or together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): count kotlin's vendored hold as a 0.25-readiness blocker
The kotlin `hold` added in the previous commit makes the tree-sitter
upgrade-readiness report count it as a blocker — the report treats every
held vendored grammar as frozen below a runtime upgrade (same as the
intentionally-pinned tree-sitter-cpp and the ABI-held tree-sitter-c),
"in-range ABI or not". So the report's blocker count goes 2 -> 3.
Update the hardcoded count in
test_issue_update_summary_regex_matches_current_report (and the
_render_report docstring) accordingly — exactly as that test instructs:
"if a grammar is added/removed or a pin/hold changes, update the expected
counts". kotlin's ABI (14) is in range; the hold is what flags it, with the
reason recorded in .github/vendored-grammars.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): refresh kotlin baselines for the grammar bump
Two committed baselines pinned the pre-bump kotlin state and broke when the
grammar was re-vendored (0.3.8 -> 0.4.0):
- cli-commands.test.ts pinned the vendored kotlin package version at 0.3.8 ->
update to 0.4.0.
- bench/scope-capture/baselines.json: the new kotlin-fun-interface fixture joins
the lang-resolution/kotlin-* corpus AND the new grammar parses `fun interface`
as a class_declaration (not an ERROR node), so the capture fingerprint drifts.
Rebaselined to the NEW grammar's fingerprint (verified by building the vendored
parser.c against tree-sitter@0.21.1 and running measure.mjs --check); scaling
~0.83 (linear).
Like the fun-interface integration tests, the scope-capture --check passes only
once the regenerated prebuilds land; until then CI loads the committed 0.3.8
binary, so it stays red.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(prebuilds): rebuild + commit grammar prebuilds into the PR on vendored-source change
build-tree-sitter-prebuilds.yml previously rebuilt a grammar's native prebuilds
only when its package.json VERSION bumped, and delivered them via a separate bot
PR. Now any change to the vendored grammar source re-cuts the prebuilds and they
ride into the same PR.
- Trigger on any build-affecting change under gitnexus/vendor/tree-sitter-*/**
(parser.c, grammar.js, binding.gyp, scanner, bindings), not just version bumps.
The prebuilds/ subtree is negated in the paths filter AND excluded from the
guard's source diff, so the bot's own prebuild commit can never retrigger the
workflow (no build -> commit -> build loop).
- The guard builds a grammar when its recorded version changed OR its vendored
source changed vs the PR base.
- Same-repo PRs get the rebuilt prebuilds committed straight onto their own head
branch (included in the SAME PR) via a non-force push that only adds a commit
on top of head. Manual dispatch still opens a fresh chore/ PR; fork PRs stay
artifacts-only (a bot cannot push into a fork branch).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(prebuilds): deliver rebuilt prebuilds to fork PRs via a trusted workflow_run stage
A fork PR's producer run has a read-only token and no secrets, so it can build and
validate the prebuilds but can't commit them. Add the safe two-stage handoff that
mirrors the pr-autofix producer/publish split.
- build-tree-sitter-prebuilds.yml (untrusted producer): on a fork PR, upload a
pr-meta artifact (schema, pr_number, head_sha, head_ref, head_repo, base_repo)
alongside the prebuild artifacts. Values flow through env + jq, never
interpolated into a shell.
- commit-fork-prebuilds.yml (trusted, workflow_run): downloads ONLY the artifacts
(never executes fork code — it checks out the pinned HEAD SHA solely to add
files), allowlist-validates every metadata field, cross-checks identity against
the workflow_run authority (head_sha / head_repo / pr_number, via
commits/{sha}/pulls for forks), then pushes the prebuilds onto the fork head
branch with --force-with-lease + http.extraheader auth. No PAT: this works when
the contributor left "Allow edits by maintainers" on; on push failure it posts a
sticky comment telling them to enable it or commit the downloaded artifacts.
zizmor: allowlist commit-fork-prebuilds.yml's workflow_run dangerous-trigger with
the documented mitigation, matching the existing ci-report / pr-autofix entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(vendor): rebuild tree-sitter-kotlin prebuilds for the re-vendored fun-interface grammar
The fun-interface re-vendor changed vendor/tree-sitter-kotlin source but left main's
old (0.3.8) prebuilds in place, so all 6 platform binaries were stale relative to the
new parser. Replace them with the freshly cross-built + ABI-validated binaries from
build-tree-sitter-prebuilds run 28010841458 — each .node was require()-loaded and
parsed a snippet on its target platform-arch before upload.
This is the manual equivalent of the commit-fork-prebuilds.yml delivery, which can't
run for this fork PR until it lands on main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lang-kotlin): read extension-function receiverType from the re-vendored grammar's `receiver` field
The fun-interface re-vendor changed the kotlin AST: an extension function's
receiver is now a `receiver_type` exposed via a named `receiver` field, where the
old grammar emitted a bare user_type before the name. extractReceiverType only
matched the old shape, so receiverType came back null
(method-extraction.test.ts > Kotlin MethodExtractor > extracts receiverType).
Prefer the `receiver` field (unwrapping it), and keep the old child-scan — now
also recognizing `receiver_type` — as a fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* 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.
* feat(routes): resolve + persist handler symbol on Route nodes (#2138 part 2, WIP)
Part 2 groundwork for #2138: give the graph-assisted HTTP provider path the
handler symbol directly, so it no longer re-parses source to recover the
handler name. (The remaining parse-skip in extract() + a call-count benchmark
land in a follow-up commit.)
- ExtractedDecoratorRoute gains `handlerName`; the Spring extractor captures
the decorated method's name (the method_declaration node is in hand).
- New `resolveRouteHandlerSymbols` (call-processor) resolves each route's
handler to a real symbol UID, keyed by normalized route URL — Laravel
framework routes (controller + method) and decorator routes (Spring/FastAPI)
both reduce to `(filePath, name) -> nodeId`. Threaded through the parse phase
onto `ParseOutput.routeHandlerSymbols`.
- routes phase stamps `Route.handlerSymbolId`; persisted end-to-end (schema +
Route CSV row + getCopyQuery COPY columns), mirroring Part 1's `method`.
- HttpRouteExtractor: `HANDLES_ROUTE_QUERY` returns `handlerSymbolId`;
`extractProvidersGraph` uses it as the authoritative symbol and SKIPS
`getDetections()` for resolved rows (CONTAINS is a cheap graph lookup for the
display name only — no tree-sitter parse). Fully backward compatible: an
unresolved/old-index route with no `handlerSymbolId` keeps the source-scan
fallback.
- Extracted `normalizeExtractedRoutePath` to `route-extractors/route-path.ts`
(shared by routes phase + resolver without an import cycle).
- SCHEMA_BUMP 6->7 (ParseWorkerResult gained `handlerName`); regenerated the
emit-persistence byte-identity baseline (route.csv header gained two columns).
- Tests: Spring pipeline asserts the Route node carries a handlerSymbolId
resolving to the handler method; extractor fast-path test proves the handler
resolves with zero source detections.
Refs #2138
* perf(group/http): skip source parse for graph-covered route files (#2138 Part 2)
Builds on the persisted Route.handlerSymbolId (U0–U3a). When a file's
HANDLES_ROUTE rows all resolve a handler symbol AND its language plugin
declares routeCoverage: 'complete' (Java/Python/PHP), the graph is
authoritative for that file's providers, so the source scan + tree-sitter
parse can be skipped — the scan would only re-discover routes the graph
already has. This is the measurable parse reduction #2167 could not show.
Consumer safety: routeCoverage: 'complete' asserts *provider* Route-node
completeness only. The scan() of those same languages also emits consumer
detections (RestTemplate/WebClient/OkHttp/Feign, Guzzle/Http::,
requests/httpx), and ingestion's FETCHES edges are JS/TS-only — so the
graph cannot back up server-side consumers. A provider-covered controller
that also calls out would otherwise lose its consumer contract. Guarded by
a cheap, parse-free text gate.
- types: HttpLanguagePlugin gains
- routeCoverage?: 'complete' | 'partial' (default 'partial')
- hasConsumerSignals?(content): false only when the raw source provably
has no outbound-HTTP call this plugin detects (conservative).
- java/python/php: mark routeCoverage 'complete' + implement
hasConsumerSignals with a token regex over their consumer idioms.
- http-route-extractor: run the graph provider pass first to build a
coveredFiles set; then keep a file covered only when
hasConsumerSignals(content) === false (read via readSafe, no parse).
scanFiles = files not covered → drives collectProjectDetections + both
source scans. Fail-open per file: any unresolved row, a 'partial'
language, a positive consumer signal, a missing hook, or an unreadable
file leaves the file in the scan set. The orchestrator names no
languages — token knowledge stays in the plugins.
Net: pure-provider controllers skip the parse (the win); controllers that
also call out are still parsed (no consumer loss); partial-coverage
languages and graph-less runs are unchanged.
- test: route-parse-skip integration test spies the real parseSourceSafe to
COUNT parses over a temp repo of Spring controllers with a mock DB —
baseline (every file parsed), fully-covered (0 parses), mixed (unresolved
file falls back, resolved stays skipped), and provider+consumer (a covered
controller that also calls restTemplate is parsed; its consumer contract
survives).
* fix(group/http): cover Spring HTTP Interface @*Exchange in Java consumer-signal gate
#2254 (merged) added Spring 6 HTTP Interface `@(Get|...)Exchange` /
`@HttpExchange` as a new Java *consumer* idiom. The #2138 parse-skip
consumer-safety gate must recognize it, or a provider-covered file carrying
an `@GetExchange` could be parse-skipped and lose that consumer contract.
Add `Exchange` to JAVA_HTTP_PLUGIN.hasConsumerSignals (conservative; also
matches `restTemplate.exchange(`).
* style(group/http): prettier formatting for #2138 Part 2 files
* style(ingestion): prettier formatting for call-processor.ts (#2138 Part 2)
* fix(group/http): P1 (Java over-claim) + P2 (handler mis-attribution) on top of #2268 (#2138 Part 2)
Re-applied on the maintainer's #2268 (expanded Java/Kotlin consumer
extraction) base.
P1 — `routeCoverage: 'complete'` over-claimed for Java: the graph provider
set is a strict subset of the group scan (array-form `@GetMapping({...})`,
interface-inherited routes, same-URL multi-verb have no graph Route node),
so parse-skip could drop those group-only providers.
- java/python → default 'partial' (always source-scanned). Java flips to
'complete' only once ingestion provider extraction matches the group scan
(a separate follow-up). Python was a no-op anyway (no handlerName resolved);
'complete' was a latent trap. PHP stays 'complete' (Laravel ingestion ⊇ the
group scan, the one language the skip engages for).
- python hasConsumerSignals widened to a true superset of scan() (uri=/url=
wrapper, aiohttp, urllib). Java's gate already covers #2268's consumer set
(same receivers; the @*Exchange token is present).
P2 — resolveRouteHandlerSymbols: reserve the URL slot on first encounter even
when unresolved (mirrors addRoute first-writer-wins, so a later same-URL route
can't stamp the node-winner's slot); refuse to guess on an ambiguous same-name
lookup (exactly one match → use it; zero/many → fail-open, never a wrong
handler). The cross-source case (filesystem route winning a URL a framework
route also normalizes to) is unchanged — the resolver never receives
filesystem routes — and stays fail-open.
Tests:
- route-parse-skip rewritten: the parse-skip win is proven on PHP (fully
covered → 0 parses; mixed fallback; consumer-covered file still parsed), plus
three Java P1 regression guards (array-form / interface-inherited / multi-verb)
asserting the group-only routes survive — verified they go red if Java is
flipped back to 'complete'.
- resolve-route-handler-symbols: direct unit tests (the fn had none) — unique
resolve, ambiguous/unknown fail-open, same-URL reservation, first-writer-wins.
- http-consumer-signals: each plugin's hasConsumerSignals is a superset of its
scan() consumer idioms; pure providers return false.
- route-handler-symbol-roundtrip: real-LadybugDB CSV→COPY→query for
Route.handlerSymbolId.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* feat(group): extract RestTemplate URI.create(...) static paths (Java)
Widen the RestTemplate query @path captures to (_) and resolve
URI.create("/x") arguments via a new extractStaticPathExpression
helper. Variable-bound paths stay unresolved (no consumer).
* feat(group): resolve RestTemplate UriComponentsBuilder chains (Java)
Add appendPath + recursive extractUriComponentsBuilderPath (fromPath/
fromUriString/fromHttpUrl seeds, path/pathSegment append, build/query
passthrough). Host-bearing seeds are normalized downstream. Non-literal
segments stay unresolved.
* feat(group): infer OkHttp request verb from builder chain (Java)
Walk up from the matched .url(...) call to the sibling verb helper
(.post()/.method("X")), defaulting to GET. Variable-bound verbs stay
GET. Re-document the Kotlin OkHttp GET-default pin as an accepted
Java/Kotlin asymmetry (Kotlin verb-walk is a tracked follow-up).
* feat(group): Java HttpClient HEAD, .method("X"), and default-GET
Add HEAD to the verb-helper regex and two dedicated pattern families:
.method("VERB", body) (covers PATCH) and a bare .build() defaulting to
GET. The three families terminate at distinct calls, so each chain
matches exactly one (no double-emit); variable-bound verbs stay unresolved.
* feat(group): extract fully-qualified Java route annotations
Widen JAVA_ROUTE_ANNOTATION_PATTERNS @ann to match scoped_identifier
(predicate-free node-type change), normalizing to the trailing segment
via simpleName in the scan loop. Snapshot-verified: only previously
unmatched FQN annotations gain routes; existing contracts unchanged.
Brings Java to FQN parity with the Kotlin plugin (closes#2254 limitation).
* refactor(group): reuse simpleName helper in hasAnnotation
Drop the inline split('.').pop() (which shadowed the new module-level
simpleName) and call the helper. Note appendPath's deliberate divergence
from the shared joinPath so it is not accidentally unified.
* docs(test): fix reversed Java/Kotlin OkHttp asymmetry comment
The comment stated .kt->POST / .java->GET; it is the opposite — Java
infers the verb (inferOkHttpMethod) so .java emits POST, while Kotlin
still defaults so .kt emits GET. Aligns the prose with the assertion
below it.
* docs(group): correct stale kotlin.ts OkHttp parity comment
The comment claimed Kotlin's GET-default 'mirrors java.ts:OK_HTTP_PATTERNS'
and is 'the same trade-off Java has accepted'. The Java plugin now infers
the verb (inferOkHttpMethod), so this is now a documented Java/Kotlin
asymmetry; the Kotlin verb-walk is the tracked follow-up.
* test(group): pin OkHttp default-GET branch on a distinct path
The bare `.build()` (no verb call) now uses /api/bare-build and is
asserted individually, so the default-GET branch of inferOkHttpMethod
is no longer masked by the explicit .get() case collapsing into the
same {param} slot.
* fix(group): skip OkHttp emission for variable-bound .method(verb)
inferOkHttpMethod now returns string|null: an explicit .method(verb, …)
with a non-literal verb returns null and the loop skips it, instead of
asserting a wrong GET contract. A bare .url().build() with no verb call
still defaults to GET (OkHttp's real default). Matches WebClient
long-form, which also skips variable-bound verbs.
* fix(group): strip query from UriComponentsBuilder seed literal
A query baked into the seed (fromUriString("/base?x=1")) was returned
verbatim, so a later .path("/sub") glued onto it (/base?x=1/sub) and
normalizeHttpPath truncated the tail at ? to /base. Strip ?query at the
seed so .path() appends to a clean base → /base/sub. Host prefixes are
preserved and stripped downstream by normalizeConsumerPath.
* fix(group): add recursion depth guard to extractUriComponentsBuilderPath
The recursive builder-chain walk was unbounded; a pathological or
machine-generated chain could overflow the stack. Cap recursion at
MAX_BUILDER_DEPTH (100) and return null past it — consistent with the
project's other AST-depth guards.
* docs(group): document accepted FQN simple-name collision trade-off
The route discriminator matches on the trailing annotation segment, so a
non-Spring annotation sharing a route name (@com.evil.GetMapping) is
treated as a route — the same trade-off hasAnnotation makes and the
intended Kotlin parity. Note why package-origin gating is deliberately
not added.
* refactor(group): extract static-path helpers to java-static-path.ts
Move the URI.create / UriComponentsBuilder resolution helpers
(methodInvocation*, firstLiteralArgument, appendPath, extractUri*,
extractStaticPathExpression) out of java.ts (back under ~1000 lines).
java.ts imports the four it consumes; inferOkHttpMethod stays. Pure
move, behavior-preserving — full group suite unchanged.
* fix(group): walk builder chain for Java HttpClient verb (#2268)
Replace the three rigid JAVA_HTTP_CLIENT_* pattern families with one
.uri()-anchored query plus inferHttpClientMethod, which walks up the
fluent chain for the verb (mirroring inferOkHttpMethod). The walk is
transparent to intervening .header()/.timeout()/.version() calls, so a
header/timeout hop before the terminal no longer silently drops the
consumer contract.
Relocate both verb-walks onto a shared inferBuilderVerb in
java-static-path.ts and de-export the now-internal methodInvocation*
primitives; java.ts drops 1015 -> 910 lines.
* fix(group): append UriComponentsBuilder .path() verbatim (#2268)
Spring's UriComponentsBuilder.path(p) appends p as-is without inserting
a slash (then collapses duplicate slashes), unlike .pathSegment() which
slash-joins. The resolver used the always-one-slash appendPath for both,
so fromPath("/api").path("users") resolved to /api/users instead of
Spring's /apiusers. Switch the .path() branch to verbatim append plus a
colon-aware duplicate-slash collapse (preserving a host seed's ://);
.pathSegment() keeps appendPath.
* fix(group): skip empty-string verb literal in builder verb-walk (#2268)
`.method("", body)` produced a malformed `http::::/path` consumer:
unquoteLiteral('""') returns "" (not null), so the `=== null` guard
let an empty method through. Treat a falsy literal verb as unresolvable
(return null from the shared inferBuilderVerb) and switch the OkHttp and
HttpClient emission guards to falsiness, so an empty verb skips like a
variable-bound one.
* test(group): harden Java HTTP consumer coverage (#2268)
Add coverage beyond the tri-review findings: a count guard on the
UriComponentsBuilder query-seed test (so a double-emit can't slip past
the two find assertions), an exchange()+UriComponentsBuilder end-to-end
case (the widened (_) @path exchange capture was only covered with
URI.create), and an HttpClient .method("REPORT") custom-verb
pass-through pin.
* docs(group): document pre-path builder rigidity + fix stale refs (#2268)
Document the OkHttp pre-.url() limitation (a builder call before .url()
is missed) at OK_HTTP_PATTERNS, cross-referencing the Java-HttpClient
pre-.uri() dual the verb-walk rewrite leaves in place — so neither
comment overclaims that the chain is walked before the path call. Update
the now-stale 'inferOkHttpMethod in java.ts' references in kotlin.ts and
the test to point at java-static-path.ts after the relocation.
* feat(group): match Java HTTP consumer chains with a pre-path builder call (#2268)
The OkHttp .url() and HttpClient .uri() queries required the path call to
sit directly on the construction, so a builder call BEFORE it —
new Request.Builder().addHeader(...).url(...) or
HttpRequest.newBuilder().version(v).uri(...) — silently dropped the
consumer contract. Match the path call on any receiver and re-impose the
framework anchor in JS (okHttpUrlRootsAtBuilder / httpClientUriRootsAtNewBuilder:
the chain must root at new Request.Builder() / HttpRequest.newBuilder()), so a
preceding call is captured while an unrelated .url()/.uri() is rejected. The
verb-walk now scans the whole chain, so a verb set before the path call also
resolves. Also extract the HttpRequest.newBuilder(URI.create(...)) constructor-arg
form (skipped when a later .uri() overrides it). Resolves the deferred
follow-ups from the round-2 tri-review.
* feat(group): Kotlin OkHttp verb-walk parity with Java (#2268)
The Kotlin OkHttp consumer always emitted GET while the Java side walks
the builder chain to recover the verb — a documented Java/Kotlin
asymmetry. Mirror the verb-walk into kotlin.ts, adapted to the
tree-sitter-kotlin call_expression/navigation_expression grammar: match
.url("literal") on any receiver, gate to chains rooting at
Request.Builder() (kotlinUrlRootsAtRequestBuilder), and scan the whole
chain for the verb (inferKotlinOkHttpMethod — last-wins, null-skip for a
variable/empty .method(verb), resolves a named-argument .method(method="X")).
This brings .kt to full parity with .java — verb inference, a builder
call before .url(), and verb-before-url — pinned by two new Java<->Kotlin
parity-harness rows. Flips the former GET-default asymmetry test.
* [+] Add django route discovery to create cross-link for multi-repo
* [+] Update ingestion
* [~] Fix bugs and abstraction violation
* feat(python-http): add keyword url= and variable propagation for consumer detection
- Add REQUESTS_KEYWORD_URL_PATTERNS for requests.get(url='...') keyword args
- Add WRAPPER_URI_PATTERNS for generic wrapper.fetch(uri='...') calls
- Add WRAPPER_URI_VAR_PATTERNS + buildLocalStringMap for uri=variable propagation
- Add LOCAL_STRING_ASSIGNMENTS to track uri='...' assignments
- Wire both direct-string and variable-propagation loops in scan()
- Add normalizeConsumerPath() helper
Note: Automatic cross-link detection remains limited for runtime-computed URLs
(URLs built via .format(), string concat, or module constants). Manual
manifest links needed for known cross-repo contracts.
* [+] add extract uri and url keywork pattern for request http
* feat(python-http): add variable propagation for uri=/url= consumer patterns
Re-add LOCAL_STRING_ASSIGNMENTS, WRAPPER_URI_VAR_PATTERNS,
buildLocalStringMap(), and normalizeConsumerPath() lost during
cherry-pick merge of upstream keyword-URL commit.
Together with the upstream WRAPPER_URI_PATTERNS and
REQUESTS_KEYWORD_URL_PATTERNS, we now detect:
- requests.get(url='literal') keyword args
- wrapper.fetch(uri='literal') keyword args
- wrapper.fetch(uri=variable) where variable was assigned a string literal
* fix(group): discover Django roots relative to manage.py dir + multi-project (#1836 R1)
A Django project not at the repo root (e.g. backend/manage.py) discovered
zero routes: the settings module path was resolved repo-root-relative only,
so backend/myproj/settings.py was never found and discovery returned null.
Resolve settings, star-imported base settings, ROOT_URLCONF, and the root
urls.py against the manage.py's own directory first, then the repo root
(resolvedSettingsPath is now project-dir-aware so relative imports anchor
correctly). Iterate every manage.py so a monorepo with several Django
projects yields each project's root — the provider hook becomes plural
(discoverRootRouteFiles → string[]) and the main-thread pass loops over all
roots (inner-scoped continues, parser hoisted once per language).
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(group): remove dead code in Django root discovery (#1836 R9)
- Collapse the identical if/else in extractStarImports to one push.
- Drop the unreachable baseModule.startsWith('.') branch (baseModule is
always a resolved slash-path or a bare absolute module — never dot-prefixed).
- Import DjangoFileReader from django.ts instead of re-declaring the type.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): walk Django includes once per prefix, not per file (#1836 R2)
The include() recursion guard was keyed on file path alone and shared across
the whole walk, so a urlconf included under two prefixes (a "diamond" — the
same app mounted at /v1/ and /v2/) emitted routes for only the first mount.
Key the guard on (resolvedFilePath, accumulatedPrefix) at all three sites
(function entry, path()-wrapped include, bare include) so a file reached
under two distinct prefixes is walked once per prefix while a genuine cycle
(same file + same prefix) still terminates — null/'' prefixes collapse to one
key so a no-prefix re-entry is treated as a cycle. MAX_INCLUDE_DEPTH remains
the backstop. Adds diamond + self-include-cycle tests.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): extract Django routes from non-list urlpatterns (#1836 R3)
findUrlpatternsLists only accepted a list-literal RHS, so common shapes
yielded zero routes: concatenation (urlpatterns = a + b), wrapper calls
(format_suffix_patterns([...]), i18n_patterns, staticfiles_urlpatterns), and
tuples.
Add collectUrlpatternContainers to descend binary_operator operands, known
wrapper-call list arguments, and tuples. Inherently-dynamic forms (DRF
router.urls, comprehensions, bare names) still yield nothing but now emit a
debug log so the silent-zero case is observable rather than mysterious.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(group): thread Django parser explicitly, drop module singleton (#1836 R4)
extractDjangoRoutes relied on a module-level _djangoParser set via
setDjangoParser before each call — hidden state that would break if a second
language ever used the include re-parse path, and an easy-to-forget contract.
Pass the tree-sitter parser as an explicit parameter of extractDjangoRoutes
(the extractRoutes provider hook already receives it) and delete the global
plus its setter. The Python provider wires it directly; tests pass the parser
in place of the removed setDjangoParser() call.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): isolate a throwing extractRoutes in the cross-file route pass (#1836 R5)
The main-thread cross-file route pass called provider.extractRoutes without a
guard, so a throw (e.g. a future grammar edge case in the include() walk) would
propagate out of the parse phase and abort the entire analyze — unlike the
worker, which isolates per-file failures.
Wrap the per-root extractRoutes call in try/catch that logs a warning and
continues to the next root. Export extractCrossFileRoutes and add a unit test
driving a stub provider whose extractRoutes throws, asserting the pass returns
[] and does not propagate.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(ingestion): bucket only route-capable languages in cross-file pass (#1836 R6)
extractCrossFileRoutes runs in the deferred band on every analyze (incl. warm
all-cache-hit runs). It now derives the set of languages whose provider exposes
the cross-file route hooks once, returns early if none do, and buckets only
those languages' paths — so a non-framework repo no longer pays to bucket the
languages it doesn't use here.
Route results are intentionally not persisted across runs, so a Django repo
still re-derives its routes each analyze; documented inline that cross-run
route caching is a deliberate follow-up rather than implemented here.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(group): prettier-format http-patterns/python.ts (#1836 R7)
The file was not formatted to the root .prettierrc (the consumer-path
normalizer used single-line try/catch and method chains), so the CI
quality/format check (`prettier --check .`) failed. Reflow only — no logic
change (`git diff -w` confines the change to normalizeConsumerPath's layout).
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): dedup Python URI detections by byte offset, not line arithmetic (#1836 R8)
The wrapper-URI dedup key was lineNum*1000+methodRow, which can collide for
distinct calls in files over 1000 lines (carry into the row term) and can
fail to dedup a genuine duplicate when a node straddles a line boundary.
Key on node byte offsets (`${pathNode.startIndex}:${methodNode.startIndex}`),
matching the sibling seenVarDetections dedup a few lines below.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): end-to-end Django cross-file route extraction (#1836 R10)
Adds an integration test that runs runPipelineFromRepo against a Django
fixture whose project lives under backend/, asserting the resulting Route
graph nodes (/health, /api/items, /api/items/<int:pk>). This exercises the
previously-untested main-thread orchestration glue (discovery → parse →
extractRoutes → allExtractedRoutes → Route nodes) and, because the project is
in a subdirectory, regresses the subdir-discovery fix (R1) — a repo-root-only
resolver would discover nothing and emit zero Route nodes.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): anchor Django include() resolution at the project root (#1836 review F1)
resolveIncludedFile tried the bare repo-root candidate (app/urls.py) before the
project-relative one, so in a monorepo with both a repo-root app/ and a
backend/ Django project that also has an app/, include('app.urls') from the
backend project resolved to the WRONG service's routes.
Probe up-tree from the root urls.py for the nearest manage.py (the Django
project root / sys.path entry) and try that-anchored candidate first. Absolute
module paths like `app.urls` now resolve to <projectRoot>/app/urls.py
unambiguously. When no manage.py is reachable (e.g. unit tests with a urls-only
reader) the prior strategy order is preserved. Adds a monorepo wrong-app test.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): drop bogus Django provider source-scan, use graph routes (#1836 review F2)
The DJANGO_PATH_PATTERNS / DJANGO_URL_PATTERNS source scan emitted an HTTP
provider contract for every path()/re_path()/url() string literal, without
checking it was inside urlpatterns, without skipping include() mount points,
and without composing the include() prefix across files. For
`path('api/', include('app.urls'))` + child `path('items/', view)` it emitted
providers for `/api` (a mount, not a route) and `/items` (un-prefixed) — which
survived the exact-contract-ID dedup alongside the correct graph route
`/api/items`, polluting cross-repo matching with false providers.
Remove the Django provider patterns and their scan blocks. Django provider
contracts come from the graph Route nodes, which the ingestion route extractor
builds with includes already composed (and now correctly, per the other fixes).
Python HTTP *consumer* patterns (requests/wrapper) are unaffected.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): match method-agnostic Django providers to any-method consumers (#1836 review F3)
Django function views are method-agnostic, so extractDjangoRoutes emits
httpMethod '*'. That '*' was dropped by normalizeRouteMethod and then defaulted
to GET by the contract extractor, while the matcher only expanded wildcard
*consumers* — so a `POST /api/items` consumer never matched the Django
provider that was silently narrowed to GET.
- routes.ts: preserve '*' as a method-agnostic marker on the Route node, so the
contract layer emits a wildcard provider (http::*::path) instead of GET.
- matching.ts: make findMatchingKeys symmetric — a specific-method consumer
now matches an exact-method provider OR a wildcard (http::*::) provider on the
same path, mirroring the existing wildcard-consumer expansion.
Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Dinh Huy <huynd86@fpt.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group): Kotlin Spring HTTP consumer extraction + provider parity with Java
Brings the Kotlin group/contract HTTP extractor up to parity with Java for
inter-service contract detection, and unifies the language-agnostic consumer
logic so it is not duplicated.
Consumers (new for Kotlin):
- @FeignClient interface @(Get|...)Mapping methods are emitted as OpenFeign
consumers (a remote call), not providers — previously mis-classified because
tree-sitter-kotlin models an interface as a class_declaration.
- Spring 6 HTTP Interface @(Get|...)Exchange (with optional class-level
@HttpExchange(url) prefix) — added for BOTH Java and Kotlin.
- Native OpenFeign @RequestLine, gated to interfaces (Feign proxies are
interfaces only), mirroring java.ts's findEnclosingInterface check.
Providers (Kotlin parity with java.ts scanSpringProject):
- A @(Get|...)Mapping on a non-Feign interface is a route *contract*, not a
served route; it is skipped in scan() so the implementing controller is the
sole provider (Java drops these implicitly via interface_declaration).
- scanProject inherits interface routes onto the implementing class, gated on
the class being a @RestController/@Controller (kotlinClassIsController handles
both the attached `modifiers` shape and the detached leading-arg-form
prefix_expression shape) so non-controller implementers don't emit phantom
providers.
Shared module:
- New spring-consumer-shared.ts holds the language-agnostic primitives
(REST_TEMPLATE_/WEB_CLIENT_/EXCHANGE verb maps, joinPath, parseRequestLine,
framework + confidence constants); java.ts and kotlin.ts both import it.
Array-of-paths (both languages):
- Route/Feign/Exchange annotation paths are `String[]`; a multi-element array
registers the route under EVERY element. The class/Feign/HttpExchange prefix
maps now accumulate all elements (were last-write-wins) and emission
cross-products prefixes × method paths, so `@RequestMapping(["/a","/b"])` +
`@GetMapping(["/x","/y"])` yields all four contract IDs. Array form is matched
via a predicate-free alternation over Kotlin `collection_literal` / Java
`element_value_array_initializer`.
Tests: comprehensive Java + Kotlin cases incl. consumer-vs-provider
classification, @*Exchange, @RequestLine (interface-only + plain-interface),
interface-based controller inheritance, non-controller negative case, detached
@RestController, single- and multi-element array paths (method-level and
class-prefix cross-product). 109 http-route + group tests pass; tsc/eslint/
prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): apply @RequestMapping prefix to Kotlin @RequestLine consumers (#2254 P2)
A @RequestLine method on an interface with a class-level @RequestMapping
prefix but no @FeignClient(path) dropped the prefix in Kotlin while Java
applied it (java.ts merges the fallback into feignPrefixByInterfaceId).
Mirror the feignPrefixByClassId ?? prefixByClassId ?? [''] chain already
used by the @GetMapping-in-Feign path. Adds Kotlin twins for the
@RequestMapping-prefix and @FeignClient(path)-wins cases.
* fix(group): accept named-arg Kotlin @RequestLine(value=...) (#2254 P2)
The positional pattern's '.' anchor only matched @RequestLine("VERB /x"),
silently dropping the named @RequestLine(value = "VERB /x") form that
java.ts accepts. Add a dedicated named pattern constrained to #eq? @key
"value" (Java parity: non-value keys stay dropped). Adds Kotlin twins
for the named-value and non-value-key cases.
* fix(group): resolve Kotlin FQN annotations/supertypes by trailing segment (#2254)
A fully-qualified @org…RestController / supertype : a.b.Api parses to a
user_type with one type_identifier per dotted segment; kotlinAnnotationName
and collectKotlinSupertypes took the FIRST ("org"/"a"), so FQN controllers
were not recognised and FQN supertypes never matched their interface. Take
the trailing segment. Adds FQN controller + FQN supertype inheritance twins.
* refactor(group): remove dead prefix_expression branch in kotlinClassIsController (#2254)
AST probe (bare and realistic package+constructor forms) confirms the
arg-form @RestController("bean") attaches under the class `modifiers` as an
annotation/constructor_invocation, caught by the modifiers loop — the
prefix_expression sibling branch was unreachable. Remove it and correct the
false grammar comments (source + the arg-form test). The existing arg-form
test stays green via the modifiers branch, confirming no behavior change.
* feat(group): support Kotlin arrayOf(...) annotation arrays (#2254 P3)
arrayOf("/a","/b") (the explicit String[] form) parses to a call_expression,
not a string_literal/collection_literal, so it was missed across all five
annotation-array families. Add dedicated arrayOf query patterns (positional +
named) per family via a shared arrayOfArg fragment — kept out of the existing
[(string_literal) (collection_literal …)] alternation to avoid the
tree-sitter 0.21.x predicate-bucket hazard. Verified one match per element
(multi-element accumulates) with buildPath/produces/empty anti-overreach.
* feat(group): detect WebClient long-form in Java for Kotlin parity (#2254 P3)
Java deliberately deferred webClient.method(HttpMethod.X).uri(...); the
Kotlin plugin proves a single structural query suffices (same field-access
shape as REST_TEMPLATE_EXCHANGE). Add WEB_CLIENT_LONG_FORM_PATTERNS + scan
loop so .java and .kt detect it identically. Move WEB_CLIENT_LONG_VERB_RE to
the shared module (single source for both). Flip the now-obsolete java :1741
negative test to positive (verbs + no-double-emit) and add a Java var-verb
anti-overreach twin.
* refactor(group): share pushPrefix between java.ts and kotlin.ts (#2254)
The de-duping prefix accumulator was duplicated as kotlin.ts pushKotlinPrefix
and a java.ts closure. Hoist a single export pushPrefix into
spring-consumer-shared.ts; both plugins import it. No behavior change.
* test(group): add Kotlin interface-inheritance boundary twins (#2254)
Twins for the Java inheritance-boundary cases that had no Kotlin counterpart:
shared-leading-segment combine, prefix-less method overlap, ambiguous
duplicate-interface-name suppression, plus a positive multi-interface
implementer. These pin Kotlin's scanProject behavior before U8 extracts the
shared inheritance algorithm.
* refactor(group): share the Spring interface-inheritance scanProject algorithm (#2254)
scanKotlinProject and scanSpringProject were ~80-line near-duplicates over
structurally identical type records. Extract scanSpringInheritanceProject +
SharedSpringType into spring-consumer-shared.ts; collapse KotlinTypeInfo and
SpringTypeInfo into the shared type; both plugins' scanProject become thin
collect-and-delegate wrappers. The ownerPrefix-carrying intermediate is owned
by the shared function. Behavior-preserving — Java and Kotlin inheritance
suites (incl. the new Kotlin boundary twins) byte-identical; tsc clean.
* test(group): close Kotlin↔Java consumer test-parity gaps + assert confidence (#2254)
Add Kotlin twins for Java-tested consumer scenarios with no Kotlin coverage:
@RequestLine query-strip, mixed @RequestLine+@GetMapping, malformed-value
rejection, and @FeignClient(path)-wins-when-@RequestMapping-first. Add the
Java dual-role twin (interface as consumer + implementing controller as
provider). Add two-sided provider confidence (0.8) assertions on the
canonical Java and Kotlin interface-inheritance tests.
* docs(group): document Java FQN route-annotation limitation + pin it (#2254)
Per KTD6, the Java FQN route-annotation gap is documentation-first: the gap is
route-string-only (FQN controllers are already recognised via hasAnnotation)
and FQN-written annotations are vanishingly rare. Document the asymmetry with
Kotlin in JAVA_ROUTE_ANNOTATION_PATTERNS and pin current behavior with an
anti-overreach test. The scoped_identifier query change is deferred to avoid
re-keying existing contracts via the predicate-bucket hazard.
* test(group): add Java↔Kotlin contract set-equality parity harness (#2254)
Independent per-side twins can both pass while the emitted contract SETS
differ. Add a table-driven harness over the parity-critical families
(@RequestLine prefix-fallback, named @RequestLine, @FeignClient(path)+@GetMapping,
@HttpExchange+@GetExchange, WebClient long-form, interface inheritance) that
runs matched .java/.kt fixtures through both plugins and asserts the full
projected contract set (role+contractId+framework+confidence) is equal across
languages AND equal to the expected set — the durable guard for the
byte-identical goal. Gated on kotlinConsumerAvailable.
* style(group): apply prettier formatting to #2254 changes
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(lbug): serialize singleton connection to stop --pdg analyze double-free
LadybugDB is single-writer and its Connection is NOT safe for concurrent
query execution. The WAL-checkpoint driver (5s setInterval) issued
`conn.query('CHECKPOINT')` on the same module-singleton `conn` the analyze
pipeline used for COPY. With --pdg the extra BasicBlock / REACHING_DEF / CDG /
POST_DOMINATE / TAINTED / CALL_SUMMARY / TAINT_PATH table COPYs outlast the
5s tick, so a checkpoint executed concurrently with an in-flight COPY on one
connection -> two libuv workers mutate shared native state -> heap corruption
("double free or corruption (out)" / SIGABRT, detected at the final
"Saving metadata..." free).
Fix: add conn-lock.ts (`withConnLock`, a promise-chain mutex) and run every
singleton-`conn` helper's full query + result-drain inside it: queryAndDrain
(when targetConn === conn), executePrepared, executeWithReusedStatement,
flushWAL, tryFlushWAL, getLbugStats, deleteAllInterprocTaintPaths,
deleteAllCallSummaries. Add an `if (inflight) return` reentrancy guard to the
driver tick so overdue ticks don't stack checkpoints. streamQuery is
intentionally NOT wrapped (read path, re-entrant per-row callback).
Reproduced the crash with concurrent queries on one raw Connection (serial =
stable); verified the fix drives the same overlap through the locked adapter
without crashing.
Tests: conn-lock serialization (no overlap / FIFO / throw-releases) and
driver reentrancy guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): lock deleteAllCommunitiesAndProcesses against the WAL driver (#2264)
The count + DETACH DELETE ran raw conn.query on the singleton connection during
incremental --pdg writeback while the WAL-checkpoint driver was live — the same
concurrent CHECKPOINT-vs-write double-free this branch fixes elsewhere. Wrap the
body in withConnLock, mirroring the already-wrapped deleteAllInterprocTaintPaths.
Adds test/integration/lbug-conn-serialization.test.ts (call-through withConnLock
spy) asserting the helper now acquires the lock, wired into the lbug-db vitest
project (and excluded from the default project so it doesn't run twice).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): lock queryImporters against the WAL driver (#2264)
queryImporters issued a raw conn.query on the singleton connection inside the
importer-BFS loop of incremental --pdg writeback, while the WAL-checkpoint driver
could fire a concurrent CHECKPOINT — the same double-free class. Wrap the read
(query + getAll + drain) in withConnLock.
Extends lbug-conn-serialization.test.ts with a routing assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): lock deleteNodesForFile count query on the singleton path (#2264)
The per-table count read used a raw targetConn.query while the sibling DETACH
DELETE already routed through the locked queryAndDrain — an asymmetry that left
the count racing the WAL-checkpoint driver during incremental --pdg writeback.
Gate the count through withConnLock when targetConn === conn (the singleton),
matching queryAndDrain; per-query/temp connections stay lock-free.
Test asserts the count loop takes the lock once per filePath-bearing node table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): drain DELETE results in the deleteAll* helpers (#2264)
deleteAllInterprocTaintPaths, deleteAllCallSummaries, and
deleteAllCommunitiesAndProcesses awaited conn.query(...DELETE...) but dropped the
returned QueryResult (only the count result was closed), leaking a native result
handle and violating the helpers' own "query + drain inside the lock" contract.
Close each delete result via closeQueryResults, matching the count handling.
Adds a seeded drain test (closeQueryResults fires for the DELETE result).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): make the conn-lock non-reentrancy invariant enforced, not just documented (#2264)
A future withConnLock-wrapped helper calling another wrapped helper would await
its own holder's tail and hang silently. Add an AsyncLocalStorage-based re-entry
guard: withConnLock throws a clear error when invoked from within a holding fn's
async context. A boolean flag can't do this — a legitimately-queued top-level
caller also runs while the lock is held; only AsyncLocalStorage distinguishes a
true nested call from normal contention.
Tests: re-entry throws (not deadlocks); sequential and concurrent top-level calls
do NOT false-fire; the lock releases after a re-entry throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* refactor(lbug): rename __resetConnLockForTests to _resetConnLockForTests (#2264)
Match the repo's single-underscore test-seam convention (_initLockPathForTest).
Pure rename of the @internal export and its sole importer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(lbug): fix stale CHECKPOINT guard regex after the c.query refactor (#2264)
lbug-checkpoint.test.ts asserted exactly two CHECKPOINT sites by grepping the
literal `conn.query('CHECKPOINT')`. The connection-serialization refactor changed
flushWAL/tryFlushWAL to capture `const c = conn` and call `c.query('CHECKPOINT')`
inside withConnLock, so the literal grep found 0 and the test failed (expected 2).
Make the regex receiver-agnostic (`.query('CHECKPOINT')`) — preserves the guard's
intent (exactly two authorized CHECKPOINT sites; a third is a regression) while
tolerating the captured-receiver form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): skip native close on CLI exit to dodge LadybugDB destructor double-free (#2264)
THE actual fix for the `analyze --pdg` crash. gdb shows the abort is a double-free
inside LadybugDB's own destructor during conn.close():
"double free or corruption (out)" -> abort
lbug::main::ClientContext::~ClientContext()
lbug::main::Connection::~Connection()
NodeConnection::Close(...) <- conn.close() from safeClose
It reproduces with the WAL driver OFF and with serial load, so it is NOT the
checkpoint/COPY concurrency the rest of this branch serialized — it's a native
LadybugDB engine bug (@ladybugdb/core 0.17.1, latest stable) triggered by the
larger --pdg write set, firing during teardown AFTER a fully-written, checkpointed
index.
Fix: closeLbug({ skipNativeClose }) CHECKPOINTs for durability (flushWAL) then
skips conn.close()/db.close(), leaving the handles referenced so no GC finalizer
re-runs the destructor. The CLI analyze command (success, error, and SIGINT paths
all process.exit) opts in via skipNativeCloseOnExit; long-lived callers (MCP
server, tests) keep the real close. Mirrors the pool adapter's fire-and-forget
native close and the ONNX native-cleanup philosophy.
Validated end-to-end: `analyze --pdg --force` now exits 0 with a 193,876-node
index; re-opening it (no --force) reads clean and reports up-to-date, proving the
CHECKPOINT-only persistence is durable without db.close().
Workaround for an upstream LadybugDB bug (ClientContext destructor double-free).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): keep conn.close()/db.close() literals out of the closeLbug comment (#2264 review P1-1)
The skipNativeClose comment in closeLbug contained the literal `conn.close()`/
`db.close()`, which the structural guard test (lbug-checkpoint.test.ts:52-53 —
"closeLbug must not inline conn.close()/db.close()") greps for and fails on.
Reword the comment to describe the native close without the literal tokens; the
code already delegates close exclusively to safeClose, so the guard's intent holds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): real close on the analyze error path to avoid a hang under skipNativeClose (#2264 review P1-2)
The CLI error handler soft-returns (process.exitCode = 1) instead of forcing
exit, relying on the released native handles to let Node terminate. The earlier
commit made runFullAnalysis's error-path closeLbug skip the native close, leaving
live LadybugDB handles that keep the event loop alive forever — a post-init
analyze failure would hang. Only the SUCCESS path (which guarantees a following
process.exit) skips the native close; the error path now always closes for real.
A late-error close could still abort in the destructor, but that terminates the
process — it does not hang.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): skip native close in the analyze worker to avoid the LadybugDB destructor crash (#2264 review P2-3)
The forked server analyze worker runs runFullAnalysis then force-exits
(process.exit(0)). With a real native close inside runFullAnalysis, the LadybugDB
ClientContext destructor can double-free after --pdg writes and abort the worker
BEFORE it sends 'complete', failing the parent's analyze. Pass
skipNativeCloseOnExit: true so the worker checkpoints for durability and lets its
process.exit reclaim the handles — same about-to-exit contract as the CLI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(cli): force exit on a soft error-return when LadybugDB handles are open (#2264 review P1)
The full-analysis success path skip-closes LadybugDB (handles left open, reclaimed
by process.exit). If a post-finalize step (assertAnalysisFinalized) then throws,
the outer catch soft-returns (process.exitCode = 1) — and with native handles
open the event loop never drains, so the process HANGS instead of exiting 1.
Guard once at the analyzeCommand wrapper, after the try/finally: if isLbugReady()
(handles still open) the analyze actually ran and we must force the exit. The
success path never reaches here (analyzeCommandImpl process.exit(0)s itself);
early-validation errors and unit tests that mock runFullAnalysis never open the DB
(isLbugReady() false), so the soft return is preserved.
Adds analyze-finalize-failure-exits.test.ts (force-exits when handles open; does
NOT when they aren't). The analyze-*.test.ts that mock lbug-adapter now also mock
isLbugReady (vitest throws on accessing an undefined export of a mocked module).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): skip the native close on the analyze error path too (#2264 review P2)
A real conn.close() on the error path after large --pdg writes can itself hit the
LadybugDB ClientContext destructor double-free → SIGABRT, degrading an actionable
exit-1 error into a raw native abort. Switch the error-path close to
skipNativeClose (mirroring the success path). Safe now that the CLI catch
force-exits when isLbugReady() (the prior commit): handles left open are reclaimed
by that guaranteed process.exit, so the process terminates without the abort and
without hanging. flushWAL keeps the partial index durable.
Depends on the prior commit (CLI force-exit guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): run loadCachedEmbeddings reads under withConnLock (#2264 review P2)
loadCachedEmbeddings issued raw conn.query reads on the singleton connection
outside withConnLock — safe today only because it runs before the WAL-checkpoint
driver starts, an ordering invariant not enforced by code. Wrap the whole read in
withConnLock so a future reorder can't race a CHECKPOINT on the connection. Leaf
read; no nested wrapped helpers.
Adds a routing assertion to lbug-conn-serialization.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(lbug): de-brittle the close/CHECKPOINT structural guard (#2264 review P2)
lbug-checkpoint.test.ts grepped the adapter SOURCE (comments included) for
conn.close()/db.close()/.query('CHECKPOINT') literals, coupling a passing test to
comment wording — a prior commit had to reword a comment just to keep it green.
Strip comments from the read source before the structural assertions so they
reflect code only; the invariant (exactly two CHECKPOINT sites; close calls only
in safeClose) is preserved and no longer breaks on a comment edit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* refactor(lbug): rel COPY uses the captured writeConn, matching node COPY (#2264 review P3)
The relationship COPY passed the module-level `conn` to copyCsvWithRetry while the
node COPY uses the captured `writeConn`. Use `writeConn` for both — one captured
reference for the whole bulk load, removing the latent identity dependency. Same
object during analyze (`conn` is only reassigned at open/close under the session
lock), so the queryAndDrain `targetConn === conn` lock gate still engages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): make the analyze worker's IPC send() failure-safe (#2264 review P3)
The worker's send() used `process.send?.(msg)` — the `?.` guards an undefined
channel but not a throw from an already-closed one (ERR_IPC_CHANNEL_CLOSED). A
throw in the catch-branch send() would escape the message handler and skip the
scheduled `setTimeout(process.exit(0))`, stranding the worker (with skip-close
leaving native handles open, #2264). Wrap process.send in try/catch so the exit
always fires; a vanished child is a failure to the parent regardless.
Not unit-tested: send() is module-private and importing the worker registers
process signal handlers; the change is a defensive try/catch around one call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(cli): bound the SIGINT cleanup CHECKPOINT so Ctrl-C stays responsive (#2264 review P3)
The SIGINT handler calls closeLbug({skipNativeClose:true}), whose flushWAL
CHECKPOINT queues behind the connection lock held by an in-flight COPY — so a
single Ctrl-C during a long --pdg COPY appeared hung until the COPY released.
Race the cleanup against a 2s timeout before process.exit(130); the WAL replays
on the next analyze. The double-Ctrl-C escape hatch is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): report analyze-worker errors over IPC, never swallow (#2264 P3)
The worker's send() swallowed IPC failures (and a prior pass logged them to
stderr). Per review, all worker errors must be reported back to the parent over
the existing IPC channel (send({ type: 'error' })) and nothing silently dropped.
- send() no longer catches: a dead channel (ERR_IPC_CHANNEL_CLOSED) throws
instead of being swallowed.
- Every handler (uncaughtException, unhandledRejection, SIGTERM, the analysis
message handler) reports its error via send() in try and schedules process.exit
in finally, so a throw from send() can no longer skip the exit and wedge the
worker — the P3 'schedule the exit so it always fires' fix, without a swallow.
- SIGTERM cleanup failures are now reported to the parent instead of an empty
catch {}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(workers): report every caught parse-worker error over IPC (#2264)
The parse worker swallowed or only-locally-logged several caught errors, so they
never reached the pool: the per-language-group catch was an empty catch {} that
silently dropped the whole group on any throw (not just an unavailable grammar),
the per-file parse/query-execution catches only logger.warn'd (worker-thread
local), and the C++ template-constraint catch swallowed silently.
Route all work-path catches through a new reportWarning() helper that posts
{ type: 'warning', message } to the pool (which logs it on the main thread AND
resets the worker idle timer, so a worker grinding through failing files isn't
falsely idle-evicted), with a logger.warn fallback for the non-worker path. The
existing inline warning sites (query-compilation, the extractParsedFile callback,
CFG build) are migrated to the same helper.
The 4 optional-grammar module-load guards (Swift/Dart/Kotlin/C) stay silent: they
run before the 'ready' handshake and their absence is already surfaced via
result.skippedLanguages + the isLanguageAvailable gate. Fatal/group-aborting
errors continue to flow through the message handler's { type: 'error', errorStack }.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(cli): harden finalize-failure test against forked-worker death (#2264)
analyzeCommand calls installFatalHandlers(), which registers global
unhandledRejection/uncaughtException handlers that call the REAL process.exit(1).
Across this file's vi.resetModules() reimports they accumulate on `process`, and
under CI timing a stray async rejection fired one while no process.exit spy was
active — killing the forked vitest worker ("Worker exited unexpectedly"), which
only surfaced once the full test lanes finished (they were pending at review time).
Keep process.exit spied for the whole file (beforeAll/afterAll) so a fatal handler
can never really exit mid-run, strip the handlers installFatalHandlers added in
afterAll (preserving vitest's own, snapshotted up front) before restoring the real
process.exit, and reset process.exitCode so the worker exits clean. Passes in
isolation and grouped; behavior under test is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(analyze): don't take the up-to-date fast path for an unregistered repo (#2264)
A prior 'analyze --name X' that hit a registry name collision writes meta.json
(meta-save runs before registerRepo) but fails before registering — leaving the
index up-to-date but UNREGISTERED. A later 'analyze --name X --allow-duplicate-name'
then matched the up-to-date gate and early-returned WITHOUT registering, so the
repo stayed invisible to list_repos/MCP and the CLI's assertAnalysisFinalized
rejected it. --allow-duplicate-name could never heal it.
This was latent on main, masked by the very close-hang this PR fixes: the lingering
process pushed the cli-e2e #829 step-3 analyze past its 60s spawn timeout
(status===null → the test's vacuous early-return). With the hang gone the analyze
exits promptly, exit 1 surfaces, and the bug becomes deterministic on all platforms.
Fix: the up-to-date fast path now short-circuits only when the repo is actually
registered (new isRepoRegistered helper, sharing assertAnalysisFinalized's exact
canonical/case-folded membership check). An indexed-but-unregistered repo falls
through to the pipeline, which registers it honoring allowDuplicateName. Already
registered repos keep the fast path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* chore: trigger CI re-run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(analyze): gate up-to-date self-heal on --allow-duplicate-name (#2264)
The prior commit healed every up-to-date-but-unregistered repo by falling through
to register it — which broke the #1169 guard: a plain `analyze` of an up-to-date
repo whose registry entry is missing MUST fail loudly ("Analysis did not finalize")
rather than silently register a possibly half-finalized index.
Distinguish the two causes of "unregistered":
- collision-rejected + user re-runs with --allow-duplicate-name → explicit intent
to register, so fall through to the pipeline and register it (#829).
- plain analyze, registry missing/wiped → keep the #1169 fail-loud behavior.
So self-heal is gated on options.allowDuplicateName; isRepoRegistered is only read
on that opt-in branch, so the common fast path keeps its single-stat cost. Both
cli-e2e guards (#1169 fail-loud, #829 heal) now pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): skip the native close on analyze-worker SIGTERM cancellation (#2264 P2)
cancelJob() / the 30-min timeout (analyze-job.ts) send SIGTERM to the forked
analyze worker, but its SIGTERM handler still did a full `await closeLbug()`
(native conn/db teardown) — even though normal completion now skips it via
skipNativeCloseOnExit. A cancelled or timed-out --pdg server analyze could
therefore still hit the LadybugDB ClientContext destructor double-free, or block
behind the in-flight COPY's connection lock before exiting.
Mirror the CLI SIGINT path: a best-effort CHECKPOINT with
closeLbug({ skipNativeClose: true }) bounded by a 2s Promise.race timeout, then
process.exit(0) (which reclaims the handles). A CHECKPOINT failure is reported to
the parent over IPC rather than swallowed; the exit is in .finally so it always
fires.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(cli): import analyze once in the finalize-failure test (#2264 CI)
The test failed deterministically only on the ubuntu coverage lane (2/2 runs)
while passing locally and in isolation, incl. with --coverage. Cause: the
vi.resetModules() + per-test `await import('analyze.js')` re-instrumented the
ENTIRE analyze module graph on every test; under --coverage on the
memory-constrained CI runner that OOM/crashed the forked worker ("Worker exited
unexpectedly" → the assertion never ran).
Import analyzeCommand ONCE and drive the mocks per-test via mockReturnValue
(resetModules wasn't needed — the hoisted mocks are controllable per-test). Keeps
the whole-file process.exit spy + afterAll fatal-handler strip from the prior pass.
Behavior under test is unchanged; passes in isolation and with --coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(cli): pre-set NODE_OPTIONS heap cap so ensureHeap can't re-exec (#2264 CI)
Root cause of the ubuntu-coverage-only failure (3/3 CI runs, passing locally):
analyzeCommand calls ensureHeap() (analyze.ts:715), which RE-EXECS the process —
spawning `node <heap-flags> <argv>` with vitest's argv — unless NODE_OPTIONS
already carries --max-old-space-size (analyze.ts:498). That re-exec killed the
forked vitest worker ("Worker exited unexpectedly" → the assertion never ran).
It only reproduced on the memory-constrained CI runner because locally a high V8
heap-size-limit also short-circuits ensureHeap (analyze.ts:501).
Reproduced locally with NODE_OPTIONS="--no-warnings" (no heap cap) → same failure;
fixed by pre-setting --max-old-space-size in beforeAll (restored in afterAll), the
same workaround cli-e2e uses. Verified: passes under the repro condition, normally,
and with --coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): worker asserts finalization before reporting complete (#2264 P2)
The forked analyze worker reported {type:'complete'} straight after runFullAnalysis,
so a server/web analyze of a half-finalized repo (meta.json written but the global
registry entry missing — a prior collision-aborted run, or a wiped registry) was
reported successful while the repo stayed unregistered/invisible to list_repos. The
CLI already guards this with assertAnalysisFinalized; the worker did not.
Extract the run -> finalize -> report contract into a side-effect-free
analyze-worker-core seam (the entry module's top-level process.on handlers make it
untestable directly) and call assertAnalysisFinalized before sending complete — a
failure is reported as {type:'error'} instead of a false success. The seam is
dependency-injected and unit-tested with fakes; the entry module wires the real deps
and keeps owning process.exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): coordinate worker SIGTERM cancellation with completion (#2264 P3)
The worker SIGTERM handler unconditionally sent {type:'error','Analysis cancelled'}
and didn't coordinate with the message handler that sends complete, so a cancel near
the finish line could report a cancelled job complete, or a late SIGTERM could flip
an already-complete job to failed.
Add a single terminal-outcome claim (createTerminalClaim) shared by the message
handler and the SIGTERM handler: whoever claims it first reports its terminal
message; the other skips its terminal send. Single-threaded JS makes the
check-and-set atomic. The cleanup + process.exit still run regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(server): make a job's terminal outcome immutable on the parent side (#2264 P3)
Defense-in-depth complement to the worker terminal-claim: the launcher's message
handler and the job manager's updateJob both lacked a terminal-state guard, so a
late worker IPC message (a SIGTERM-driven 'error' after 'complete', or vice versa)
could re-release the repo lock and flip the reported status. (Touches parent-side
files outside the original PR diff — deliberate, clearly-scoped.)
- analyze-job.ts updateJob: drop any update once the job is already terminal (the
transition INTO terminal still applies, since status isn't terminal yet then).
- analyze-launch.ts message handler: return early when the job is already terminal,
mirroring its sibling exit handler.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(lbug): assert deleteAllInterprocTaintPaths + deleteAllCallSummaries route through withConnLock (#2264)
The lock-routing suite covered 4 singleton-conn helpers but not these two
withConnLock-wrapped delete helpers (lbug-adapter.ts), which also run during the
incremental --pdg writeback window — so a revert of either wrapper would have gone
uncaught. Add the two routing assertions to complete the coverage the file's header
claims (every singleton-conn helper reachable during the WAL-driver window).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(lbug): assert temp-conn deleteNodesForFile skips withConnLock (negative gate, #2264)
The positive case (singleton deleteNodesForFile locks each per-table count) was
covered, but not the negative branch of the targetConn === conn gate: a per-file/temp
connection (dbPath provided) must NOT take the singleton lock, or temp-conn callers
would needlessly contend with it. Add the negative-gate assertion so a regression
that unconditionally locks is caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* test(cli): assert the force-exit forwards process.exitCode, not a hardcoded 1 (#2264)
The existing cases asserted process.exit(1), but since the error catch always sets
exitCode=1 they couldn't distinguish forwarding (process.exit(process.exitCode ?? 1))
from a hardcoded 1. Add a case on the alreadyUpToDate path — which returns without
setting exitCode or calling process.exit — with a pre-set exitCode=2 and isLbugReady
forced true, asserting the wrapper force-exits with 2. Proves the exitCode-forwarding
branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* refactor(lbug): replace skipNativeClose flag with a dedicated closeLbugBeforeExit() (#2264)
The "skip the native close only when a process.exit is guaranteed to follow"
invariant was enforced by convention across ~4 call sites via a boolean option on
closeLbug — the exact foot-gun the review flagged. Encode the contract in the name
instead:
- New closeLbugBeforeExit() (CHECKPOINT via flushWAL, then return without the native
close); closeLbug() drops the option and is the plain real-close again.
- run-analyze success + error paths: options.skipNativeCloseOnExit ?
closeLbugBeforeExit() : closeLbug(). CLI SIGINT + worker SIGTERM call
closeLbugBeforeExit() directly. skipNativeCloseOnExit stays on AnalyzeOptions as the
caller's "I will exit" signal.
- lbug-checkpoint.test: assert closeLbugBeforeExit exists + has no native close, and
match `closeLbug =` precisely so it doesn't prefix-match the new function.
- Retarget the conn-serialization integration case to closeLbugBeforeExit(); add the
new export to the 12 analyze-*.test.ts lbug-adapter mocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* refactor(lbug): extract isSharedSingletonConn predicate for the lock gate (#2264)
The targetConn === conn object-identity gate (decides whether an op takes
withConnLock) was duplicated inline in queryAndDrain and deleteNodesForFile with
its own explanatory comments. Extract a single isSharedSingletonConn(c) predicate
with the rationale in one place; both sites route through it. Behavior unchanged —
covered by the lock-routing tests' positive (singleton locks) and negative
(temp-conn skips) cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* refactor(lbug): share the bounded checkpoint-then-exit cleanup (SIGINT/SIGTERM) (#2264)
The CLI SIGINT handler (analyze.ts) and the worker SIGTERM handler (analyze-worker.ts)
had near-identical Promise.race([closeLbugBeforeExit, timeout]).finally(exit) blocks
with separately-hardcoded 2s timeouts. Extract boundedCheckpointBeforeExit into a
shared shutdown-helpers module — parameterized by exit code, an optional flush-error
reporter (worker reports over IPC), and an optional beforeExit hook (CLI flushes the
logger). checkpoint + exit are injectable test seams, so it's unit-tested without the
real LadybugDB close or process.exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* refactor(storage): extract registryPathEquals for the registry case-fold compare (#2264)
The Windows case-insensitive / POSIX case-sensitive registry-path comparison was
duplicated across 6 sites (registerRepo dedup, the fresh-merge findIndex,
removeRepo/removeBranchIndex local 'matches' helpers, isRepoRegistered, and the
path-match lookup). Extract a single registryPathEquals(a, b) predicate so every
registry lookup/dedup/finalize check answers identically; route all 6 through it.
No behavior change — repo-manager + finalize-invariant suites pass (incl. the
Windows case-fold case).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* feat(lbug): runtime-guard streamQuery against the WAL-checkpoint driver (#2264)
streamQuery is deliberately not wrapped in withConnLock (its per-row callback can
re-enter the adapter), so its unlocked per-row reads could race a CHECKPOINT on the
shared connection — the corruption window the lock serializes everything else
against. That invariant was comment-only, safe today only because the serve/read
path forks analyze workers. Make it enforced:
- lbug-adapter: a walDriverActive flag + markWalDriverActive(bool); streamQuery
throws an actionable error when the driver is active.
- wal-checkpoint-driver: arm the flag on start, disarm in stop() AFTER the in-flight
CHECKPOINT drains (clearing earlier would briefly allow a race).
A future in-process analyze overlapping a stream now fails loud instead of
corrupting native state. (reentrancy test's lbug-adapter mock gains the new export.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* docs(lbug): explain why closeLbugBeforeExit skips finalizeLbugSidecarsAfterClose (#2264)
Document the deliberate trade-off: the skip-close path intentionally does NOT run
the sidecar-finalize step that safeClose runs after a real close. It's designed for
released WAL handles; running it with the connection still open risks a Windows
file-lock on the in-use WAL. The CHECKPOINT already made the index durable and the
next run's preflightLbugSidecars reconciles residual WAL — the deferral is the
accepted cost of skipping the native close to dodge the destructor double-free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
* fix(lbug): move WAL-driver-active flag to its own module (fix mock ripple, #2264)
The streamQuery guard (4449bb4a) put markWalDriverActive in lbug-adapter, and the
wal-checkpoint-driver imported it from there. That broke every test mocking
lbug-adapter while loading the real driver — CI's ubuntu lane caught
run-analyze-fts-repair.test.ts ('No markWalDriverActive export on the mock').
Move the one-bit shared flag to a dedicated wal-driver-state module: the driver
toggles markWalDriverActive there, streamQuery reads isWalDriverActive there, and
lbug-adapter no longer carries it — so mocking lbug-adapter no longer has to stub
the toggle. run-analyze-fts-repair now passes untouched; the reentrancy test's mock
addition is reverted (no longer needed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump gitnexus, plugin.json, and marketplace.json to 1.6.8 and add the
CHANGELOG section. Headline: opt-in PDG-backed impact analysis plus the
full PDG/taint substrate (CFG → reaching-defs → intra/inter-procedural
taint → control dependence) across the language matrix, multi-branch
indexing, private GitHub PAT + Azure DevOps support, and MCP trace/HTTP.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: fail tree-sitter summary parse drift
* docs(ci): cross-reference readiness regexes to their test mirror
The two report.match() literals in the upsert-issue github-script step are
duplicated as _ISSUE_READY_RE / _ISSUE_BLOCKER_RE in
test_check_tree_sitter_upgrade_readiness.py, and only the Python copy is
asserted against the rendered report. Since a stale regex now throws via
requireMatch (instead of the old silent '?' fallback), add a reciprocal
keep-in-sync note at the workflow site so a future prose edit can't desync
the JS literal from the asserted mirror undetected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): route read-phase network failures to fetch_failed, not a crash
npm_view_json and fetch_text caught only (URLError, HTTPError[, JSONDecodeError]).
urllib wraps connect-phase OSErrors into URLError, but a failure during
resp.read() AFTER urlopen returns (ConnectionResetError, ssl.SSLError,
socket.timeout, http.client.IncompleteRead) is not a URLError subclass — it
escaped the helper, crashed main(), and left stdout empty. main() is unguarded
(the only top-level except wraps just stdout.reconfigure), and the report print
is its last statement, so an empty report then makes the workflow's requireMatch
throw on a non-drift scheduled run.
Broaden both except tuples with OSError + http.client.IncompleteRead so a
transient mid-body network blip yields None, routing the grammar to the existing
fetch_failed blocker bucket (a complete report) — preserving the fail-loud intent
for real drift while removing the crash-to-empty-stdout path. JSONDecodeError
stays explicit (it is a ValueError, not an OSError). Adds read-phase regression
tests that fail on the old narrow tuple.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): document the regex-contract assertion counts
test_issue_update_summary_regex_matches_current_report asserts hardcoded
capture groups ("9","10") and "2" with no explanation. Document the
derivation from _render_report()'s mock corpus — 9 of 10 npm grammars Ready
(tree-sitter-cpp is the intentional pin), 2 blockers (pinned tree-sitter-cpp +
held vendored tree-sitter-c) — so a future grammar or pin change is an obvious
two-step update (mock + counts) rather than a mystery failure. Assertions
unchanged; comment only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: name _Resp method receivers 'self' to clear py/not-named-self
The _Resp stub inside _patch_urlopen named its method receivers
`self_inner`, which CodeQL flags as py/not-named-self (PEP 8) — three
alerts on this PR's merge ref (lines 230/233/236). _patch_urlopen is a
@staticmethod, so there is no outer `self` to collide with; rename the
receivers to the conventional `self`. Pure rename, no behavior change.
All 25 tests in test_check_tree_sitter_upgrade_readiness still pass.
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>
* fix(impact-pdg): run mutation oracle's analyze child from built dist, not tsx-over-src
The nightly Impact PDG Mutation Report workflow failed at the first fixture with
ERR_MODULE_NOT_FOUND for src/cli/lazy-action.js. The harness shelled the real CLI
out as `node --import tsx src/cli/index.ts analyze …`; on the CI runner's Node
22.22.3, native TypeScript type-stripping is enabled by default and handles the
.ts entry instead of tsx, and native stripping does NOT remap the `./lazy-action.js`
import specifier to lazy-action.ts the way tsx does — so CLI startup crashes
before analyze even runs.
The workflow already builds dist/ (build: 'true'). Prefer the shipped
dist/cli/index.js (plain compiled JS — no tsx, no strip-types, and the parse
workers it spawns also resolve from dist/) for the analyze child, falling back to
tsx's own CLI over src only for build-free local runs. Production-faithful and
version-agnostic across the engines range (node >=22.0).
Verified on a real Node 22.22.3: the dist child starts cleanly with no
lazy-action resolution error; the full `--mutation --only=inter-dispatcher-thin`
run scores realized recall 1.0 and gate-mutation-recall passes. Workers are
independently confirmed green on 22.22.3 in CI (run 27874383902).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(impact-pdg): declare the mutation oracle's @babel/* deps
`bench/impact-pdg/mutation-oracle.mjs` imports @babel/parser, @babel/traverse,
@babel/generator and @babel/types to instrument + value-diff the fixture AST,
but none were declared in package.json. @babel/parser and @babel/types happen to
be hoisted into gitnexus/node_modules transitively, but @babel/traverse and
@babel/generator are only present at the monorepo root — so a fresh `npm ci` in
gitnexus/ (CI) can't resolve them and the oracle dies at module load with
`Cannot find package '@babel/traverse'` right after analyze succeeds.
Declare all four as devDependencies (they're already lazily imported only on the
--mutation path, so they stay out of the unit-test module graph). Verified the
oracle resolves them from gitnexus/node_modules and scores recall 1.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(impact-pdg): gate only recall-gated mutation checks (honor recallGated)
The recall gate filtered checks by `typeof c.recall === 'number'`, which includes
the UPSTREAM fixtures. The mutation oracle is a FORWARD value-diff: it mutates the
criterion line and observes which downstream lines' values change, so its
behavioral AIS can never intersect a reverse (upstream) PDG slice — recall is 0
by construction. measure.mjs already marks these `recallGated: false` (alongside
id-discrimination corroboration cases) and excludes them from its own internal
gate; the standalone gate just didn't honor that flag, so `intra-control-loop`
(direction: upstream, recall 0) tripped the floor even though the oracle ran the
full suite cleanly (mean recall 0.923).
Filter on `c.recallGated === true` so the floor applies only to the downstream
cases the forward oracle can fairly validate. Verified locally: an
upstream+downstream report now scores 1 of 2 and the gate passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(impact-pdg): fail the mutation gate when it has no recall signal + fix README drift
Tri-review hardening of this PR's own changes:
- gate-mutation-recall.mjs: the floor check passed vacuously when `scored` was
empty (`min === null` short-circuits `min !== null && min < floor`). Narrowing
the filter to `recallGated === true` made an empty `scored` set reachable in
more inputs (a degenerate corpus, or a harvest that silently emptied every
behavioral AIS). Now fail loudly when checks exist but none are recall-gated,
so a hollow gate is red rather than a green "scored cases: 0 of N". A genuinely
empty report (0 checks) still passes — it's not a degenerate-corpus signal.
- README.md: the harness substrate section still documented the old
`node --import tsx src/cli/index.ts …` child invocation this PR replaced;
update it to the dist-preferred form to match `cliChildArgs`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(routes): persist HTTP method on Route nodes
Part 1 of 2 for issue #2138 (skip redundant HTTP provider source-scan).
The ingestion routes phase already knows each route's HTTP verb —
`ExtractedRoute.httpMethod` (Spring/Laravel framework routes) and
`ExtractedDecoratorRoute.httpMethod` (decorator routes) — but dropped it
when creating the Route graph node. As a result `HttpRouteExtractor`'s
graph-assisted path could not recover the verb for `framework-route`
sources (whose edge `reason` is undecodable by `methodFromRouteReason`)
and had to fall back to re-scanning the handler source.
Changes:
- routes phase: carry `httpMethod` into `RouteEntry` and persist it as
`Route.method` (filesystem-derived Next.js/Expo/PHP routes have no
structural verb, so they stay method-less).
- HttpRouteExtractor: HANDLES_ROUTE query now returns `route.method`;
`extractProvidersGraph` prefers it and falls back to the edge reason
for older indexes / method-less routes (fail-open, fully backward
compatible).
- tests: graph-method precedence, multi-verb handler disambiguation via
the persisted verb, case normalization, and old-index fallback.
This change is intentionally NOT a performance optimization on its own:
the graph path still parses handler files to recover the handler *name*.
Eliminating that parse (and thus the redundant source-scan #2138 targets)
requires linking HANDLES_ROUTE to the handler symbol, which lands in
Part 2. This PR is the data-completeness groundwork for that.
Refs #2138
* test: account for new Route.method in blade route-registry assertion
The routes phase now persists httpMethod onto RouteEntry/Route nodes, so
the strict toEqual on the framework-route registry entry must include the
new method field.
* fix(routes): persist Route.method end-to-end + real-lbug round-trip test
Addresses review on #2234 (magyargergo + tri-review): the prior commit
read `route.method` in HANDLES_ROUTE_QUERY but never added the column to
the schema/persistence path, so against a real LadybugDB the query failed
to bind (`Cannot find property method for r.`) and the `catch { return [] }`
silently swallowed it — regressing the graph-assisted HTTP provider path.
- schema: add `method STRING` to ROUTE_SCHEMA.
- csv-generator: write `method` in the Route CSV row (header + row, column
order aligned with the COPY statement).
- lbug-adapter: add `method` to getCopyQuery('Route').
- routes phase: normalizeRouteMethod() canonicalizes the verb to upper-case
and skips non-verbs — Laravel resource/apiResource carry httpMethod
values like `resource`/`apiResource`, which must not land a junk method.
- http-route-extractor: log at debug when the HANDLES_ROUTE / FETCHES graph
query throws, so a total graph-provider outage is observable instead of
silently swallowed. Export HANDLES_ROUTE_QUERY for the round-trip test.
- tests: add a real-lbug round-trip (graph -> CSV -> COPY -> HANDLES_ROUTE_QUERY)
asserting the verb persists and reads back; update the blade registry
assertion for the normalized (upper-case) method.
Refs #2138
* fix(csv): coerce Route.method to string for escapeCSVField typecheck
node.properties.method is typed unknown (not a declared property), so
`x || ''` stayed unknown and failed tsc against escapeCSVField's
string|number param. Coerce explicitly with String(... ?? '').
* test(bench): regenerate emit-persistence fingerprint for Route.method column
Adding the method column to route.csv changes the byte-identity
fingerprint of the emit-persistence benchmark (the synthetic graph's
route.csv header now includes 'method'). scaling_ratio unchanged (~0.9,
linear); this is the documented regenerate-on-legitimate-emit-change
path. Streaming baseline (BasicBlock/PDG) is unaffected.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(server): resolve clone/upload/mapping roots from GITNEXUS_HOME
CLONE_ROOT (git-clone.ts), UPLOAD_ROOT (upload-paths.ts), and the
server-mapping file (embeddings/server-mapping.ts) computed their root
from os.homedir() directly, ignoring GITNEXUS_HOME. The Docker image
sets GITNEXUS_HOME=/data/gitnexus (the persistent volume that also holds
the registry and indexes), so cloned/uploaded repos and their .gitnexus
indexes instead landed in the container's ephemeral ~/.gitnexus and were
lost on container recreation, while registry.json (which honors
GITNEXUS_HOME) kept pointing at the now-dead path. That also defeated
incremental re-analysis: a recreated container re-clones from scratch and
full-rebuilds instead of git pull + incremental update.
Source all three from the existing getGlobalDir() helper — the same
GITNEXUS_HOME-aware primitive the registry and groups already use.
Behavior is unchanged when GITNEXUS_HOME is unset (CLI / local installs):
it falls back to ~/.gitnexus exactly as before. No signatures change and
no UPLOAD_ROOT consumers are touched.
Adds test/unit/gitnexus-home-roots.test.ts covering both the
GITNEXUS_HOME-set and unset paths for all three roots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(server): make clone-root assertions GITNEXUS_HOME-aware; cover server-mapping fallback
Addresses PR review (#2229):
- git-clone.test.ts hardcoded path.join(os.homedir(), '.gitnexus', 'repos')
for the clone root, so the "direct child of the clone root" and containment
assertions failed when GITNEXUS_HOME was set in the ambient env (e.g. a CI
runner). CLONE_ROOT now derives from getGlobalDir(); mirror that derivation
via EXPECTED_CLONE_ROOT (GITNEXUS_HOME || ~/.gitnexus), computed at module
load — the same point CLONE_ROOT is frozen — so the two always agree.
- The GITNEXUS_HOME-unset fallback test covered clone + upload roots but not
server-mapping (one of the three changed modules). Add a fallback case for
readServerMapping. It redirects HOME/USERPROFILE to a tmp dir (os.homedir()
honors them) so it exercises the real ~/.gitnexus fallback without writing
into the developer's actual ~/.gitnexus/server-mapping.json.
Both files pass with and without GITNEXUS_HOME set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(server): use mkdtemp for GITNEXUS_HOME path-root test temp dirs
CodeQL js/insecure-temporary-file flagged the two writes that used a fixed
os.tmpdir() name (gitnexus-home-roots-test, gitnexus-fallback-home): a
co-located process could pre-create or symlink the predictable path before
the test write lands. Switch both to fs.mkdtemp(), which atomically creates a
uniquely-named directory — the canonical sanitizer this repo already uses
(see core/group/storage.ts). Behavior is unchanged; tests still pass with and
without GITNEXUS_HOME set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(embeddings): resolve server-mapping path for parity with clone/upload roots
MAPPING_FILE now wraps path.resolve() like CLONE_ROOT (git-clone.ts) and
UPLOAD_ROOT (upload-paths.ts), so a relative GITNEXUS_HOME yields an absolute
path. No-op for the supported absolute-GITNEXUS_HOME config (Docker) and for
readServerMapping's only caller (run-analyze.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(server): derive EXPECTED_CLONE_ROOT from getGlobalDir() to avoid drift
The test mirror now imports getGlobalDir() and computes the expected clone root
the same way production CLONE_ROOT does, instead of re-deriving the
GITNEXUS_HOME || ~/.gitnexus fallback by hand. Byte-identical today; future-proof
if getGlobalDir() grows a branch. (os import retained — still used by os.tmpdir().)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(server): rename shadowed savedHome in server-mapping fallback test
The inner savedHome (saves process.env.HOME) shadowed the describe-scope
savedHome (saves process.env.GITNEXUS_HOME). Renamed the inner one to
savedProcessHome at its declaration and both restore sites in the finally
block. Pure rename, no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(server): scope customHome temp dir to the GITNEXUS_HOME-set cases
beforeEach created a customHome temp dir for all five tests, but the two
fallback cases never use it (one manages its own fakeHome, the other needs
none). Moved the mkdtemp/rm into a nested describe('with GITNEXUS_HOME set')
wrapping the three set-cases; the shared outer afterEach still restores
GITNEXUS_HOME and resets modules for all five.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* test(lbug): lock PARALLEL=false as a tested correctness invariant (#2203)
The parallel CSV reader (Kuzu-derived, default PARALLEL=true) cannot parse
quoted fields with embedded newlines (kuzudb/kuzu#5778); our content/text
columns hold source code, so PARALLEL=false is mandatory for correctness.
Add a live-DB multiline-quoted round-trip that fails if it is ever flipped,
plus a static guard on the generated COPY queries. Export COPY_CSV_OPTS /
getCopyQuery for the static assertion; document the invariant at the source.
* feat(lbug): expose node/rel phase boundary via onNodePhaseComplete hook (#2203)
streamAllCSVsToDisk now fires an optional onNodePhaseComplete(nodeFiles)
callback right after node CSVs are flushed and before the relationship pass
writes any rel_*.csv — the boundary the COPY-overlap leg needs. The node-file
manifest construction is hoisted above the rel pass and reused in the return,
so output is byte-for-byte identical when no callback is supplied (verified by
the emit bench fingerprint and the splitRelCsvByLabelPair differential oracle).
The callback is not awaited, so the rel pass runs concurrently with the
caller's node COPY.
* perf(lbug): overlap node COPY with relationship emit (#2203)
The deferred parallelism leg of #2203. LadybugDB is single-writer and its
parallel CSV reader is unsafe for our multiline content (kuzudb/kuzu#5778), so
the only safe parallelism is pipeline-overlap: node COPY (uses conn, never the
rel files) runs concurrently with the relationship emit pass (writes rel_*.csv,
never conn). Node COPY now starts at streamAllCSVsToDisk's onNodePhaseComplete
boundary while the rel pass keeps writing; the relationship COPY still waits for
node COPY (FK precondition), so DB load order and content are unchanged.
- Extract copyNodeCSVs; start it in the hook (overlap) or after emit (serial).
- GITNEXUS_SERIAL_LBUG_LOAD=1 forces the legacy strictly-sequential path
(operator escape hatch + differential-test oracle).
- Settle the in-flight node-COPY promise on emit failure (no unhandled
rejection); rethrow node-COPY errors at the FK barrier.
- Preserve the PDG manifest merge + collision guards (node merge at the hook,
rel merge before rel COPY) and all retry/fallback/cleanup behavior.
- PROF_LBUG_LOAD gains mode=overlap|serial; copy-nodes becomes the residual
node-COPY time after emit (trends to 0 as overlap hides it).
* test(lbug): differential gate — overlap load === serial load (#2203)
Loads one fixture (multiple node tables, multiple edge pairs, multiline File
content + BasicBlock text) into two fresh DBs — once via the default node-COPY
‖ rel-emit overlap, once via GITNEXUS_SERIAL_LBUG_LOAD=1 — and asserts the two
databases are content-equivalent: identical per-table node counts, per-type
edge counts, byte-for-byte multiline content/text, and identical
insertedRels/skippedRels/warnings. This is the issue's byte-identical-content
acceptance gate for the parallelism leg.
* fix(review): apply autofix feedback
- csv-generator: onNodePhaseComplete doc-contract now matches reality (a sync
throw is allowed and is how loadGraphToLbug surfaces the manifest collision
guard) — drops the inaccurate 'must not throw synchronously' line.
- lbug-adapter: copyNodeCSVs totalSteps is the node-table count (drop the +1
rel-step holdover; the rel COPY has its own progress line).
- lbug-adapter: on emit+node-COPY double-failure, log the swallowed node-COPY
error before rethrowing the emit error (diagnosability).
- lbug-load-prof test: assert mode=overlap on the default path.
* fix(test): use mkdtemp for secure temp dirs (CodeQL js/insecure-temporary-file)
CodeQL flagged lbug-load-overlap.test.ts writing a file into a predictable
os.tmpdir() path. Create the base temp dir with fs.mkdtemp (atomic, random
suffix) in both new live-DB tests, and switch to the gitnexus-lbug- prefix that
TEST_FIXTURE_PREFIXES recognizes so the Windows stale-sidecar sweep covers
these fixtures.
* fix(lbug): check PDG manifest rel-pair collision before node COPY (#2203)
Found by Codex in tri-review. The manifest rel-pair collision guard ran after
the FK barrier (after node COPY committed), so on that should-never-happen
error branch the overlap path left orphan node rows AND the
GITNEXUS_SERIAL_LBUG_LOAD escape hatch diverged from the legacy 'validate
manifest before any COPY' behavior. Move the rel merge + collision check ahead
of beginNodeCopy/the barrier: the serial path now detects a collision before
committing any node rows (legacy parity restored — the escape hatch is a
faithful oracle again), and the overlap path detects it as early as csvResult
is available. The node-collision guard already ran before node COPY (in the
hook).
* test(lbug): cover rel-emit failure with node COPY in flight (#2203)
Resolves a P1 review gap on PR #2226: the overlap's catch(emitErr) branch
(settle the in-flight node-COPY promise, then rethrow the emit error) was
untested. Fault-injects via a vi.mock of streamAllCSVsToDisk that fires
onNodePhaseComplete (starting a real node COPY on a live DB) then throws,
asserting loadGraphToLbug rejects with the emit error and no unhandled
rejection leaks. Also covers the both-fail case (node COPY error is logged,
emit error still wins). Listener removed in finally; macrotask queue flushed
before the assertion so it can't pass vacuously.
* test(lbug): cover node-COPY hard-failure rethrow at the FK barrier (#2203)
Resolves the second P1 review gap on PR #2226. Mocks emit to fire
onNodePhaseComplete with a nodeFiles entry pointing at a missing CSV (a
bind-time COPY error that IGNORE_ERRORS does not suppress) and otherwise
succeed, so copyNodeCSVs throws, the error is captured in nodeCopyError, and
loadGraphToLbug rethrows it at the FK barrier — asserted via rejects /COPY
failed for File/.
* test(lbug): cover PDG manifest rel-pair collision in overlap + serial (#2203)
Resolves the P2 gap behind the Codex tri-review finding: the manifest rel-pair
collision guard (moved ahead of node COPY in ad195582) had no test. A leaky
graph with a structural BasicBlock->BasicBlock edge (routed by id-prefix, no
BasicBlock nodes — isolating the rel-pair clash from the node-CSV one) plus a
PdgEmitSink manifest declaring the same pair makes loadGraphToLbug reject with
the rel-pair collision error, asserted on both the overlap (default) and serial
(GITNEXUS_SERIAL_LBUG_LOAD=1) paths.
* perf(lbug): yield the event loop periodically during relationship emit (#2203)
Resolves a P2 review finding on PR #2226: the relationship-emit loop ran long
synchronous stretches between write-stream drain awaits, which could starve the
overlapped node-COPY callbacks on fast I/O and erode the node-COPY-||-rel-emit
overlap. Yield via setImmediate every REL_YIELD_EVERY (5000) edges so the node
COPY and drains get scheduling time. Scheduling-only — emit bench fingerprint
unchanged (byte-identical), csv-pipeline determinism + overlap differential
green.
* refactor(lbug): extract shared copyCsvWithRetry helper (#2203)
Resolves a P2 maintainability finding on PR #2226: the COPY + IGNORE_ERRORS
retry block was duplicated in copyNodeCSVs and the inline relationship-COPY
loop. Extract copyCsvWithRetry(conn, query, onError); the callback receives the
RAW retry error so each site keeps its own message shape + slice length (node
throws, slices 200; relationship warns + records the failed pair, slices 80).
Behavior-preserving — guarded by the live-DB round-trips plus the new
node-COPY-failure and overlap error-path tests.
* docs(lbug): document loadGraphToLbug non-transactionality (#2203)
Resolves the advisory review finding on PR #2226: loadGraphToLbug runs
independent COPYs with no surrounding transaction, so a mid-load failure leaves
a partial DB and recovery is a --force re-analyze. Make that contract explicit
on the function so callers don't assume atomicity.
"description":"Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
@@ -24,6 +24,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
```jsonc
{ /* …the tool's normal result… */
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
}
```
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: closure/callback, property/field, and implicit flows are not modeled, and interprocedural findings are function-level `TAINT_PATH` hops rather than statement-level path proof, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
### Control & data dependence (`pdg_query`)
@@ -104,6 +122,8 @@ A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
Cross-repo (experimental): pass `repo: "@groupName"` to trace across a group's member repos — the path may cross **one**`ContractLink` boundary (reported as a `CONTRACT_LINK` hop with the bridged contract in `crossings[]`). Omit `to` entirely to follow `from`'s outgoing HTTP call to whatever provider endpoint it lands on. Groups are configured via `group_list` / `group_sync`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
@@ -119,8 +139,10 @@ Lightweight reads (~100-500 tokens) for navigation:
## Graph Schema
**Nodes:** File, Function, Class, Interface, Method, Community, Process
3. detect_changes() → Map current git changes to affected flows
3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .`
4. Assess risk and report to user
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
## Checklist
```
- [ ] impact({target, direction: "upstream"}) to find dependents
- [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] detect_changes() for pre-commit check
- [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check
- [ ] Assess risk level and report to user
```
@@ -55,7 +56,7 @@ description: "Use when the user wants to know what will break if they change som
## Tools
**impact** — the primary tool for symbol blast radius:
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:
```
impact({
@@ -73,10 +74,10 @@ impact({
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**detect_changes** — git-diff based impact analysis:
**detect_changes** — git-diff based impact analysis. If MCP is unavailable, use `node .gitnexus/run.cjs detect-changes --scope all --repo .` instead:
description:"Use when the user wants the GitNexus engineering pipeline run end-to-end on a task: gitnexus-plan (plan depth chosen up front), a blocking gate to execute with gitnexus-work or stop, finishing with a gitnexus-review of the result. Examples: \"/gitnexus-lfg Add retry support to the ingestion pipeline\", \"run the gitnexus pipeline on this\", \"plan, build and review this feature\"."
---
# gitnexus-lfg — plan → gate → work → review
Thin orchestrator over three existing skills. It adds no engineering logic of
its own — it sequences `gitnexus-plan`, `gitnexus-work`, and
`gitnexus-review`, with the user deciding at the plan gate. Run every lane
by actually invoking the named skill (read its SKILL.md and follow it);
never inline a summary of what the skill would have done.
```
/gitnexus-lfg <task description>
/gitnexus-lfg docs/plans/<existing-plan>.md # skip lane 1, start at the gate
```
## Lane 1 — Plan
**Boundary triage first.** If the task is plainly below the planning
boundary — trivial or small-bounded work an agent finishes in well under ~35
turns (the measured regime where a planning pass costs more than it returns;
measured in the GitNexus repository's `eval/workflow_bench/`) — say so and
offer `gitnexus-work` direct mode as an alternative to the full pipeline
before spending the plan lane. Honor the user's choice.
The threshold is a promoted benchmark policy measured offline, not a
timeless heuristic — never self-edit it from a live task. Its re-evaluation
governance lives in this skill's README.
Otherwise invoke `gitnexus-plan` with the task (knob overrides pass through
verbatim; `gitnexus-plan` owns the up-front depth question — never ask it
again here). If the input is already a plan file path, skip to Lane 2. The
plan lands in `docs/plans/` — record its path; every later lane consumes it.
## Lane 2 — The plan gate (user choice, blocking)
Present the plan's chat summary (objective, proposed changes, sequence, top
risks, open questions, plan path), then ask the user — as a blocking
question (`AskUserQuestion` in Claude Code; a numbered list in chat on CLIs
without a blocking tool):
1.**Proceed to work** — continue to Lane 3.
2.**Stop here** — the plan file is the deliverable; end the pipeline.
Depth was the user's up-front choice in Lane 1, so deepening is not offered
by default — but honor an explicit request for it at the gate: run
`gitnexus-plan` Deepen mode on the plan file and return here with the
strengthened plan, as many times as the user asks. Do not proceed past the
gate without an explicit choice — the gate is the pipeline's only checkpoint
and exists precisely because execution is expensive to unwind.
**Headless / non-interactive runs:** no one can answer the gate, so end the
pipeline after Lane 1 — the plan file is the deliverable (gate option 2) —
and say so in the final report. Never auto-proceed to execution.
## Lane 3 — Work
Invoke `gitnexus-work` with the plan path. It re-anchors the plan at HEAD,
executes the Implementation Sequence as verified atomic commits, refreshes
the knowledge graph when done (its Phase 4), and reports deviations. If it routes back for re-planning (structural drift), run the
Deepen pass and return to the Lane 2 gate rather than pushing through.
## Lane 4 — Review
Invoke `gitnexus-review` on the completed work. Pass an open PR URL/number
when one exists; otherwise pass the current branch. The review skill owns
target resolution, exact-SHA checkout/index alignment, and merge-base
selection. Do not duplicate that logic here. If work left local changes,
pass `local` as a second, separately labeled review surface.
Surface the review verdict and findings to the user. Findings the user
wants fixed: those within `gitnexus-work`'s direct-mode bounds (1–2 files,
no architectural decisions) → hand to `gitnexus-work` direct mode; anything
larger → offer the plan gate instead (Deepen the plan with the findings, or
stop). Then re-run this lane's review once. On that re-run, do not start
another fix cycle even if findings remain — report them and point the user
at `/gitnexus-work` (or the plan gate) to continue deliberately.
## Final report
One message: plan path, deepen cycles run, commits produced, verification
status, review verdict with unresolved findings, and what (if anything) was
explicitly left undone. The pipeline does not push or open a PR on its own —
| **Codex CLI** | Ask: "run gitnexus-plan for <task>" (Codex reads `AGENTS.md`) — or install the user-level prompt below | `AGENTS.md` § Engineering planning & execution |
| **Any AGENTS.md-aware agent** | Ask it to "read `.claude/skills/gitnexus-plan/SKILL.md` and follow it for <task>" | `AGENTS.md` § Engineering planning & execution |
```
/gitnexus-plan Add retry support to the ingestion pipeline
/gitnexus-plan Fix the stale warm-cache invalidation bug in exportedTypeMap
/gitnexus-plan depth:deep impact_depth:3 Migrate the emit phase to streaming COPY
```
Output: `docs/plans/YYYY-MM-DD-gitnexus-plan-<slug>.md` — a 13-section plan whose
section 11 is a machine-readable **implementation context pack** that a
follow-up agent can consume without re-investigating the repository. Compact
and full packs both include versioned evidence provenance: a canonical global
dirty digest and a sorted, per-layer cited-path manifest. An npm-dependency-free,
versioned Node helper shared byte-for-byte with `gitnexus-work` is the only
supported serializer, so planner and executor hash identical bytes. The same
helper is the only supported existing-plan reader and plan writer. Its
descriptor-anchored `read-plan` receipt binds the canonical path, exact base64
bytes, and SHA-256 digest before Deepen or execution. The writer accepts a repo-relative
description:'Use when you need a deep, implementation-ready engineering plan for a code change — built from GitNexus graph intelligence, statement-level PDG analysis, and targeted source verification, compact enough that an implementation agent can start without re-investigating. Also strengthens existing plans via Deepen mode. Examples:"/gitnexus-plan Add retry support to the ingestion pipeline","/gitnexus-plan deepen docs/plans/<plan>.md","plan this change using the knowledge graph".'
| `depth` | by category | `narrow` = `impact_depth` 1, PDG only if one function is clearly central; `default` = this table; `deep` = `impact_depth` 3 + clusters/processes read |
| `form` | by category | `compact` (core sections + mini-pack, ≤80 lines excl. pack — see `references/plan-template.md`) or `full` (all 13 sections) |
| `impact_depth` | 2 | `maxDepth` for `impact` |
| `pdg_data_depth` | 2 | Data-dependence hops in the PDG slice |
| `pdg_control_depth` | 2 | Control-dependence hops in the PDG slice |
| `max_snippet_lines` | 30 | Longest source excerpt quoted in the plan |
| `freshness` | by category | `strict` (full-plan categories) = refresh a stale index (and a missing PDG layer) with `analyze --index-only [--pdg]` before relying on the graph; `accept` (compact categories) = plan on the current graph, source-weighted and labelled, refreshing only if a graph claim becomes load-bearing |
## Fallback mode (GitNexus or PDG unavailable)
1. Say so, first thing, in chat and in the plan.
2. Use targeted repo exploration (grep/glob/reads) to approximate callers,
dependencies, execution flow, state changes, and related tests.
3. Label every such finding **source-derived** in the plan — never present it
as graph-derived, and never fabricate statement-level edges.
4. Recommend `analyze --index-only` (add `--pdg` for the PDG layers) via
the resolved runner — `node .gitnexus/run.cjs`, installed `gitnexus`, or
`npx gitnexus` — when it would materially raise confidence.
## Skill feedback
If this run exposed friction in the instructions, include concise feedback in
the final response. Feedback is chat-only: do not append evaluation learnings,
edit benchmark data, or modify this skill during a live planning task.
description:'Review code changes with GitNexus from a GitHub PR URL or number, a branch/ref or commit range, or local staged, unstaged, and untracked changes. Use when the user asks for a code review, merge-risk assessment, regression hunt, missing-test analysis, or a verdict on whether a PR, branch, commit range, or local diff is safe.'
---
# GitNexus review
Review the requested change surface without editing source, committing, pushing,
posting, or resolving threads. A later explicit request may authorize those
actions. Use GitNexus for structural evidence and source inspection for proof;
description:CI review swarm lane. Assumes the change is broken and constructs concrete failure scenarios — races, hostile inputs, state corruption, abuse of new surfaces — verified against source and the GitNexus graph. Read-only; reports findings only.
description:CI review swarm lane. Hunts logic errors, edge cases, contract breaks, and state bugs in the changed symbols of a PR, grounded in the GitNexus graph. Read-only; reports findings only.
description:CI review swarm gate. Audits the orchestrator's draft review before publication — every finding anchored and concrete, severities calibrated, sections and verdict wording conformant, no generic filler. Returns PASS or a defect list; never rewrites the review.
description:'Use when executing an engineering plan produced by gitnexus-plan (or a small bounded task directly) — implements step by step with GitNexus impact checks before every symbol edit, tests from the plan''s scenarios, and detect_changes gating every commit. Examples:"/gitnexus-work docs/plans/2026-07-11-gitnexus-plan-ingestion-retry.md","/gitnexus-work"(latest plan), "execute the plan".'
---
# gitnexus-work — execute a gitnexus-plan
Execute an implementation plan produced by `gitnexus-plan`, shipping it as a
sequence of verified, atomic commits. The plan's section 11
(`implementation_context` pack) is the primary machine-readable input; the
prose sections are its rationale. This skill **does** edit code — it is the
executor counterpart to the planning-only `gitnexus-plan`.
```
/gitnexus-work <plan path> # execute this plan
/gitnexus-work # newest docs/plans/*gitnexus-plan*.md here
/gitnexus-work <small task text> # direct mode, see Input triage
```
## Input triage
- **Plan path** (or blank → the newest `docs/plans/*gitnexus-plan*.md` under
the current repo root): the normal mode; continue to Phase 1. Schema-2
description:"Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
- NEVER rename symbols with find-and-replace — use `gitnexus_rename`.
- NEVER commit without running `gitnexus_detect_changes()`.
- NEVER ignore HIGH/CRITICAL risk warnings from impact analysis.
- NEVER run `npx gitnexus analyze` without `--embeddings` if `.gitnexus/meta.json` shows stored embeddings.
- NEVER run `npx gitnexus analyze` without `--embeddings` if the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`) shows stored embeddings.
Full rules: **[AGENTS.md](../AGENTS.md)** (`gitnexus:start` block, Cursor Cloud section).
@@ -10,6 +10,8 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
>
> The trade-off of the copy model: host and container config **diverge after first create.** A skill or plugin you add on the host later won't appear in the container until you wipe the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Edits you make inside the container persist across rebuilds but never reach the host.
"hold":"pinned to unreleased fwcd main commit c8ac3d26 for `fun interface` support (fwcd/tree-sitter-kotlin#169, closes #87) — npm latest (0.3.8) lacks the fix, so the monitor must NOT auto-revert (isNewer is strict-inequality: 0.3.8 != 0.4.0). Drop this hold and bump when upstream cuts a release that includes the fix"
echo "::notice::Release GitHub App secrets (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) are not configured — prebuilds will build and upload as artifacts, but the auto-PR is skipped. Provision the App, or run with open_pr=false to suppress this notice."
fi
# ── Fork PRs: emit the PR identity so the trusted `commit-fork-prebuilds`
# workflow_run job can push the rebuilt prebuilds back onto the fork's
# branch. That job has no PR context of its own (workflow_run.pull_requests
# is empty for forks), so it reads this. Same-repo PRs don't need it — the
# aggregate job below commits straight onto their branch. This artifact is
# untrusted producer output: every field is allowlist-validated again on
# the consumer side AND cross-checked against the workflow_run authority.
✅ **Rebuilt native prebuilds pushed to this PR branch.** A grammar source change re-cut the vendored \`tree-sitter\` prebuilds for all 6 platforms and they're now committed on your branch. ([builder run](${RUN_URL}))" ;;
nothing-to-commit)
body="${marker}
✅ Native prebuilds are already up to date on this branch — nothing to push." ;;
loop-prevented)
body="${marker}
🔁 Skipping prebuild push: the branch HEAD is already an automated prebuild commit." ;;
lease-failed)
body="${marker}
⏳ The PR head moved while the prebuilds were building, so they weren't pushed. Push another commit (or wait for the next build) and they'll be re-cut. ([builder run](${RUN_URL}))" ;;
push-failed)
body="${marker}
⚠️ Rebuilt native prebuilds are ready but **couldn't be pushed to your fork branch**. Tick **Allow edits by maintainers** in the PR sidebar so CI can commit them — or download them from the [builder run](${RUN_URL}) artifacts (\`ts-prebuild-*\`) and commit them under \`gitnexus/vendor/<grammar>/prebuilds/\` yourself." ;;
*)
body="${marker}
❓ Prebuild delivery finished in an unexpected state (\`${RESULT:-unknown}\`). See the [builder run](${RUN_URL})." ;;
esac
# Strip the YAML block indent so the rendered comment starts at column 0.
body="$(printf '%s\n' "$body" | sed 's/^ //')"
# Upsert a single sticky comment keyed by the marker; only ever edit our
# own bot comment (PATCH on someone else's 403s and would abort).
existing=$(gh api "repos/${GH_REPO}/issues/${PR}/comments" --paginate \
echo '::error::GITNEXUS_BENCH_AUTH_TOKEN is not configured. The evolution loop runs real benchmark sessions and needs an Anthropic API key (not the Claude Code OAuth token).'
Automated skill-evolution promotion. The deterministic gate passed; this PR is the human-review step — inspect the diff and the evidence before merging.
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
Four canonical, CLI-neutral skill specs under `.claude/skills/` (Claude Code invokes
them as slash commands; Codex or any other agent reading this file should read the
named SKILL.md and follow it directly — user-level Codex prompts are documented in the
plan/work/lfg skill READMEs):
- **`gitnexus-plan/SKILL.md`** — deep, implementation-ready plan for a code change:
GitNexus graph intelligence for navigation, statement-level PDG slices for behavioral
constraints, targeted source reads for verification. Output lands in `docs/plans/`
with a reusable implementation context pack (section 11). Planning-only — it never
edits code (index freshness refreshes via `analyze --index-only` are the one
permitted state change). Interactive runs ask up front how deep to go
(quick / standard / deep); Deepen mode strengthens an existing plan in place.
- **`gitnexus-work/SKILL.md`** — executes a gitnexus-plan as verified atomic commits:
drift-checks the plan's evidence pin against HEAD, `impact` before every symbol
edit, tests from the plan's scenarios, `detect_changes` before every commit.
- **`gitnexus-review/SKILL.md`** — read-only GitNexus review of a PR URL/number,
branch or commit range, or local staged/unstaged/untracked changes. It pins exact
SHAs, aligns the graph and checkout, runs a PDG-backed taint pass on trust-boundary
diffs, scales to per-domain expert lenses from the graph's clusters (dispatched as
parallel swarm lanes — `ci-personas/` — when the CI review agent runs it), and
reports evidence-backed findings.
- **`gitnexus-lfg/SKILL.md`** — pipeline orchestrator: plan (depth asked up front) →
blocking user gate (proceed or stop) → work → `gitnexus-review`.
The family ships with the npm package (`gitnexus/skills/`, installed to editor targets
by `gitnexus setup`) and the Claude Code plugin; review also has a standalone Cursor
mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Token savings of the workflow are measurable with
`eval/workflow_bench/` (real headless CLI runs, free-model routing supported — see its README).
## Changelog
| Date | Version | Change |
|------|---------|--------|
| 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. |
| 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). |
| 2026-07-16 | 1.12.0 | Renamed `gitnexus-pr-review` to `gitnexus-review`; added PR URL/number, branch/range, and local-change targets plus install migration (setup warns on a legacy `gitnexus-pr-review` dir and leaves it in place; uninstall removes it). |
| 2026-07-11 | 1.11.0 | Skill family shipped via npm skills/ + plugin (sync-guarded); added eval/workflow_bench token-savings benchmark. |
| 2026-05-22 | 1.8.0 | Kotlin added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). Closes #1756 (companion-vs-instance dispatch) and #1757 (lambda scopes); refs #1746. RFC §6.4 corpus criterion waived (corpus-mode wiring is #927-scope); fixture criterion met. |
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
@@ -74,29 +111,31 @@ commits, or posts.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --directionupstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
- NEVER commit before MCP/CLI graph change analysis.
## Resources
| Resource | Use for |
|----------|---------|
| --- | ---|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
@@ -105,33 +144,13 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
1.**Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
1.**Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). The default DAG of 19 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
| `group_list` | List repo groups or details for one group |
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). The previously-planned `group_query`, `group_context`,`group_impact`, `group_contracts`,`group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@<groupName>"` — but, unlike the others, it resolves`from`/`to` across **all** members (a`@<groupName>/<memberPath>` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member.
| Resource URI | Purpose |
|--------------|---------|
Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
19 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.`--pdg` adds `taintSummaries` and `callSummaries` (21 total).
**Non-phase files in the same directory:**`parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
- **Skippable phases** — `skipGraphPhases` omits MRO/di/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
### How to add a new phase
@@ -138,7 +147,9 @@ import type { PipelinePhase, PhaseResult } from './types.js';
@@ -207,15 +222,36 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
### Callable-value flow
First-class callable values use a language-neutral inclusion analysis in `passes/callable-value-flow.ts`. Providers recognize their own syntax and emit JSON-safe `CallableFlowSite` facts (`seed`, `copy`, `alias`, `address`, `load`, `store`, `formal`, `argument`, and `invoke`) into `ParsedFile`; shared ingestion never branches on a language name. These always-on facts cross workers and the durable parse store, whose schema is bumped whenever their semantic shape changes.
The emit stage defers only invocation sites proven to reference a flow cell. Ordinary receiver/free/reference passes still resolve direct callees first and record exact callee IDs by file/line/column. Property dispatch then runs before callable flow because a property-dispatched wrapper call can seed actual-to-formal propagation. The callable solver consumes those direct targets, propagates callable sets through lexical cells and formals, and emits `CALLS` at the real indirect invocation site with reason `callable-value-flow` (confidence 0.8 for a singleton, 0.7 for a bounded multi-target set).
The solver is flow-insensitive but bounded: dependency-indexed work items rerun only when a cell they read changes; target/address sets cap at 32; a hostile fact graph has a finite work budget; overflow or budget exhaustion emits no partial `CALLS` and produces a structured warning. Lexical shadowing is function/block aware, invocation/constructor results are not reinterpreted as callable designators, and overload selection uses provider-supplied signature metadata. C/C++ additionally associate visible prototypes with unique definitions so actual-to-formal flow crosses translation units; the provider-owned `hasFileLocalCallableLinkage` hook prevents `static` declarations or definitions from leaking across files. C++ member-function pointers preserve parameter/cv shape, keep non-virtual targets exact, and expand virtual targets through `MethodDispatchIndex`/MRO.
Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`.
Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning.
### Receiver chains and the drop census (#2766)
A compound receiver (`svc.getUser().address.save()`) is captured as a compact string on `ReferenceSite.receiverChain`. `utils/receiver-chain-codec.ts` is the ONE encoder/decoder — capture emitters, the scope-resolution fold, and the durable ParsedFile store all import it rather than hand-rolling the format.
Wire format is **v2**: `2|<base>|<step>|<step>…`, one-character version prefix, then base-first steps, each a one-character kind sigil plus the member name (`c` = call, `f` = field). `a` (await) and `i` (index) are **name-free** and encode as a bare sigil — an awaited call's name already lives on its `c` step, and a subscript key is a value, not a lookup-able identifier. The version went 1 → 2 when those two kinds were added, and a decoder REFUSES a foreign version rather than decoding the prefix it understands: a chain missing its await/index hop decodes cleanly as a different, shorter chain and would type the receiver against the wrong member. The format is unescaped (`|` and `~` cannot occur in an identifier), so an unencodable name is refused rather than escaped, and the payload is capped at `MAX_RECEIVER_CHAIN_BYTES` / `MAX_CHAIN_DEPTH` steps. Because these strings live in the incremental parse cache and the durable ParsedFile store, a format change requires a `PARSE_CACHE_VERSION` schema bump — a stale cache would otherwise replay v1 chains this build discards.
Receivers the resolver could not type are not silently dropped. Each records a `ResolutionOutcome` (`scope-resolution/resolution-outcome.ts`) carrying the receiver's *shape* (`classifyReceiverShape`: `chain-call` / `chain-field` / `chain-mixed` / `chain-unwrap` / `no-chain` — the bench censuses these) and its *origin* (`in-program` / `external` / `unknown`). `scope-resolution/unresolved-receivers.ts` aggregates them per member name into the index-persisted `unresolvedReceiverMembers` summary, keeping in-program and external counts under separate keys. Only in-program drops make a count short: an external-rooted call (`System.out.println`, `fetch(...)`) has no in-graph node an edge could have reached, so it is reported but does not hedge. `impact` / `context` read that summary and publish `epistemic: 'exact' | 'lower-bound'`, prose `boundaries`, and the machine-readable `causes` split (`EpistemicCauses` in `mcp/local/local-backend.ts`).
On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no**`Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other:
- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind).
- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge _kind_ (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind).
- **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`.
- **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data.
- **M5 — CDG** (#2085): Ferrante control dependence over a Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept.
- **M6 — read surface** (#2086): the `pdg_query` MCP tool answers "what gates X?" (CDG, `mode: controls`) and "where does Y flow?" (REACHING_DEF, `mode: flows`); `explain` is the taint consumer. Both are always anchored + `LIMIT`-bounded (LadybugDB has no rel-property index) and share one `resolveBlockAnchor` helper. These PDG edge types are deliberately kept out of the default `VALID_RELATION_TYPES` / web schema.
- **Cross-repo trace enrichment**: group-mode `trace` (`pdg: true`) reuses the same anchored REACHING_DEF `flows` query to annotate a boundary-adjacent segment with how a value reaches the cross-repo call — strictly intra-procedural (data flow never crosses the repo boundary). See the group-aware tools note above.
See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-dependence / reaching-defs / taint passes) and `mcp/local/local-backend.ts` (`_pdgQueryImpl`, `_explainImpl`, the shared `resolveBlockAnchor`).
@@ -223,21 +259,26 @@ See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-depend
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
| `isNamespaceImport(parsedImport, targetFile, fromFile)` | Optionally reclassify a resolved named import as a namespace handle when the imported symbol is itself a module |
| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 |
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
| `elementTypeOf?` | `(containerType, via: {kind:'index'} \| {kind:'accessor',name}) → elementType \| undefined` — element type of a container, reached by subscript (`repos[0]`) or by a property-style collection view (`data.Values`). ONE hook for both routes (it replaced the split `unwrapCollectionAccessor` / `unwrapCollectionElement`, where implementing one silently answered nothing for the other). Consulted only where the source actually performed the access — never as a general type-name normalizer |
| `stripTypePreservingDecoration?` | `(typeName) → strippedName \| undefined` — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, `const`, nullable, borrow, sigil) so a receiver declared `*Host` still finds the `Host` binding (#2766). Never a container: unwrapping `Repo[]` here would fold `repos.find(x)` to `Repo.find` — that is `elementTypeOf`'s job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — default off |
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions |
| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in |
| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) |
### Per-language registration
@@ -248,27 +289,28 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
- **Cross-phase Tree cache**: the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) lets a scope-resolution per-language hook (`emitScopeCaptures`) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's `ParsedFile` (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
- **Callable-value worklist**: dependency-indexed inclusion propagation is linear in a reverse-ordered copy-chain fixture; target/address sets cap at 32 and the whole worklist has a finite budget with no partial edge emission on exhaustion.
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
---
@@ -291,15 +333,16 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
| Field | Purpose |
|-------|---------|
| `id`, `extensions` | Language identity and file matching |
| `treeSitterQueries` | S-expression queries for AST extraction |
| `exportChecker` | Public/exported symbol detection |
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
| `namespace` | Python | Module aliases resolved at call site |
### Chunked parse-and-resolve
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the _sole_ parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
### Inheritance and MRO
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
**Optional `--pdg` additions** (off by default, opt-in via `gitnexus analyze --pdg`; see _Optional CFG/PDG emission_ above): a `BasicBlock` node table, plus the PDG relation types `CFG`, `REACHING_DEF`, `CDG`, `TAINTED`, `SANITIZES`, and `TAINT_PATH` on the same `CodeRelation` table. These are deliberately kept out of the default `VALID_RELATION_TYPES` / web graph schema — query them via `cypher`, `explain`, or `pdg_query`.
@@ -420,12 +468,12 @@ Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
- **GitNexus:** standard skills in`.claude/skills/gitnexus-*/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
- **Engineering plans, execution & review:** `/gitnexus-plan <task>` (implementation-ready plans via GitNexus + statement-level PDG + source verification; Deepen mode for existing plans), `/gitnexus-work [plan]` (executes a plan as impact-checked, detect_changes-gated atomic commits), `/gitnexus-review [PR|branch|range|local]` (read-only graph-backed review), `/gitnexus-lfg <task>` (plan with depth asked up front → proceed/stop gate → work → review pipeline). Specs in `.claude/skills/gitnexus-{plan,work,review,lfg}/SKILL.md` (see AGENTS.md § Engineering planning & execution).
## Changelog
| Date | Version | Change |
|------|---------|--------|
| 2026-07-20 | 1.8.0 | The CI review agent runs `gitnexus-review` as a coordinated swarm — six `ci-personas/` lanes dispatched via the `Agent` tool with a bounded critic gate. |
| 2026-07-16 | 1.7.0 | `/gitnexus-plan` asks depth up front in interactive runs; `/gitnexus-lfg` gate slimmed to proceed/stop. |
| 2026-07-16 | 1.6.0 | Renamed `/gitnexus-pr-review` to `/gitnexus-review` and added PR, branch/range, and local-change targets. |
| 2026-07-11 | 1.5.0 | Added `/gitnexus-work` and `/gitnexus-lfg` to the engineering plans & execution pointer. |
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
| 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. |
| 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. |
@@ -56,29 +62,31 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --directionupstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
- NEVER commit before MCP/CLI graph change analysis.
## Resources
| Resource | Use for |
|----------|---------|
| --- | ---|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
@@ -87,32 +95,12 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
@@ -13,12 +13,17 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
## Development setup
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
**Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
@@ -134,7 +134,7 @@ Run the commands relevant to the touched area. If something cannot be run in the
### 4.5 If CI workflows or release pipelines changed
- [ ] The workflow passes a dry-run or triggered run before merge; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly.
- [ ] The workflow passes a dry-run or triggered run; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly. Workflows that only execute once registered on the default branch (an `issue_comment` trigger, or a newly added `workflow_dispatch`) cannot be dry-run pre-merge — merge them **registered but disabled**, then validate same-repo and fork execution post-merge before enabling.
- [ ]`CHANGELOG.md` is **not** edited here — it is owned by the release process.
This guide shows how to connect GitNexus to the Kilo Code VS Code extension using Kilo’s MCP support, based on a setup that has been tested successfully.
## Prerequisites
GitNexus should already be installed globally and working on the target repository, and the repository should be indexed successfully with `gitnexus analyze` before testing inside Kilo.
## Tested Versions
| Component | Version |
| --- | --- |
| VS Code | 1.125.1 (user setup) |
| Node.js | 24.15.0 |
| Kilo Code | 7.3.50 |
| OS | Windows 11 25H2 / Windows_NT x64 10.0.26200 |
| GitNexus | 1.6.7 |
## Where Kilo Stores MCP Config
Kilo Code stores MCP server configuration in its main config file. For the VS Code extension, config can be stored at either the global or project level.
| Scope | Config path |
| --- | --- |
| Global | `~/.config/kilo/kilo.jsonc` |
| Project | `kilo.jsonc` or `.kilo/kilo.jsonc` in the project root |
Kilo supports local MCP servers through STDIO, and GitNexus should be added as a local server under the `mcp` key in `kilo.jsonc`. Use this configuration:
```jsonc
{
"mcp":{
"gitnexus":{
"type":"local",
"command":["npx","-y","gitnexus@latest","mcp"],
"enabled":true,
"timeout":10000
}
}
}
```
## Check It Through the Kilo UI
1. restart kilo code extension or vs code
2. open kilo code settings
3. select mcp server section
#### From there, Kilo allows adding, editing, enabling, disabling, and deleting MCP servers, and it writes changes directly to the appropriate config file.

## Test the Connection
After configuration, Kilo automatically detects the tools exposed by the MCP server and can use them from chat once the server is available.
@@ -19,7 +19,8 @@ Maintainer may widen scope per task.
2.**Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists.
3.**Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
4.**Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
5.**Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in `.gitnexus/meta.json`(the previous behavior wiped them). Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap).
5.**Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in the index metadata (`.gitnexus/gitnexus.json`, mirrored to the legacy `meta.json`) — the previous behavior wiped them. Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap).
6.**Never `terminate()` a worker that may be inside a native call** — killing a worker thread mid-N-API aborts the entire process (`Napi::Error` → `std::terminate` → SIGABRT, #2432), so a timeout meant to trigger a graceful fallback takes the whole run down instead. Any worker running native code (tree-sitter grammars, LadybugDB, Icebug) must either reach a JS-visible safe point first — the parse pool's `shutdownDrainMs` handshake in `src/core/ingestion/workers/worker-pool.ts` — or be abandoned with `unref()` and left to exit on its own. A one-shot worker that ends after a single `postMessage` needs no `terminate()` at all: it exits by itself. This bites hardest on the path you cannot test locally, because the abort only reproduces once the native module actually loads.
---
@@ -30,20 +31,26 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Stale graph after edits
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. When the effective write set exceeds ~50% of the repo's files (minimum 50 files), the run transparently switches to the full wipe + bulk-COPY write plan and logs "switching to a full DB write" — expected behavior, not a bug, and file-level bookkeeping stays incremental.
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Trigger:** `analyze` produces unexpected results, or `incrementalInProgress` is set in the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`), or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. A dirty-flag recovery rebuild parks the interrupted run's sidecars beside the DB as `lbug.wal.dirty-recovery` / `lbug.shadow.dirty-recovery` for post-mortem debugging — harmless, and removable with `npx gitnexus clean --lbug-sidecars`. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
- **Trigger:** Semantic search quality drops; `stats.embeddings` in the index metadata (`gitnexus.json` / legacy `meta.json`) is 0 after refresh.
- **Do:** Re-run `npx gitnexus analyze --embeddings` to regenerate. Check the analyze log for a `Warning: could not load cached embeddings` line — if present, the cache restore failed (corrupt DB / schema mismatch) and the rebuild had nothing to preserve. If you intentionally passed `--drop-embeddings`, this is expected.
- **Why:** Plain `analyze` preserves prior vectors by re-inserting them after the rebuild; the only ways to end up at zero are an explicit `--drop-embeddings`, a cache-load failure (now logged), or a model/dimension change that invalidates the cache.
- **Why:** Plain `analyze` preserves prior vectors by re-inserting them after the rebuild; ways to end up at zero include an explicit `--drop-embeddings`, a cache-load failure (now logged), or a model/dimension change that invalidates the cache — but zero is no longer the only embedding-loss signature to watch for; see the Sign below for the non-zero, partial-failure case. A dirty-recovery run that cannot move the crashed WAL aside now either discards it (logged: forensics lost, embeddings still preserved) or fails fast with a lock error naming the holder — it never silently zeroes embeddings.
### Analyze finishes but embeddings are incomplete (partial embedding index)
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["embedding-checkpoint-pending"]` (or the human-readable "Index incomplete reasons" line); `stats.embeddings` is honest and **non-zero**, and the preceding analyze log showed a `Warning: N node(s) lost their embeddings to embedding-endpoint failures` line (#2790).
- **Do:** Re-run plain `npx gitnexus analyze` — no `--embeddings` flag needed. A retained `embeddingCheckpoint` in the index metadata forces embedding generation for exactly the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
- **Why:** A long analyze run against a flaky HTTP embedding endpoint tolerates bounded sub-batch failures instead of aborting the whole run: it deletes the affected nodes' embedding rows (so they hold zero rows, never a partial set) and records those nodes as pending in `embeddingCheckpoint`. `stats.embeddings` stays an honest, non-zero count of everything that did succeed, so this state never trips the "Embeddings vanished" Sign above — `embedding-checkpoint-pending` is the only reliable signal.
### MCP lists no repos
@@ -61,7 +68,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP.
- **Why:** Embedded DB expects single-process ownership.
- **Why:** Embedded DB expects single-process ownership.`@ladybugdb/core` 0.18.0 also reports this contention as `"Only one write transaction at a time is allowed in the system."` — our busy/lock retry matcher (`isDbBusyError` in `src/core/lbug/lbug-config.ts`) recognizes this exact string too, so it's auto-retried the same as any other lock error. If you see that exact message, it's the same "one writer at a time" issue above, not a new failure mode.
The field that decides whether an existing index can be reused changed in
`.gitnexus/gitnexus.json` (and in each `branches/<slug>/gitnexus.json`):
`schemaVersion?: number` has been removed and `schemaFingerprint?: string`
added. The new value is a 12-character digest of the graph DDL this build
creates, so it *describes* the schema an index's tables were actually built
from rather than asserting a number about it.
An absent fingerprint is treated as a mismatch, and that is the whole
backward-compatibility story: every index written by an earlier GitNexus
carries no fingerprint, so it is rebuilt exactly once.
### Do I need to migrate?
**No.** There is nothing to run, edit, or pass. The first `analyze` after
upgrading logs one line —
```
index schema changed (built by an unidentified GitNexus build, this build is <fingerprint>); forcing a full re-analyze so the database is recreated from the current schema.
```
— and then performs that full re-analyze itself. The same run stamps the
fingerprint, and every run after it takes the normal incremental path again.
### What happens on re-index?
One automatic full re-analyze, once per index. Nothing else changes; the
resulting graph is what the current build would have produced anyway.
The scope of that one-time cost is worth knowing before you hit it. It is
per **index**, not per machine or per repository — branch-scoped index slots
(#2106) each keep their own `gitnexus.json`, so every slot pays for itself
the first time it is analyzed after the upgrade. On a very large repository
a full re-analyze is substantial, not a blip; plan the first post-upgrade
run accordingly.
### Why a digest instead of a version number?
`schemaVersion` was hand-incremented, and it had to predict something a
number cannot know: whether the DDL an on-disk database was created from
matches this build's. It collided with `main` eight times, twice *exactly* —
and an exact clash was the quiet failure. Two builds stamp the same number
over different DDL, the strict `===` reuse gate reads the index as current,
the `CREATE … TABLE` statements are skipped as "already exists", and edges
whose endpoint pair the live database cannot persist are dropped. A wrong
graph, with no error anywhere.
A derived digest cannot fail that way: two builds agree exactly when their
DDL agrees, so concurrent branches never need renumbering and a mismatch is
always a real mismatch. The retired ladder's per-version rationale (v2
`BasicBlock.callees` through v35's generated relation cross-product) now
lives only in git history:
`git show 561f913a3:gitnexus/src/storage/repo-manager.ts`.
### What about rollback?
Downgrading to an older GitNexus is safe. The older binary looks for
`schemaVersion`, does not find one, treats the index as pre-versioning, and
forces its own full rebuild — the same one-time cost in the other direction,
never a stale or mismatched graph.
### What if I alternate between an old and a new binary?
Every switch forces a rebuild. The end-of-run metadata is written as a fresh
object literal rather than merged over the previous file, so a new build's
write drops `schemaVersion` and an old build's write drops
`schemaFingerprint` — neither field survives the other's run, and each binary
then finds its own gate unsatisfied. This hits anyone running a pinned
`npx gitnexus@<version>` alongside a local build, or an editor hook still on
an older release. It is a cost, not a correctness problem: each run rebuilds
against its own schema, and the graph it serves is correct for the binary
that produced it. Pin one version per index to avoid the churn.
**Important:** If you already had embeddings, **always** pass `--embeddings` on later analyzes, or they can be dropped. See `stats.embeddings` in `.gitnexus/meta.json`(0 means none).
**Important:** If you already had embeddings, a plain `npx gitnexus analyze`**preserves** them (Non-negotiable 5 in [GUARDRAILS.md](GUARDRAILS.md)) — pass `--embeddings` when you also want vectors generated for new or changed nodes, and `--drop-embeddings` only for a deliberate wipe. See `stats.embeddings` in `.gitnexus/gitnexus.json` (or its legacy `meta.json`mirror; 0 means none) — but that figure isn't always freshly measured: if a run's embedding-count query can't answer, it carries the previous run's number forward instead of writing a wrong zero. For a certified read, check `capabilities.vectorSearch.status` instead — it reads `unavailable` (never a stale count) whenever GitNexus can't vouch for the live vector index.
**Partial embedding index (analyze exits 0, but some nodes never got embedded):** A long run against a flaky embedding endpoint can finish successfully while a bounded number of sub-batches still fail. Affected nodes are dropped to zero rows (never left half-written) and recorded as a pending `embeddingCheckpoint`; `npx gitnexus status` then reports `incompleteReasons: ["embedding-checkpoint-pending"]`. Recovery is a plain:
```bash
npx gitnexus analyze
```
No `--embeddings` flag needed — a retained checkpoint forces embedding generation for the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
@@ -152,7 +160,9 @@ Analyze re-execs Node with a **large old-space heap** when needed (`analyze.ts`)
## LadybugDB / lock errors
Only one process should open a repo’s `.gitnexus/lbug` store at a time. If MCP and a second `analyze` run conflict, stop one process, then retry `analyze` or restart MCP.
Only one process should open a repo's `.gitnexus/lbug` store at a time. If MCP and a second `analyze` run conflict, stop one process, then retry `analyze` or restart MCP.
If the error text is `"Only one write transaction at a time is allowed in the system."` instead of a lock/busy message, it's the same underlying conflict — our retry matcher (`isDbBusyError` in `src/core/lbug/lbug-config.ts`) recognizes this exact string and auto-retries it. The fix if it still surfaces after retries is the same: stop the overlapping process.
@@ -16,18 +16,19 @@ Evaluate whether GitNexus code intelligence improves AI agent performance on rea
> **Recommended**: Use `native_augment` mode. It mirrors the Claude Code model — the agent gets both explicit GitNexus tools (fast bash commands) AND automatic enrichment of grep results with callers, callees, and execution flows. The agent decides when to use explicit tools vs rely on enriched search output.
**Models supported:**
**Models supported** (see `configs/models/` for the current list):
- Claude 3.5 Haiku, Claude Sonnet 4, Claude Opus 4
- MiniMax M1 2.5
- Claude Haiku 4.5, Claude Sonnet 4, Claude Opus 4
- MiniMax M1 2.5, MiniMax M2.5
- GLM 4.7, GLM 5
- DeepSeek
- Any model supported by litellm (add a YAML config)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.