Commit graph

1279 commits

Author SHA1 Message Date
Gergő Magyar
6088d2e309
chore: release v1.6.10 (#3064)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* chore: release v1.6.10

* fix(eval): derive the pinned runtime version from package.json

The containment suite mounts a GitNexus runtime built from this checkout and
asserts its version equals PINNED_GITNEXUS_VERSION, a constant hardcoded to
"1.6.9" when the harness landed in #2566. The first release after that lands
1.6.10 in gitnexus/package.json, the built runtime reports 1.6.10, and
`eval / containment (ubuntu)` fails on drift the release itself created.

Read the version from gitnexus/package.json instead. The check keeps its real
job -- proving the mounted runtime came from this checkout rather than a
published package -- without a copy that only ever drifts on release day.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 23:21:40 +01:00
DuduPhudu
0f793558ad
fix(group)!: stop group sync claiming matching it never did (#3020)
* fix(group)!: remove the matching cascade that was advertised but never built

`gitnexus group create` wrote `matching.bm25_threshold` and
`matching.embedding_threshold` into every generated group.yaml, and no matcher
ever read either one. That was not the whole of it — an entire feature surface
described a BM25/embedding cascade that does not exist:

- `matching.bm25_threshold` / `matching.embedding_threshold` — parsed, persisted,
  unread
- `detect.embedding_fallback` — defaulted and templated, unread
- `MatchType` declared `'bm25' | 'embedding'`; both variants unreachable
- `SyncOptions.skipEmbeddings` — declared in sync.ts and never read
- `gitnexus group sync --skip-embeddings` — accepted, threaded through
  GroupService, ignored
- CLI help in en and zh-CN promised "Exact + BM25 only (no embedding fallback)"
- the MCP `group_sync` schema exposed `skipEmbeddings`, described as
  "Exact + BM25 only (Demo PR: same as default exact path)"

`sync.ts` imports exactly `buildProviderIndex`, `runExactMatch` and
`runWildcardMatch`, and the printed cascade has one stage. An operator whose
links do not match reaches for those thresholds first, and turning either knob
changes nothing — config that silently does nothing is how people conclude a
feature is broken.

Evidence that the cascade should be deleted rather than implemented, from a real
backend/frontend pair: of 165 consumer contracts, 149 link exactly and 16 do not.
Nine of the sixteen are third-party APIs (Google OAuth, Apple public keys,
PostHog, image annotation) with no in-group provider by construction — similarity
matching cannot recover them, it can only invent false links. Two are verb
mismatches: the frontend calls `POST /links` and `GET /links/check-exists` while
the backend declares `GET /links` and eleven other `/links/*` routes but neither
of those, so a fuzzy path match would link a POST consumer to a GET provider. The
rest are path-extraction artifacts. Roughly none of the sixteen would be
correctly recovered, and several would be actively mis-linked.

BREAKING CHANGE: `gitnexus group sync --skip-embeddings` and the MCP `group_sync`
`skipEmbeddings` parameter are removed. Both were accepted and ignored, so no
behavior changes — but a script passing the flag now fails with `unknown option`
instead of being silently misled. Existing group.yaml files keep loading: the
removed keys are simply no longer part of the schema, and a regression test pins
that a legacy config carrying all three still parses.

Closes #3006

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

* fix(group)!: honour --exact-only, drop inert --allow-stale, report every matching stage

Addresses the review findings on #3020, all of which are the same defect the PR
itself is about: group-sync surface that describes behaviour the pipeline does
not have.

`exactOnly` was inert in exactly the way `skipEmbeddings` was — declared on
`SyncOptions`, threaded through the CLI and the MCP tool, and read by nothing —
and strictly worse, because the stage it promised to suppress DOES run and DOES
write `matchType:'wildcard'` links into contracts.json and the bridge, which
`group impact` and cross-repo `trace` then traverse. It is now honoured rather
than deleted: unlike the never-built BM25/embedding stages, the stage it names
exists, so the flag describes a real choice. The substituted result is
`{ matched: [], remaining: unmatched }`, not an empty result — `wildcard.remaining`
IS `SyncResult.unmatched`, so skipping the stage has to leave its input unmatched
rather than dropping it from the count an operator reads.

`allowStale` had no such stage to gate: `syncGroup` emits no stale warning at any
point (the `checkStaleness` call lives in `groupStatus`, a different path), so it
is removed under the same rationale as `skipEmbeddings`.

`group sync` now prints every matching stage instead of `exact` alone. The old
block printed a `Matching cascade:` header and counted only exact links while the
next line reported `result.crossLinks.length` — which also includes `manifest` and
`wildcard` — so for any group with those the two numbers disagreed with nothing on
screen explaining why. Counting is an exhaustive `Record<MatchType, number>`, so a
new MatchType fails the build here instead of going silently uncounted, and reads
through `?? 0` so a legacy registry carrying a removed matchType prints an honest
count rather than `NaN`.

Also: the MCP `group_sync` description no longer omits the wildcard stage that
always runs, `exactOnly`'s description no longer refers to a "cascade", and
bench/cross-repo-trace/verify.mjs no longer generates the removed threshold keys
into a fresh group.yaml.

Tests: `sync-exact-only.test.ts` pins both directions of the gate (mutation-verified:
removing the gate, or returning `remaining: []`, both go red). `group-tools.test.ts`
pins that the MCP schema dropped `skipEmbeddings` and kept `exactOnly`.
`group-cli.test.ts` pins that both removed flags are rejected, with `--exact-only`
as an accepted-flag control. `config-parser.test.ts` now pins that legacy keys are
PRESERVED (measured, not assumed) rather than only that parsing does not throw.

The type narrowing's fallout in test files is cleared: `tsc -p tsconfig.test.json`
is 987 errors at head against 987 measured on origin/main, with the two error sets
identical — zero net, zero new, zero masked.

Verification: `tsc --noEmit` exit 0; prettier clean; eslint 0 errors (2 warnings,
both pre-existing on base); 69 test files / 1169 tests green across
test/unit/group, test/integration/group, tools, cli-i18n and cli-index-help.

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

* fix(group): reject malformed and retired group_sync parameters (U1)

`GroupService.groupSync` read `exactOnly` off an untyped MCP payload with
`Boolean(params.exactOnly)`. While the flag was inert that coercion was
harmless; now that it gates the wildcard matching stage, the string "false" --
a routine shape for an LLM caller emitting JSON -- is truthy, so a caller that
asked to KEEP wildcard matching got it suppressed and a registry with fewer
cross-links persisted to disk. The opposite of the request, written down.

Validate instead of coercing, at the service boundary: the MCP SDK does not
enforce a tool's advertised inputSchema and `callTool` is reachable directly,
so this method is the real gate. The validator mirrors `validateImpactMode`'s
`{ value } | { error }` shape -- the established idiom for this boundary, and
the one groupSync's other guards already return through.

Also refuse `skipEmbeddings` and `allowStale` by name. The CLI rejects them
outright because commander errors on an unknown option; the MCP path accepted
and silently dropped them, so an agent working from a cached tool schema was
never told. Removing them took away discoverability, not acceptance.

Both guards run before the group is read off disk, so a rejected call performs
no work. Every test asserts the sync did NOT run -- an error string alone
cannot distinguish "refused" from "refused but synced anyway".

The tool description gains the validation note AFTER the registryOutcome
paragraph: `tools.test.ts` slices that description by ordinal position of the
'preserved' / 'superseded' / 'no-prior-registry' literals, so appending past
all three leaves those slices intact (verified, 44/44).

tsc clean; 1039/1039 group unit tests pass.

* fix(group): record the matching stages a sync was told to skip (U2)

An `--exact-only` sync wrote a contracts.json and bridge with fewer cross-links
and nothing recorded that the wildcard stage had been suppressed by request.
`group_impact` and cross-repo `trace` read that registry as authoritative, so a
narrowed graph was indistinguishable from a complete one -- and because
`group_sync` is MCP-exposed, one agent call durably narrowed the shared answer
for every later reader with no signal at all.

Add `suppressedMatchStages` to ContractRegistry and SyncResult, following the
`unreadableRepos` tri-state end to end: absent means a registry written before
the field existed, `[]` is the measurement "this run suppressed nothing", and a
populated list names the stages. The writer always emits it, because omitting
the empty case is what made "measured, none" unreachable for `unreadableRepos`.

Two properties that are easy to get backwards, and are why the split matters:

- SyncResult carries the marker on EVERY outcome. The sync genuinely did skip
  the stage whatever happened to the file afterwards, and the CLI summary (U3)
  renders from this rather than re-deriving it from the caller's options.
- The PERSISTED registry stamps it only on the `written` outcome. The preserve
  path re-writes `{ ...prior }`, so a carried-forward registry keeps the marker
  of the sync that actually produced its contracts instead of being relabelled
  with this run's request. That holds by construction: the registry literal
  carrying the field is only reachable on the written path.

`loadContractRegistryResilient` gains an explicit line, because it rebuilds the
envelope field by field with no spread of the parsed root -- a new on-disk field
is silently dropped unless named there.

Its reader is `recordedMatchStages`, not the existing `recordedRepoList`: that
one validates `string[]`, which is right for repo names and one notch too weak
here. This repo has already retired MatchType members ('bm25', 'embedding'), so
a stale value on disk is a real shape, and dropping non-members keeps an unknown
stage name from reaching a caller typed as a live one.

Surfaced on `group_contracts` and on `group_sync`'s own return -- deliberately
kept separate from the truncated/truncationReason/riskEpistemic triple. That
triple reports limits a run hit by accident, whose remedy is to fix the repo; a
suppressed stage was asked for, and its remedy is to re-sync without the flag.
Conflating them would tell an agent to retry something that returns identically.

tsc clean; 1043/1043 group unit tests pass.

* fix(group): name a skipped matching stage as skipped, and pin it (U3, U4)

Two facts were printing as the same line. `wildcard: 0 cross-links` meant both
"the stage ran and matched nothing" and "the stage never ran because you passed
--exact-only" -- the same conflation this summary block was introduced to remove
one line up, reintroduced by the flag that made the block necessary.

Render a suppressed stage as `skipped (--exact-only)`, driven by the sync's own
`suppressedMatchStages` rather than by `opts.exactOnly`. The renderer reports
what the sync did, not what the caller asked for, so it stays correct on the
outcomes where the run ended without writing a registry -- which is where the
summary is least legible and a re-derivation from the options would have been
wrong.

Also drops the `?? 0` fallback and the comment justifying it. The comment
claimed a legacy registry could carry a retired matchType into this loop. It
cannot: `syncGroup` returns a freshly computed `crossLinks` array on every
outcome, and even on the preserve path the prior links go to disk while the
fresh array is returned. The code was harmless; the stated reason was false, and
a comment that explains an unreachable path is worse than no comment.

U4 pins both halves through the CLI. A manifest fixture is sufficient: the stage
counts must sum to the total on the `Wrote contracts.json (…)` line, and the
skipped rendering does not need a stage to have matched anything, because
--exact-only records the suppression whatever the fixture holds. That is why
this coverage did not need indexed gRPC/Thrift fixture repos.

Verified by mutation, not assertion: removing the skipped-rendering branch turns
`names a stage it was told to skip as skipped` red and leaves the other 22
green. A control case pins the opposite direction -- the same group without the
flag still reports the stage as zero -- so `skipped` cannot be printed
unconditionally and pass.

U3 and U4 land together: the test has no value without the renderer, so one
commit keeps a revert clean. Both depend on U2, which introduced the field they
read.

tsc clean; 1066/1066 across the group unit and CLI integration suites.

* fix(group): make every description of --exact-only match what it does (U5)

Two descriptions this branch wrote or touched still misstated behavior.

The MCP `exactOnly` description carries "Manifest links still apply." The CLI
help and both locale strings, rewritten in the same commit, omit it -- so the
surface most operators read understated what still runs. Manifest cross-links
are computed before the gate and are genuinely unaffected by the flag, so the
caveat is the accurate half and the CLI now says it too.

The `group_sync` tool description opened with "extract HTTP contracts". That
clause was carried forward byte-identical while only the trailing cross-linking
half was rewritten, and it is wrong: the detect config has six non-HTTP
extraction toggles, and this branch's own new test fixture is Thrift.

`help-i18n.ts` is deliberately untouched. It maps an option to its translation
key and that key already exists; only the commander string and the two locale
values carry text, so a text-only change does not reach it.

The tool-description edit sits ahead of the registryOutcome paragraph, leaving
the relative order of the 'preserved' / 'superseded' / 'no-prior-registry'
literals intact -- `tools.test.ts` slices that description by their positions.

tsc clean; 64/64 across the locale-parity, help-registration, tool-schema and
group-tool suites.

* fix(group)!: remove max_candidates_per_step and shared_libs (U6)

Both keys were declared, defaulted, written into every generated group.yaml,
and read by nothing -- the same three-station dead surface this PR removed for
bm25_threshold, embedding_threshold and detect.embedding_fallback. Every other
DetectConfig field gates a real extractor in sync.ts; shared_libs gates nothing,
because 'lib' contracts come only from the operator-declared manifest extractor.
MatchingConfig reaches matching.ts solely through buildNoisyContractFilter,
which reads exclude_links_paths and exclude_links_param_only_paths and nothing
else.

Existing group.yaml files keep loading and keep their keys. parseGroupConfig
spreads the raw block over its defaults, so a key the schema no longer knows
about survives into the returned config -- which matters because `group add` and
`group remove` round-trip the operator's file through loadGroupConfig ->
yaml.dump -> write, so anything the parser dropped would be deleted from their
checked-in file. The legacy-config test now pins both keys in the same cast form
as its three siblings, and the fixture carries shared_libs so that assertion is
not vacuous.

Two stations that are easy to miss and are swept here:

- gitnexus/bench/cross-repo-trace/verify.mjs GENERATES a fresh group.yaml. It is
  not a preserve-path fixture, so "leave YAML fixtures alone" does not cover it;
  the repo has two generators and both are updated. It is a .mjs file outside
  tsconfig's include, so no type gate would have caught it.
- config-parser.test.ts asserted the removed default at runtime, which vitest
  DOES run. That assertion is gone from the defaults case (the key no longer has
  a default) and re-formed as a preserve assertion in the legacy case.

Verification gate, corrected: "zero net new errors against origin/main" would
have measured the whole branch delta and been red through no fault of this
commit. Measured instead against the branch tip immediately before it --
tsc -p tsconfig.test.json --noEmit reports 989 before and 989 after. Twenty-four
typed-literal sites across ten test files, none of them CI-gated, plus the two
runtime sites above which are.

Note the deliberate side effect: removing a key from the defaults also stops the
group add round-trip from re-adding it to a file that never carried it. Nothing
in src reads either key, so no behavior changes.

BREAKING CHANGE: `matching.max_candidates_per_step` and `detect.shared_libs` are
no longer part of the group.yaml schema and are no longer written into generated
templates. Existing files carrying them continue to parse and retain them.

src tsc clean; 1189/1189 across the group unit, group integration, locale-parity,
help-registration and tool-schema suites.

* docs(group): map PR #3020 review findings to the commits that close them

Retitles the ledger to hold one section per reviewed PR and adds #3020's ten
findings. Two things are stated rather than claimed away: `abda0d041` closes
three findings because they are one code block plus the test that pins it, and
the suppressed-stage marker is a coupled set because the renderer consumes the
field the earlier commit introduces.

Also records what is NOT closed here -- the PR description's false claim about
`max_candidates_per_step` lives outside this branch.

* refactor(group): apply simplify-pass findings

Four cleanup agents (reuse, simplification, efficiency, altitude) over this
run's diff. Efficiency was clean. The rest found five things worth fixing, two
of which were real gaps rather than style.

`recordedMatchStages` filtered unknown values instead of rejecting the list.
That inverted the tri-state on the one field built to prevent exactly this
conflation: a stale `['bm25']` -- the scenario its own comment cites as the
motivation -- survived as `[]`, which on this field MEANS "measured, nothing was
suppressed". A confident clean answer manufactured from a value we could not
read. Now all-or-nothing, matching `recordedRepoList`.

`gitnexus group contracts` showed nothing after an exact-only sync. The human
renderer destructures a fixed field list and gates its incompleteness warning on
`truncated`, so the marker reached the MCP payload and the JSON output but not
the listing an operator actually reads. It now warns, separately from the
`truncated` warning, because the remedies differ: one says fix the repo, this
one says re-run without the flag.

`verbose` was still coerced with `Boolean()` in the same call whose tool
description this branch changed to promise "PARAMETERS ARE VALIDATED". Validated
now, and added to the tool schema -- it was read by the backend and advertised
nowhere.

Reuse: the thrift wildcard-matchable pair existed twice, near-verbatim, in
`sync-exact-only` and `registry-suppressed-stages`. Both now call a shared
`makeWildcardPair` fixture, so the shape `runWildcardMatch` fires on is defined
once.

Simplification: dropped a `Set` built per sync over a list that only ever holds
zero or one entries; iterating `Object.keys(STAGE_COUNTS) as MatchType[]` also
keeps the exhaustiveness the `Record` was built for, which `Object.entries` had
discarded.

Deliberately not done, with reasons: a schema-driven unknown-parameter layer at
the MCP chokepoint (five parameters are read by backends and declared in no
schema, so a strict layer rejects working calls today, and it cannot produce the
"was removed" message finding 3 is about); folding the marker into
`GROUP_IMPACT_TRUNCATION_REASONS` (reverses a recorded plan decision and the
bridge scope is an open question for the maintainer); a per-stage suppression
cause `Record` (no second suppressor exists -- speculative); collapsing the six
`detect` extractor branches into a table (a real generalization, but a refactor
outside this diff); and converging an untouched pre-existing CLI test onto the
new manifest helper (it captures a value the helper does not return, so the
change risks more than the duplication costs).

tsc clean; eslint 0 errors (1 pre-existing warning); 1085/1085.

* docs(group): remove REVIEW-FINDINGS-MAP.md

Removes the findings-to-commits ledger from the source tree.

Note for anyone reading this in history: the file was introduced on main by
#3012 and carried that PR's findings map; this branch had appended a #3020
section. Deleting it drops both. #3012's content is recoverable with
`git show 2c0fb7753:gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md`.

* fix(group): stop cross-repo impact and trace claiming a narrowed graph is complete

Closes the half of the suppressed-stage finding that was deferred. The reviewers
were right that deferring it was the weak point: the motivating harm was named
as `group_impact` and cross-repo `trace` traversing a graph missing real edges,
and those were exactly the surfaces left uncovered.

The deferral rested on an assumption that does not hold. "It is already blind to
this, so we do not make it worse" is false: `--exact-only` was inert before this
PR, so the number of narrowed registries in the world goes from zero to nonzero
exactly when this lands. The blindness was harmless only while narrowing was
impossible. And silence there is not neutral -- `cross-impact.ts` documents
`truncated: false` as an affirmative completeness claim, so those tools were
about to start asserting a complete answer over a knowingly short graph.

`suppressedMatchStages` now rides the bridge the same way `unreadableRepos`
does: persisted in meta.json (no BRIDGE_SCHEMA_VERSION bump -- meta fields have
this precedent), read back all-or-nothing, and carried across the preserve path
through `refreshPreservedBridgeMeta`'s diagnostics so a preserved bridge keeps
the marker of the sync that actually built it.

`crossRepoCompleteness` folds it in, which is what makes this one change reach
all three surfaces -- that function is by design the ONE computation behind the
truncation triple. Precedence is explicit: an unreadable or unaccounted repo
outranks a suppressed stage, because it is the more serious structural gap and
its remedy has to be the one reported.

`'suppressed-stage'` is a new member of the truncation-reason union rather than
a reuse of `'incomplete-sync'`. The earlier decision not to touch that union was
about not conflating remedies -- telling an agent to repair a repo that read
fine, for a narrowing it requested. A distinct member preserves that reasoning
while letting the answer stop claiming completeness, which is what reusing the
existing member would have destroyed.

The union's guard test did its job: adding a member failed the check that every
reason is explained on the agent-facing surface, so the impact tool description
now names this one and its distinct remedy (re-run WITHOUT the flag; nothing
failed to read).

`group status` and its CLI renderer surface it too, on the populated case only
-- absent is a registry predating the field and empty is the ordinary clean
sync; neither earns a line.

Deliberately still not done, and why: a repo-wide unknown-parameter layer for
every MCP tool. Five parameters are read by backends and declared in no schema
(`subgroupExact`, `unmatchedOnly`, `showClusters`, `showProcesses`, and
`verbose` until this branch declared it), and three tools dispatch with no
schema entry at all, so a strict layer rejects working calls until each is
reconciled. That reconciliation is the work; the layer is the cheap part. It
also cannot produce the "was removed and is no longer accepted" message the
retired-parameter guard exists to give.

tsc clean; eslint 0 errors (2 pre-existing warnings); 1159/1159 across the group
unit, group integration and tool-schema suites.

* fix(group): make the suppressed-stage signal actually reach its readers

Applies the mechanical findings from the code review of the previous commit.
That commit claimed cross-repo impact and trace stop reporting a narrowed graph
as complete. Trace did; impact did not, and two operator-facing messages said
something false. Four reviewers plus the cross-model pass converged on the same
two defects, and the untested seams were exactly where they were.

`runGroupImpact` recomputed the truncation reason and hardcoded its fallback, so
it could never emit 'suppressed-stage' -- the value the previous commit added to
the union and documented in the tool description. Every narrowed-but-readable
bridge was reported as 'incomplete-sync', telling the caller to repair a repo
that read fine. It now propagates the bridge's own reason, as cross-trace.ts
already did.

The preserve path stamped this run's request onto an older bridge. When no repo
can be read the database and registry are kept from an earlier sync, so
meta.json has to keep describing that sync; instead `{ ...existing,
...diagnostics }` overwrote its marker, leaving contracts.json, meta.json and
bridge.lbug describing three different runs. Currently masked by unreadable-repo
precedence, one loosened condition from a live wrong verdict.

`group contracts` printed "the last sync did not record which repos it could
read" after any exact-only sync: truncated was set with both repo lists empty,
so the message fell through to the wrong branch. It is now gated on the reason,
not the flag. `group impact` likewise blamed the local walk for a floor the flag
caused.

The tri-state reader is now defined once, in the leaf module whose own comment
says it exists so this exact duplication cannot recur -- it had been copied into
bridge-db.ts within one commit of that comment being true.

Both agent-facing descriptions now name the field. The previous commit added it
to three payloads and documented it on none.

Tests cover what shipped green: the preserve path for both artifacts (verified
by mutation -- reintroducing the stamp turns exactly one test red), and the
reason's REACHABILITY. The existing guard only asserted each reason is
described, which is why a documented-but-unemittable value passed it.

Also corrects a comment that said the marker is deliberately not folded into the
truncation triple. True when written; false one commit later.

tsc clean; eslint 0 errors; 1163/1163 across the group unit, group integration
and tool-schema suites.

* fix(group): drop verbose from the MCP surface, fail a superseded bridge closed

Two maintainer-directed findings from the review.

verbose is removed from the group_sync MCP schema and from GroupService, and
kept on the CLI. The parameter never did what either description claimed: the
gates emit workspace-dependency discovery stats and one aggregate manifest line,
not "each cross-link". Worse, they emit them through the server's logger, which
an MCP caller cannot read at all -- so advertising it introduced precisely the
kind of knob this PR exists to delete, in the PR that deletes them. SyncOptions
keeps the field and the CLI keeps --verbose, because a CLI user really can see
that output; its help now says "Show additional sync diagnostics", which is
what it shows. It was added to the MCP schema earlier in this same PR, so there
is no published compatibility burden in taking it back out. A caller that still
sends it is ignored rather than refused: it was never a documented parameter,
and the retired-name guard is reserved for ones this tool actually withdrew.

The second fixes a split-brain the completeness work made materially worse. When
contracts.json commits and the bridge write then fails, the previous database
stays in place describing an EARLIER sync. Until now it kept vouching for
itself, so group_impact could traverse the superseded graph and call its answer
complete while group_contracts reported the advanced registry -- two public
surfaces making contradictory epistemic claims out of one sync. That was
tolerable when the disagreement was about counts. It is not, now that
suppressed-stage makes completeness a correctness property.

markBridgeProvenanceUnknown withdraws the claim without touching the database:
bridgeMetaMatchesFile already gives provenanceUnknown highest precedence and
refuses to vouch for the pair, so cross-repo answers downgrade to a floor until
a sync succeeds. Deliberately not a re-stamp -- the metadata still describes the
database it was written for, and saying otherwise recreates the mis-pairing the
preserve path avoids. Deliberately not a delete -- the old graph is still worth
having as a floor, it just stops being called complete. Best-effort, because it
runs inside a failure handler and must not replace a reported bridge failure
with an unrelated one; the warning now states which of the two happened.

Shared registry+bridge generation identity is the architectural fix and is
deliberately NOT attempted here. This is the PR-sized containment.

Verified by mutation, both directions: neutering the withdrawal turns the new
test red, and a control pins that a healthy sync does not withdraw provenance --
otherwise every successful run would report its own answers as a floor.

tsc clean; eslint 0 errors; 1185/1185.

* refactor(group): apply simplify-pass findings

Four cleanup agents over the last five commits. Efficiency was clean and traced
why: the containment helper is failure-path only, the reason ternary sits after
the fan-out loop, and the tri-state readers run once per artifact read.

The strongest finding was one the diff itself proved. `refreshPreservedBridgeMeta`
enforced the never-persisted rule for `repoListsUnreadable` and
`pairedWithDatabase` with two deletes in its own body, under a comment noting it
was the only code that read metadata and wrote it back. That held exactly as
long as there was one such caller. `markBridgeProvenanceUnknown` made it two,
and inherited nothing. The strip now lives in `writeBridgeMeta`, so every writer
gets it and no future one can forget; `pairedWithDatabase` is the dangerous one,
because persisted it tells every later reader the pair was verified when nothing
verified it.

`group impact` still printed "fan-out stopped early" whenever `truncatedRepos`
was non-empty — but the bridge's incomplete repos are unioned into that list
even when zero crossings were attempted, so a structural gap was reported as a
runtime one, with the only working remedy omitted. That is the same false-cause
shape the contract listing was re-gated for one commit ago, left live one
command over because the new reason was bolted in front of the old branch rather
than replacing the thing it branched on. Now keyed on the reason.

The `?? 'incomplete-sync'` arm in cross-impact was unreachable: reaching it
needs `truncated` true with all three of its inputs false, which
`truncated = runtimeTruncated || bridge.truncated` forbids. Flattened.

Also: a `recordedMatchStages` insert had split `crossRepoCompleteness` from its
own JSDoc; one new test was a strict subset of another; and the bridge-failure
warning interleaved concatenation with a mid-chain ternary.

The new invariant assertion was caught being VACUOUS by mutation before it
shipped — seeded with a valid repo list, `readBridgeMeta` never sets the
reader-only field, so it passed with or without the strip. The fixture now seeds
an unreadable list, and both it and the pre-existing assertion go red when the
strip is removed.

Deliberately skipped, with reasons: a shared `firstTruncated` fold over
`TruncationFields` (the right altitude, but it changes cross-trace's return
assembly and that surface separately documents a 'timeout' rung it cannot emit —
a behavior change, not a cleanup); a reason-keyed `explainFloor` helper across
all four CLI renderers (real, but a four-site refactor); narrowing the persisted
stage vocabulary to a `SuppressibleStage` alias (would be undone by the very
extension the field was modelled as a list to allow); moving `verbose` to
`logger.debug` and deleting `SyncOptions.verbose` (the maintainer explicitly
directed keeping both); and merging the two tri-state readers behind a predicate
(they are adjacent in one file now, so a tightening applies to both by
inspection — the duplication the comment warned about was cross-FILE).

tsc clean; eslint 0 errors; 1164/1164.

* fix(group): address gitnexus-check findings

Seven bot comments across two review rounds; five distinct after dedup. Four
were valid and are fixed, two were already resolved by later commits the bot
had not seen.

The validator could throw from its own error path. `JSON.stringify` is the right
renderer there — it is what distinguishes the string "false" from the boolean,
which is the entire point of the message — but it throws on a BigInt and on a
cyclic object. So a validator promising a structured `{ error }` instead
rejected, and `callTool` is reachable directly, so neither input is
hypothetical. Guarded, keeping the distinction and falling back for the shapes
that cannot serialize.

An unreadable suppression record read as "nothing was suppressed".
`recordedMatchStages` is all-or-nothing by design, so garbage collapses to
`undefined` — and the consumer treated `undefined` as an empty measurement,
throwing that safety away and reporting a registry it could not parse as
complete. Present-but-unreadable now forces the floor, while absent stays
legitimate: a registry written before the field existed has no opinion and
should not be dragged to a floor for it.

Two test-side findings, both real and both invisible to CI because
`tsconfig.json` is src-only. Three `mock.calls[0][1]` accesses did not
type-check against a zero-arg mock, and four assertions read `truncationReason`
/ `riskEpistemic` straight off `CrossRepoCompleteness`, which is a discriminated
union carrying them on one arm. Also removed a `StoredContract` import that went
dead when those fixtures moved to `makeWildcardPair`.

Worth recording: U6 set a test-config gate at 989 errors and later commits
walked it to 994 without anyone re-measuring — the bot caught three of the five.
Now 987, below the original baseline.

Already fixed, not by this commit: the preserve-path stamp the bot flagged
against 6ceac8b1f (fixed in 1fbe0dc6b) and the displaced completeness JSDoc
(fixed in 2d2ef8c47).

Both behavior fixes are mutation-verified: restoring the unguarded stringify
turns the new unserializable-value test red, and a control pins that an absent
record still reads as complete so the fails-closed change cannot pass by forcing
every registry to a floor.

src tsc clean; eslint clean; 1167/1167.

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-27 18:27:32 +01:00
DuduPhudu
fb49613a4d
fix(ingestion): ignore emitted Next.js build output, and delete the inert public/build entry (#3018)
* fix(ingestion): ignore emitted Next.js build output, and restore the dead public/build entry

`DEFAULT_IGNORE_LIST` contained `.next` — the build CACHE — but not `_next`, the
emitted OUTPUT, which are different directories. A Capacitor/Cordova shell copies
a built Next.js bundle to `<platform>/app/src/main/assets/public/_next/static/`,
where no path segment hits the list, so the walker indexed the bundle as source.
On a real mobile-wrapped Next.js app that was 256 minified chunk files, and every
`Route` node the repo produced pointed at a webpack chunk rather than at source.

The filename heuristics did not catch them either: they match `.bundle.`,
`.chunk.`, `.generated.` and `.d.ts`, while Next.js emits hashed names like
`6862-9d1cdcb99f169a06.js`.

Separately, `'public/build'` had been sitting in `DEFAULT_IGNORE_LIST` matching
nothing at all. That set is tested one path SEGMENT at a time, and is also read
by `isHardcodedIgnoredDirectory(name)`, which receives a bare directory name —
so a slash-containing member can never compare equal to anything. Rather than
delete the entry and lose its intent, multi-segment paths now live in
`DEFAULT_IGNORED_PATH_FRAGMENTS` and are matched against the whole path, so
Remix / Laravel Mix asset output is ignored as originally intended.

A guard test pins the invariant that made the dead entry possible: no member of
the name set may contain a slash.

Measured against a production Capacitor-wrapped Next.js app (1558 JS/TS files on
disk): 256 newly ignored, none of them under `src/`, and zero files that were
previously ignored become indexed.

Closes #3007

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

* fix(ingestion): drop the inert public/build machinery, discriminate _next by segment, ignore _next on the web upload path

Addresses the review findings on #3018.

Remove DEFAULT_IGNORED_PATH_FRAGMENTS, hasIgnoredPathFragment and its
shouldIgnorePath branch. The mechanism was correct but unreachable: all four
of its match forms put a `/` or end-of-string on both sides of `build`, so a
fragment match strictly implies `build` is a whole segment, which the
per-segment DEFAULT_IGNORE_LIST loop already catches one branch earlier.
Measured over 768,420 generated paths: 65,506 fragment matches, 0 of them
decisive, 0 implication violations. `'public/build'` really was an inert
member of the name set, but its paths were never unignored — bare `'build'`
covered them on both sides — so the entry is deleted rather than relocated,
which is the other option #3007 offered. The slash-free guard test stays; it
is what stops the next slash-bearing entry from dying the same way.

Add negative cases pinning that `_next` matches as a whole path segment. The
previous suite could not tell a segment rule from a substring rule: replacing
the entry with `normalizedPath.includes('_next')` passed all five tests, while
eating `src/_nextgen/index.ts`.

Rename the public/build test to what it actually pins — that deleting the
inert entry changed no behavior — since it is green on both sides by design.

Add `_next` to the web upload filter's EXCLUDED_DIRS. That list is the live
browser ingestion path (RepoAnalyzer -> filterRepoFiles -> /api/analyze/upload)
and had `.next` but not `_next`, so a Capacitor-wrapped Next.js app uploaded
its entire minified tree against the server's 20000-file / 250MB caps for
files the analyzer then discards.

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

* test(ignore-service): make the single-component set guards able to fail

The slash-free guard added for #3007 could not fail. It selected entry lines
with startsWith("'") and read only the first quoted token per line, so
'public/build' could return as a backtick string, behind an inline block
comment, as a second entry on an existing line, or via .add() and every test
stayed green. Prettier and eslint miss the backtick and inline-comment forms
too, so CI did not catch them either.

U1: remove the duplicate '.serverless' entry so the set can be pinned to one
exact number. A Set discarded it, so no ignore behaviour changes.

U2/U3: replace the line-based parser with a shared single-pass scanner in
test/helpers/ignore-set-source.ts, and extend the guard from DEFAULT_IGNORE_LIST
to IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES and IGNORED_EXTENSIONS, which share
the same single-component match contract.

The scanner tracks string and comment state together because neither can be
removed first: the ignore-list comments quote paths and carry an apostrophe, so
matching literals before stripping comments yields phantom slash-bearing
entries; and a glob string containing a comment-open sequence makes regex
comment-stripping swallow the closing bracket. Only a single pass is correct in
both directions.

Counts are pinned exactly rather than floored — a floor cannot protect a
two-member set and hides a partial parse. Shapes a source parser cannot resolve
(spread, interpolation, concatenation, later .add) now throw instead of quietly
reporting fewer members, and the parsed names are cross-checked against
isHardcodedIgnoredDirectory so parser drift fails without exporting the set.

Verified by mutation: all six fail-open spellings now turn the suite red;
187 tests pass, tsc clean.

* test(ignore-service): pin that _next prunes the directory, not just its files

Every measured benefit of ignoring _next comes from never enumerating the
bundle tree, and no file list can observe that: anything under _next is
rejected whether the walk pruned the directory or descended and rejected each
file. childrenIgnored is the only observation that separates them.

The existing build-output tests all call shouldIgnorePath, the leaf predicate,
so a refactor moving _next to a shouldIgnorePath-only rule would keep them green
while silently restoring the full walk. These assertions close that.

Also pins that _next matches as a whole segment (_nextgen and my_next are still
walked), and that the `!_next/` negation recovers the directory at any depth —
the bare form is the one that works, since `!_next/**` alone never gets tested:
childrenIgnored prunes the directory before any descendant pattern is reached.

Placed in the .gitnexusignore-negation describe block, which owns mkPath and the
tmpdir fixture and is registered in scripts/cross-platform-tests.ts.

Verified by mutation: disabling only the pruning branch in childrenIgnored leaves
the build-output suite at 26/26 green and turns these assertions red.

* test(ignore-service): guard the twin build-output ignore lists against drift

_next now lives in two lists in two packages — the analyzer's DEFAULT_IGNORE_LIST
and the browser upload filter's EXCLUDED_DIRS — with nothing tying them
together. This is the seventh twin-list pair in this repo; the header of
receiver-twin-list-drift.test.ts records that the previous ones each shipped a
bug when one side moved.

Containment runs web -> CLI only, and that is the load-bearing direction: the
browser filter decides what the server ever sees, and it reads no
.gitnexusignore, so a name it drops that the analyzer would have indexed is
silent source loss with no recovery. The reverse is not an error — the analyzer
prunes far more aggressively than an upload needs to.

.gitnexus is the one exemption and has a mechanism: the walker passes
dot: false to glob, so it never enumerates dot-directories. Asserted in both
directions so re-adding it to the CLI list or dropping it from the web list
both fail.

Both sides are source-parsed through the shared helper. DEFAULT_IGNORE_LIST is
module-private, and no test in this package imports across the package boundary
— every cross-package precedent reads source instead.

Also corrects the documentation this PR's comments got wrong: the guard test is
cited by path rather than as "below", the unreproducible per-repo percentage is
gone, the reason _next is deliberately unanchored is recorded next to the entry
(no <web-root>/_next form matches a root-level _next/static/…), and the upload
filter now states that it consults no repository ignore rules — so unlike the
CLI, a negation cannot recover what it drops.

Verified by mutation: a web-only addition and a CLI removal each turn the guard
red. 194 targeted tests pass; tsc clean in both packages.

* refactor(test): read the ignore sets with the TypeScript parser, not a hand-rolled scanner

The guards read ignore-service.ts as source because the sets are module-private.
The first pass hand-rolled a character scanner to do it, and the repo already
vendors the right tool: ts.createSourceFile, used this way in literal-collectors,
query-determinism-guard, cli-index-help and group/sync-partial-extraction.

The scanner had two silent gaps a real parser does not have:

- It rejected `${` by substring, but template literals were consumed whole, so
  that branch could never fire and an interpolated member was accepted as a
  literal — the exact under-report the file refused to allow.
- It took the first `[` after the marker, which on a type-annotated declaration
  (`readonly string[] = ...`) is the annotation's empty pair. It returned [] with
  no throw, which would make every assertion in a suite vacuously true. This is
  the hazard receiver-twin-list-drift.test.ts documents having hit.

Reading the declaration node removes both, along with the comment-vs-string
ordering problem that motivated the scanner: a parser cannot mistake a comment
for a string or a glob's `/*` for a comment-open.

Also drops the four pinned exact counts. They were a ratchet — these sets are
edited by unrelated PRs, each of which would have failed a count assertion about
nothing it touched — and with a real parser the partial-parse hazard they existed
to catch cannot happen silently: a member that is not a plain string literal
throws.

Markers collapse to set names, and the duplicated path-resolution boilerplate
moves into the helper the two suites already share.

Net 187 deletions against 123 insertions. Verified by mutation: backtick,
inline comment, same-line, double-quote, duplicate, interpolation, spread and
runtime .add() are all caught; a type-annotated declaration now reads correctly
instead of returning empty. 194 tests pass, tsc clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-27 13:29:05 +01:00
DuduPhudu
48106d3c00
fix(ingestion): index NestJS decorator routes so api_impact and route_map stop reporting live endpoints as non-existent (#3017) 2026-08-27 08:35:36 +01:00
azizur100389
ac68f5254c
fix(ingestion): preserve object handler identity (#3046)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(ingestion): preserve object handler identity

* fix(impact): cap object callable expansion
2026-08-26 15:44:33 +01:00
azizur100389
09322d2d89
fix(storage): load VECTOR only when needed (#3045)
* fix(storage): load VECTOR only when needed

* test(storage): verify VECTOR reopen lifecycle

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-26 12:57:56 +00:00
azizur100389
88df18b829
fix(ingestion): discover nested source directories (#3043) 2026-08-26 12:24:39 +00:00
azizur100389
9d4f029001
fix(impact): mark Convex caller results incomplete (#3044)
* fix(impact): mark Convex caller results incomplete

* fix(storage): align Convex Const persistence
2026-08-26 12:54:00 +01:00
DuduPhudu
2c0fb7753c
fix(group): stop reporting what could not be measured as a measurement of zero (#3012)
* fix: surface unreadable group indexes and escape raw NUL bytes in source

Two independent diagnostics failures, both of which turn a real error into a
confident, benign-looking answer.

**Unreadable member repos (#3011).** `syncGroup` wrapped `initLbug` plus all
contract extraction for each member in a bare `catch {}` that pushed the repo
onto `missingRepos` and discarded the error. A LadybugDB storage-version
mismatch therefore surfaced as "repo not found", `group sync` printed
`0 contracts, 0 cross-links` and exited 0, and the existing contracts.json was
overwritten with an empty registry. The two states need different answers from
the operator — a missing repo must be indexed, an unreadable one is usually
version skew or a lock — so they are now separate:

- the caught error is logged with the repo, group path and lbug path
- `unreadableRepos` is tracked alongside `missingRepos` on `SyncResult`,
  persisted (optionally, so older registries still parse) on `ContractRegistry`,
  and threaded through `GroupService` sync/status
- `group sync` reports both before the cascade counts, since an unread repo is
  the likely explanation for a small or empty count
- `group status` reports unreadable repos separately; calling them "missing"
  actively misdescribed them
- when EVERY configured repo fails to open, the write is skipped: an extraction
  that read nothing is not evidence the group has no contracts, and replacing a
  good registry with an empty one loses data while reporting success

**Raw NUL bytes (#3010).** `sync.ts` and `free-call-fallback.ts` each used a NUL
as a join delimiter, written as a literal 0x00 instead of `\0`. Identical at
runtime, but it makes the file test as binary: `file(1)` reports `data`, ugrep
returns empty with exit 1 — indistinguishable from "no match", with no message —
and BSD grep replaces matching lines with "Binary file ... matches". A search
that should hit comes back as a confident "not present". Both now use the escape,
and a unit test fails on any raw control byte in src/ so it cannot silently
return.

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

* test(hygiene): guard every tracked source file against a raw NUL, not just src/

The guard added with the NUL escapes only scanned gitnexus/src for .ts/.tsx.
Neither prior recurrence of this defect in this repo was in that scope:
b620773b1 was gitnexus/bench/cpp-qualified-ns/measure.mjs and 38d737bb5 was a
fixture under gitnexus/test. A guard that cannot see where the bug has actually
landed twice is not a guard.

Drive the file list from `git ls-files` at the repository root over
.ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts — 2483 files instead of 828 — and split
the byte class, which is the part that matters:

  - 0x00 is a hard failure repo-wide. It is the byte git's binary heuristic
    keys on, so it is the one that costs a file its diff (and, on the base side
    of a PR, its inline-comment anchors and its three-way merge).
  - The wider C0 class stays scoped to gitnexus/src. A repo-wide scan finds
    exactly one hit, test/unit/logger.test.ts:146, and that 0x1b is a
    legitimate ANSI-escape fixture that is the subject of the test. Widening
    this half would go red on day one.

Read Buffers and scan bytes instead of decoding each file to latin1, through a
bounded read pool: 1.5 s for 2483 files, against 8-21 s previously for 828.

Add a negative fixture — a planted 0x00 and 0x1b run through the same scanning
helper — so a future refactor of the collector cannot leave a permanently green
guard, plus an assertion that the collected set still reaches bench/, test/ and
.mjs, which goes red if the scope is ever narrowed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): report a cross-repo impact built from an incomplete bridge as truncated

When a sync cannot read a member repo, that repo's contracts and every
cross-link touching them are simply absent from bridge.lbug. Nothing in the
impact walk could notice: the only incompleteness channel on a
GroupImpactResult is truncationFields(), which is driven by fan-out state
(truncatedRepos / localPartial / fanoutTimedOut), and a repo missing from the
bridge sets none of them.

So `group impact` on a symbol whose one downstream consumer lives in an
unreadable repo returned `{ cross: [], truncated: false }` — "complete: nothing
in another repo depends on this". That is a wrong answer, not an empty one, for
a tool an agent uses to license a delete or a rename.

BridgeMeta now records unreadableRepos alongside missingRepos, writeBridge
persists it when non-empty, and runGroupImpact folds a non-empty
unreadableRepos ∪ missingRepos into truncated / riskEpistemic: 'lower-bound',
naming the repos in truncatedRepos.

The reason is a new 'incomplete-sync' rather than the existing 'partial'
because the remedy differs: 'timeout' and 'partial' are runtime limits the same
query can clear on a retry, while this one clears only when `gitnexus group
sync` succeeds. Runtime limits still take precedence when both apply, since
those are what the caller can act on immediately.

The risk VALUE is never clamped down — mergeRisk is monotone in the traversed
crossing count, so an incomplete bridge can only under-report. Marking the floor
is what makes that legible.

Both shape changes are additive and optional, so a bridge written before this
still reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): say truthfully what a sync did to contracts.json

Review follow-ups to the unreadable-repo diagnostics. Every item below is a
place where the code still answered a question it could not answer.

1. The CLI announced a write it did not perform. `group sync` printed "Wrote
   contracts.json (0 contracts, 0 cross-links)" unconditionally, including on
   the path that deliberately left the file alone. SyncResult now carries
   registryOutcome ('written' | 'preserved' | 'not-attempted'), the CLI prints
   from it, and group_sync returns it so an agent that calls group_sync then
   group_contracts can tell why the counts disagree.

2. Refusing to write anything on total failure threw away the diagnostic
   describing the run that just happened. `group status` reads contracts.json
   from disk, so the operator who saw the sync fail and ran status to find out
   why read the PREVIOUS sync's file: no unreadable list, an old lastSync, a
   healthy-looking group — or worse, the previous run's unreadable list
   presented as this one's. The skip is now targeted: contracts, crossLinks,
   repoSnapshots and generatedAt carry forward verbatim, only missingRepos and
   unreadableRepos are refreshed. generatedAt stays put because it dates the
   contracts, which are still the previous run's. With no prior file, or an
   unparseable one, nothing is written at all.

3. Per-repo extraction is now all-or-nothing. Extractors run in sequence and
   any one can throw; appending each one's results straight to autoContracts
   meant a repo whose HTTP extractor succeeded and whose gRPC extractor then
   failed contributed a partial set to the registry, while the same run told
   the operator that repo's "contracts are omitted from this sync".

4. readRegistry gains an opt-in strict mode, and syncGroup uses it. The lenient
   `catch { return []; }` converted "I could not read the registry" into "no
   repo is registered": every configured repo then resolved to MISSING, the
   total-failure guard stayed off (it needs a load error), and a good
   contracts.json was replaced by an empty one at exit 0. That is an unreadable
   condition reported as missing, one frame above the code this branch fixes.
   The default stays lenient for the other nine callers; ENOENT stays lenient
   in both modes.

5. Absence of unreadableRepos keeps meaning "not recorded". The loader spreads
   the key in only when present instead of defaulting to [], and getStatus
   passes undefined through, so a legacy registry no longer reads as "the last
   sync found none unreadable". getStatus also gates both list fields on
   Array.isArray: it reads through readContractRegistry, which is a bare
   JSON.parse cast, so a corrupt string in either slot used to reach
   cli/group.ts and die in .join(', ') — the command whose job is explaining an
   unreadable thing, crashing on one.

6. Smaller, same theme: the per-repo warning passes the Error itself rather
   than err.message, so pino keeps the stack; the total-failure warning no
   longer fires on a dry run, where it described a file the call was never
   going to touch and which need not exist; the status table's MISSING legend
   stops re-conflating the two states; the sync warning drops its
   GITNEXUS_LOG_LEVEL=warn hint, which would only have suppressed output (pino
   emits warn at the default info level, so the reason was already printed);
   and the group_sync tool description and its idempotency comment now describe
   what the tool actually does.

Testing. The original four cases could not see the change they were named
after. Mutation testing showed two survivors: dropping the ===
configuredRepoCount conjunct, which turns "every repo failed" into "any repo
failed" and would silently freeze contracts.json for a group where one of five
repos is skewed; and deleting both logger.warn calls, the stated purpose of the
change. Both survived because every case configured exactly one repo and
nothing read the log. There is now a two-repo case running the real per-repo
loop, an all-missing case, a _captureLogger assertion on the level 40 record,
partial-extraction cases, and strict-read cases. All five mutants are killed,
each by exactly one test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): tighten the registry list gates and stop naming a truncation reason on complete results

Three follow-ups from the check bot's pass over the previous commits.

1. `detect.includes` was missing from both group-sync test fixtures, so they did
   not satisfy the `GroupConfig` they claim to construct. It went unnoticed
   because `tsconfig.json` is src-only; `tsconfig.test.json` reports it. The
   older of the two fixtures carried the gap in from the original commit.

2. `runGroupImpact` named its truncation reason in a variable computed before
   the truncated check, so on a fully complete result the variable read
   'incomplete-sync'. `truncationFields` discards the reason when `truncated` is
   false, so nothing surfaced — but a value that is wrong whenever it is unused
   is a trap for the next reader. Computed inline at the one call site that can
   consult it, which is also how the neighbouring call sites are written.

3. `Array.isArray` alone let a corrupt registry through. `['app/backend']` and
   `[{repo:'x'}]` are both arrays, and only the second reaches `cli/group.ts`'s
   `.join(', ')` — as `[object Object]`, a measurement the operator can read but
   cannot act on. Both readers now go through one `recordedRepoList` helper that
   requires an array of strings; anything else is "not recorded", the same as
   absent. Two more rows in the corrupt-value table cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): keep readRegistry's signature, and stop describing unreadableRepos as index-only

Two items from the check bot's blocking pass.

1. `readRegistry` gained an optional `opts` parameter last commit. That is
   source-compatible — every zero-argument call still compiles and behaves
   identically — but the contract check treats any parameter-list change on a
   symbol with outside callers as a break, and it is right that the safest
   version of this change touches that signature not at all. The strict read is
   now its own export, `readRegistryStrict()`, over a shared private body.
   `readRegistry()` is byte-identical to what it was; `syncGroup` is the only
   caller of the strict one, and the mode is legible at the call site instead of
   hiding in an options bag.

2. `unreadableRepos` is described everywhere as "the index could not be opened".
   That was accurate before this branch and is not now: making per-repo
   extraction all-or-nothing means a repo also lands there when an extractor
   throws partway with the index open fine. The two belong in one bucket
   because the consequence is one thing — none of that repo's contracts are in
   this sync — but the docs have to say so, or an operator reads `unreadableRepos`
   as a storage diagnosis and goes looking at LadybugDB for an extractor bug.
   Corrected on `ContractRegistry`, `BridgeMeta`, `SyncResult`, the `group_sync`
   tool description, and the `group sync` console output, which now says
   "Could not extract contracts from" rather than "Could not read the index for".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(cli): stop calling an unreadable registry an old one in group status

`getStatus` reports `unreadableRepos` as `undefined` for two different reasons:
the field is genuinely absent, or it held something that was not a list of repo
paths and the shape gate declined to guess. The status line named only the
first — "registry predates this field" — so a corrupt value read as a merely
old registry.

That is the same shape of wrong answer this command exists to stop giving: a
condition we could not read, presented as a benign one we understand. The line
now names both, and asks for a sync either way, which is the fix in both cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): close the three fail-open paths left on the safety boundaries

Follow-ups from the re-review of 31c2b6e81. All three of its blocking findings
reproduce; each is a place where unknown state still resolved to a confident
benign answer, which is the one thing this branch exists to stop.

1. Strict registry reading accepted malformed rows. `[{}]` is a JSON array, so
   it passed the shape check: every configured repo then failed to resolve into
   `missingRepos`, none produced a load ERROR, the total-failure guard stayed
   off, and a good contracts.json was replaced with an empty one at exit 0 —
   the same fail-open the strict mode was added to close, one level down from
   the file to the rows inside it. Strict mode now requires `name`, `path` and
   `storagePath` on every row and rejects the WHOLE registry if any row fails.
   Rejecting rather than filtering is the point: dropping bad rows would report
   the repos they name as unregistered, which is the same wrong answer again.
   `indexedAt` / `lastCommit` are deliberately not required — callers already
   default them, so demanding them would trade a fail-open for a fail-shut on a
   legitimate legacy registry.

2. A failed bridge publication could make impact look complete. `writeBridge`
   swaps `bridge.lbug` and writes `meta.json` as two operations, and this branch
   made that meta load-bearing: `runGroupImpact` derives its truncation fields
   from it. A sync interrupted between the two steps therefore left a NEW bridge
   beside the PREVIOUS sync's metadata, and an impact query read that as
   "complete". Fixed from both ends. The write path removes the old meta before
   the swap, so the window leaves metadata ABSENT rather than stale. The read
   path treats absent-or-unparseable meta (`version: 0`) as unknown provenance
   and reports a floor, which also covers the caught `writeBridge` failure in
   `syncGroup`. Over-reporting truncation on a bridge that is actually fine is
   the safe direction, and the next successful sync clears it.

3. `preserved` was returned when there was nothing to preserve. On a group's
   first all-unreadable sync the outcome was set before the prior registry was
   read, so the CLI told an operator "the contracts from the previous sync are
   preserved" about a file that had never existed. Split out as
   `no-prior-registry`, with its own console message.

Also widened the NUL guard to the source languages it claimed to cover. The
commit that added it said "every tracked source file" while the collector
stopped at the JS/TS family, so a raw NUL in tracked Python, Java, Go, Rust,
C/C++, Ruby, PHP, Kotlin, Swift, C# or shell would still have turned those files
binary unnoticed. Measured before widening: 2315 non-JS tracked source files,
zero hits, so this was an unforced gap rather than a tradeoff. A planted `.py`
fixture and a collector-coverage assertion keep it honest.

Every fix is mutation-verified: reverting each one individually turns its own
tests red (3, 2, 2, 1 and 1 failures respectively), and all pass together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): record the empty unreadable measurement instead of dropping it

Both writers omitted `unreadableRepos` when it was empty, which made the
tri-state this branch introduced unreachable in its most common case.

`ContractRegistry.unreadableRepos` is optional on the TYPE so a registry written
before the field existed still parses, and absence there means "not recorded".
But a sync that read every repo successfully HAS measured it, and `[]` is that
measurement. Dropping it collapsed "measured, none" into "never recorded", so
after every clean sync `gitnexus group status` printed

    Last sync unreadable repos: not recorded
    (the registry predates this field, or its value could not be read)
    Re-run `gitnexus group sync` to record it.

about the sync that had just succeeded. The distinction is only worth having if
the writer commits to it, so both `contracts.json` and the bridge's `meta.json`
now record the field whenever the sync supplied it, `[]` included.

The check bot found this on the bridge writer and attributed the consequence to
`group status`. The consequence is real but it is not the bridge's: `getStatus`
reads `contracts.json` and never touches `BridgeMeta`, whose only consumer is
`runGroupImpact` — where absent and empty are already equivalent. So the
user-visible half was in the registry writer, one file over from where it was
reported, and both are fixed.

Also fills in `DetectConfig.includes` (and `workspace_deps`) across the group
test fixtures that predate those fields. These are pre-existing on main and are
a no-op at runtime — `undefined` and `false` are both falsy at the gate — but
they are the same defect the bot flagged as an error in the new fixtures, and
`tsconfig.test.json` reported eleven of them. That file is not in CI, which is
why they survived; the group tree is now clean of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* test(group): stop two bridge-metadata tests claiming coverage they do not have

Both were named for the swap window and neither injects a swap failure.

"drops the previous meta.json before swapping the database file" runs two
successful writeBridge calls. Its assertions hold with the removal in either
position, because writeBridge overwrites meta.json at the end regardless — so
it cannot pin the ordering it is named for. Renamed to what it does cover, the
successful-rebuild replacement, with the limit stated in the body rather than
left for the next reader to discover.

"leaves NO meta.json when the swap fails partway" removes the file by hand
after a successful write, so it exercises readBridgeMeta's missing-file
contract, not writeBridge. That contract is worth pinning on its own — version 0
is the signal runGroupImpact fails closed on — so the test stays, under a name
that says so.

The ordering itself is pinned in bridge-meta-swap-window.test.ts, which mocks
retryRename to throw on the bridge.lbug swap and asserts the previous sync's
metadata cannot survive it. Both renamed tests now point there, so the coverage
is findable from the place someone would look for it.

No production code changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): pair bridge metadata to its database instead of deleting it

The previous commit closed the swap/metadata window by removing meta.json before
the database swap, so the window would fail to "absent" rather than "stale". That
was the wrong trade, and it destroyed recoverable state.

The old database's move to `.bak` sits inside a catch that swallows failures, not
just "no existing db". When that rename fails — a held read-only handle does this
on Windows, and a long-lived MCP server holds one — the failure is swallowed, the
following `tmp -> bridge.lbug` throws, and writeBridge exits with the OLD database
still in place and perfectly valid. Its metadata was already deleted. Cross-repo
impact then answers "we cannot say" for that group until some future sync
succeeds, and if the cause is a held handle or permissions there is no such sync.
A working feature, destroyed permanently to close a narrow window.

Deleting also only chose which way the window failed; it never closed it.

So destroy nothing, and make the pair self-describing instead: writeBridge stamps
the database's size and mtime into the metadata it writes, and
`bridgeMetaMatchesFile` lets a reader ask whether the two still belong together.
`runGroupImpact` treats a mismatch the same as absent metadata — provenance
unknown, report a floor. A metadata file left over from an earlier sync cannot
match a freshly renamed database, and a sync that fails before the swap leaves a
matching pair untouched. Metadata written before the stamp existed is
unverifiable rather than stale, and is accepted: failing those closed would mark
every pre-existing bridge incomplete, trading a narrow window for a repo-wide
regression.

The swap-window test now distinguishes the two failure shapes, because they want
different answers. When every rename fails the old database never moves, so the
surviving metadata still matches it and impact keeps answering from it. When only
the final rename fails the old database has already reached `.bak` and no
database is in place, so the metadata correctly matches nothing — and
`ensureBridgeReady` fails loudly on the absent file, which beats a silent floor.

Mutation-verified: reinstating the delete, neutering the pairing check, and
dropping the stamp each turn 2, 3 and 3 tests red respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* chore: keep TypeScript diffs readable after a NUL leaves the tree

Git decides a pair is binary when EITHER blob carries a NUL, and it only
sniffs the first 8000 bytes. `gitnexus/src/core/group/sync.ts` carried one
at byte 5132 on main. This branch removes it, but the base side still has
it, so the file renders as "Binary files differ" in the pull request: no
hunks, no inline comments, and no three-way merge — however clean the head
side is. A head-side byte guard cannot detect that, by construction, since
it only ever sees the working tree.

Setting the `diff` attribute stops the heuristic from hiding the change.
It does not mark the files binary, does not imply `text`, and does not
change how blobs are stored, normalized, or checked out — the root
`* text=auto eol=lf` still governs all of that. It affects diff generation
and rendering only.

Locally this turns the branch's own sync.ts diff from `Bin 17612 -> 25346
bytes` into 154 insertions and 16 deletions.

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

* fix(group): answer "provenance unknown" for malformed bridge metadata

`readBridgeMeta` guarded the read and the parse but not the SHAPE of what it
parsed, then cast the result. `runGroupImpact` spread both repo lists straight
into a Set, so a `meta.json` whose `missingRepos` held an object threw a
TypeError out of the entire cross-repo query — and threw it from a point after
`ensureBridgeReady` had taken the bridge lease and before the `try` whose
`finally` releases it, so every such query also leaked a refcount the cached
handle could never get back. A malformed file is a reason to answer "we cannot
say", never a reason to crash the question.

The shape gate now lives where the metadata is read, mirroring the one
`service.ts` already applies to the registry's copies of these same two lists.
Each list is judged independently: a garbage `unreadableRepos` no longer
discards a `missingRepos` that was genuinely measured. A list that was present
but unusable is dropped rather than normalized to `[]`, because an unreadable
value is not a measurement of zero — the new reader-side `repoListsUnreadable`
carries that distinction, and `runGroupImpact` folds it into the same
provenance-unknown verdict it already reaches for `version: 0` and for
metadata that does not pair with the database beside it.

A root that is not an object is closed too. `JSON.parse` succeeds on `null`,
`7` and `[]`; the first threw on `.version`, and the other two read `undefined`
and sailed through the version gate as if the bridge had been vouched for.

Both provenance values moved inside the protected region and are initialized
fail-closed, so a future throw between the lease and the walk releases rather
than wedges.

`repoListsUnreadable` is reader-side only: the sole `writeBridgeMeta` call site
builds a fresh literal, so nothing persists it and no schema version moves.

Mutation-verified: reverting the shape gate alone turns 4 tests red — the three
malformed-list scenarios plus the handle-release regression.

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

* fix(storage): reject registry rows that cannot identify a repo

The strict read's row gate gave `typeof v === 'string'`, and `typeof '' ===
'string'`. A row whose `name` was blank therefore passed as resolvable, then
matched nothing in `defaultResolveHandle` — putting every configured repo in
`missingRepos` and presenting an unusable registry as a clean answer about an
empty one. That is the same unreadable-as-missing fail-open the strict mode
exists to close, one level further in. A blank `storagePath` is worse than
useless: it joins to a relative `lbug` under the current directory, so the sync
opens an index that is not the repo's.

Both now have to be non-blank after trimming. `path` stays at the bare string
check, on the same reasoning that already exempts `indexedAt`/`lastCommit`:
require only what resolution depends on to IDENTIFY the repo. This gate rejects
the whole registry and the registry is machine-wide, so a field tightened past
what identification needs would let one blank value in one row break every
group sync on the machine — including groups whose repos all resolve. A blank
`path` still yields a working handle; `defaultResolveHandle` does read it, but
only for the pool id and `repoPath`, neither of which decides whether the row
names a repo.

The error now says what is actually wrong instead of naming three fields that
are all present.

Mutation-verified in both directions: dropping the trim turns the three
rejection tests red, and applying the wider fix that was considered and
declined — tightening `path` too — turns exactly the counter-case red, so that
test genuinely pins the narrow reading rather than passing either way.

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

* fix(group): bound the per-repo contract staging append

`autoContracts.push(...repoContracts)` passes every staged contract as a
separate argument, and the engine caps how many arguments one call may take.
That cap is a function of the host's available stack, so it is a different
number on every machine — this one accepts a 125k-element spread and dies at
150k.

The spread itself is not new; what it carries is. Before staging, this line
appended a single extractor's output as it came back. Staging made it carry the
whole repo's, which is enough for a large repo to raise `RangeError: Maximum
call stack size exceeded` on the one line whose job is to commit work that just
succeeded. The throw lands in the catch below, so the sync reports a repo whose
extractors all ran cleanly as one whose index could not be read — a crash
wearing the costume of a diagnostic.

A bounded loop replaces it: the count a repo can stage is now bounded by memory
rather than by how much stack the process happened to get.

The guard is structural, not size-based, and deliberately so. A "make the
fixture big enough to crash" test passes against unfixed code on any host with
a larger stack, which is exactly the guarantee a regression gate cannot give
up. It walks the AST and locates the region by role — the `const` staging
buffer typed `StoredContract[]`, then the extractor `try` that is a direct
statement of the block declaring it — so renaming either identifier keeps it
pointed at the same code. `.apply()` is rejected alongside spread, being the
same hazard in different syntax.

Direct statements only, because `syncGroup` wraps this whole section in its own
try/finally for the lease sweep, and that ancestor reads the buffer too.
Matching any enclosing `try` pulls in the entire function body — including the
two windowed-manifest spreads, which are bounded by the window size and are not
what this fixes.

Mutation-verified in both directions: restoring the spread turns the gate red
naming that line alone; deleting a manifest-window spread, and separately
adding a third one, both leave it green.

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

* fix(group): keep unreadable repos out of manifest contracts too

Per-repo staging closed one door: a repo whose extractor threw contributes
nothing through the direct path. Deferred manifest resolution was a second
door, still open. It derives its known-repo set from the resolved-handle map,
which kept an entry for a repo the same run had already declared unreadable —
so the sync re-opened that index and resolved symbols against a database it had
just told the operator it could not read.

Deleting the handle in the catch stops the re-open, but it does not satisfy
R2 on its own: `ManifestExtractor` resolves both endpoints of a link and emits
a contract for each, and for an endpoint with no executor that contract is
still emitted with a synthetic UID. The registry ended up naming a repo the
same run reported unreadable.

So the emitted output is filtered by ENDPOINT, not by link. Dropping the whole
link would delete the healthy partner's contract as well — a repo losing its
own output because a neighbour's index would not open, which is wider than the
requirement and destroys good data to suppress bad. A cross-link is different:
it asserts something about a pair, so if either end is unreadable there is
nothing left to anchor it to, and a half-anchored link is exactly the
confident-about-what-it-could-not-read answer the registry must not give.

Deleting the handle also changed what the operator gets told, so the warning is
split. An unreadable repo IS configured; letting it fall into the "references
repos not in config.repos" branch states something false and sends the reader
to edit group.yaml for a problem only re-indexing fixes. It now gets its own
message naming what was actually omitted.

Mutation-verified four ways: reverting the endpoint filter turns three
scenarios red; the over-broad whole-link variant turns the healthy-partner
scenario red and nothing else; removing the handle delete turns the
no-re-open scenario red; and reverting the warning split turns the operator-
message scenario red. Every assertion reads the written contracts.json rather
than the in-memory result.

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

* refactor(group): keep readBridgeMeta's signature stable across the shape gate

The shape gate landed by widening the return type to a reader-only
`ReadBridgeMeta extends BridgeMeta`. That is source-compatible — a covariant
return, one added optional field, every existing caller unaffected, typecheck
and suite clean — but the contract check reads it as a changed signature with a
caller left behind, and blocks the merge on it. This branch already hit the
same wall on `readRegistry` and settled it the same way: leave the signature
alone and make the difference legible some other way.

So the flag moves onto `BridgeMeta` itself as an optional, documented,
never-persisted field, and `readBridgeMeta` goes back to the exact signature
its callers already compile against.

That is the better shape here anyway. The reader-only subtype would have split
the validation two ways: `openBridgeDbReadOnly` and `bridgeExists` both gate on
`meta.version`, and the normalization that comes with the gate is what stops a
`version: null` in a hand-edited meta.json from reading as `undefined` and
sailing through `version > 0` as though the bridge had been vouched for. One
type keeps all three callers behind the same guard.

Nothing persists the flag: `writeBridgeMeta`'s only caller builds a fresh
literal, so it cannot round-trip to disk.

No behavior change — pure type restructuring. 927 tests pass, typecheck clean.

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

* fix(group): stop treating a half-written bridge stamp as a verified match

`bridgeMetaMatchesFile` joined its two `undefined` checks with `||`, so
metadata carrying a size and no mtime — or the reverse — returned `true`, the
same answer it gives a fully verified pair.

A stamp is a PAIR. Both halves absent is the legacy shape: metadata written
before stamping existed, which cannot be verified either way and is accepted
deliberately, because failing it closed would mark every pre-existing bridge
incomplete until re-synced. Exactly one half present is not that. Something
wrote a stamp and did not finish, which is precisely the condition stamping was
added to detect — so the check handed back "verified" for the one shape that
most deserves suspicion, and a cross-repo impact query built on it would report
a confident answer about a database its metadata cannot vouch for.

The two states are now separated: neither half present accepts, exactly one
rejects as provenance-unknown, both compare against the file as before.

Found by the repository's own contract check, not by the plan.

Mutation-verified: restoring the `||` form turns both half-stamp cases red
while the legacy and fully-stamped controls stay green, so the pair genuinely
pins the distinction rather than passing either way.

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

* fix(group): pair unstamped bridge metadata by write order, before any open

Unstamped metadata was waved through: `bridgeMetaMatchesFile` returned "matches"
for any pair with no stamp to check, so the stale-meta-beside-a-new-database
window stayed open for every bridge written before stamping existed, and
`runGroupImpact` spent that metadata's completeness as fact.

`writeBridge` renames the database into place and writes the metadata after, so
`meta.mtime >= db.mtime` holds for any pair written together — including by
builds that predate the stamp. A database strictly newer than the metadata
beside it can only come from a swap whose metadata write did not land. That is
the fallback now. It is a heuristic on write order, not proof of provenance, and
it is wrong in two directions: a stale metadata file touched after the swap
still reads as paired, and a pair whose clock stepped backwards between the two
writes reads as unpaired. Both are recorded at the code; the second is the safe
direction. Equality counts as paired, or a coarse-granularity filesystem would
reject every legacy bridge for a reason that is about the filesystem.

The verdict is now taken in `ensureBridgeReady` BEFORE the database is opened,
and carried on the metadata rather than recomputed afterwards.

That ordering is load-bearing, not tidiness. Impact and trace both open the
bridge and only then ask about provenance, so on any platform or LadybugDB
build where a read-only open advances the file's mtime, every pre-stamp bridge
would report provenance-unknown from its first query onward — the exact
repo-wide regression this rule was chosen to avoid, arriving as a silent
downgrade rather than an error. It does not happen on Linux, which was measured.
It cannot be measured on Windows: pinning it by really opening the database
needs an in-process write→read reopen of the same bridge.lbug, which is a
documented limitation there. Rather than ship a Windows-skipped test and leave
the assumption unverified on the platform whose file semantics are most likely
to differ, the check moved ahead of the open so no platform has to be trusted.

The new guard forces the hostile case on every platform: the open is stubbed to
advance the database's mtime, and the verdict must still be "paired". It is
registered in the cross-platform list so the Windows and macOS shards run it,
and it has a control so it cannot pass vacuously.

Two existing fixtures mocked `readBridgeMeta` to return a stamped-era version
while never writing a meta.json — a state production cannot reach, since a
non-zero version can only come from a file that exists. They now write the
metadata their own mock claims to have read, rather than the helper being
loosened to accept metadata it cannot stat.

Mutation-verified twice: reverting the write-order branch turns both rejection
cases red while all four legacy-accept cases stay green, and moving the pairing
call back after the open turns the ordering guard red on its own.

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

* refactor(group): compute cross-repo completeness in one place

Three surfaces can return a partial cross-repo answer — impact, trace, and the
contract listing — and each decided for itself whether it was complete. Impact
carried the structured triple; trace said it in prose, if at all. An agent
reading a not-found trace had no machine-readable way to tell "there is no
path" from "there may be a path in a repo this sync could not read", which is
the difference between an answer and a floor.

`crossRepoCompleteness` is now the one computation, and its input deliberately
does not name where any of it came from. `BridgeMeta` is not in the signature
and must not be: `groupContracts` answers the same question from contracts.json
and never opens a bridge, so `version`, `repoListsUnreadable` and
`pairedWithDatabase` do not exist on that path. Each caller derives its own
`provenanceUnknown` — the bridge callers through `bridgeProvenanceUnknown`,
which stays separate for exactly that reason — and passes the boolean in.

Scope arrives as a predicate rather than a repo list or a subgroup, so
narrowing a query's scope stays a change to one argument at the call site.

The trace results now carry `truncated` / `truncationReason` / `riskEpistemic`
like impact does. `notes` is untouched; it remains an addition to the machine
channel, never the channel.

One correction to the approach as written: it said to pass the trace's two
endpoint repos as the predicate, but a destination trace declares no `to`. It
asks where a call lands, so any member may hold the answer — and an unreadable
provider repo is precisely how "no outgoing ContractLink leaves this repo"
becomes a wrong answer rather than an empty one. Filtering that path to the
`from` repo would have reintroduced the bug this unit exists to close, so it
passes every repo and a test pins it.

Two pre-existing paths become consistent with the vocabulary as a result: a
crossing-capped result now reports `truncationReason: 'partial'` alongside the
`truncated` flag it already set, and the destination path's `ambiguous` returns
now report the cap its `ok` and `not_found` siblings already reported. Both are
additive — no field is removed, and no `truncated` flips from true to false.

`truncationFields` returns a discriminated union now, so `truncationReason`
reads without a fallback on the branch where it cannot be absent.

Mutation-verified: reverting the provenance fold alone — one line in the shared
helper — turns 8 tests red across both surfaces, 2 new trace scenarios and 6
existing impact ones, which is the point of there being one helper.

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

* fix(group): narrow the incomplete-repo set to the query's declared scope

A subgroup-scoped impact query was marked a lower bound by repos it had
explicitly excluded. The fan-out already drops every neighbour outside the
subgroup, so those repos could not have contributed a crossing to the answer —
and a completeness marker that fires on results it does not describe is how a
caller learns to ignore the marker.

The scope is the query's DECLARED one, not the one the walk reached. An
incomplete repo's contracts are absent from the bridge by definition, so it is
never in the traversed set; filtering on what was traversed would empty the
intersection on every query and silently restore the fail-open this channel
exists to close.

Declared scope here is the subgroup PLUS the query's own repo, which the
approach did not account for. The walk starts from that repo's contracts in the
bridge, so when it is the repo the sync could not read there are no crossings
to find under any scope — and a subgroup excluding it would have turned that
vacuum into a confident "nothing depends on this", for a tool an agent uses to
license a delete. That case reported a floor before this change, so narrowing to
the subgroup alone would have been a regression. The union only ever widens the
in-scope set, so it cannot re-mark a repo the query excluded.

Membership goes through the existing `repoInSubgroup` in both clauses, `exact`
for the origin equality, rather than growing a second notion of what it means
for a repo path to be in scope.

Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1 — at depth 2 an out-of-scope
repo can sit between two in-scope ones — and that constraint is recorded at the
intersection.

Unscoped queries are byte-for-byte unchanged: `repoInSubgroup` answers true for
an absent subgroup, so the intersection is the whole set.

Mutation-verified: restoring the unfiltered predicate turns exactly the two
scoped cases red while the unscoped control and both in-scope guards stay green.

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

* fix(group): keep the preserved registry and the bridge from disagreeing

A total-failure sync refreshed contracts.json's diagnostic lists and left
meta.json alone. But meta.json, not contracts.json, is where runGroupImpact
reads completeness from — so the registry said "this sync could not read
app/backend" while a cross-repo query answered `{ cross: [], truncated: false }`.
Two surfaces describing the same run, one of them wrong, and the wrong one is
the machine-readable one an agent uses to license a delete.

The preserve path now refreshes the same two fields in the metadata. The
database stays untouched: it still holds the contracts being preserved, and
rebuilding it here would be the one write that could lose them.

Refreshing metadata is not free, though, and the obvious version of it is a
fail-open. The rewrite moves meta.json's mtime to now while bridge.lbug's stays
old, so an unstamped pair whose database is NEWER than its metadata — the shape
the write-order rule exists to reject — would come out of a preserve sync
passing the check. Writing "no stamp" does not help; the write-order comparison
is exactly what the moved mtime defeats. The verdict has to be recorded in the
metadata, because the refresh cannot avoid moving the mtime.

So `provenanceUnknown` is persisted whenever the existing pair does not already
check out, the existing stamp fields are carried through verbatim rather than
dropped, and `bridgeMetaMatchesFile` rejects the marker ahead of both the stamp
and the write-order heuristic. A pair that already matched is re-stamped
instead, which also upgrades a legacy unstamped-but-paired bridge to an exact
stamp. No preserve run can increase the number of pairs that pass the check.
The marker self-clears: `writeBridge` builds fresh metadata and never sets it.

`BridgeMeta` carries two reader-side fields documented as never persisted, and
this is the first code in the repo that reads metadata and writes it back. Both
are stripped explicitly before every write. `pairedWithDatabase` is the
dangerous one — persisted, it would tell every future reader the pair had been
verified — and a test seeds both on disk to pin that neither survives.

The write is not wrapped in a catch, unlike writeBridge on the success path.
There contracts.json is canonical and already written, so a stale bridge is a
recoverable degradation; here the write IS the guard against a confident wrong
answer, and swallowing its failure would reinstate the fail-open it closes.
`writeContractRegistry` above is unguarded into the same directory for the same
reason.

A group with neither file writes nothing: `readBridgeMeta` already answers
`version: 0` for an absent file, so a written one would say what the absence
already says while inventing state for a bridge that has never existed.

Mutation-verified three ways: dropping the marker write turns 6 red including
both laundering scenarios; moving the marker check below the stamp branches
turns the unstamped-laundering case red; removing the field stripping turns the
never-persisted test red. Each restored byte-exactly and re-verified.

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

* fix(group): report group_contracts' completeness in the shared vocabulary

`group_contracts` returned contracts and cross-links and said nothing about
whether that listing was the whole story. An agent reading it after a sync that
could not open half the group got a confident-looking list with no way to tell
it was a floor — the same fail-open the impact path already closed, on a surface
that had no channel for the answer at all.

It now returns the registry's two diagnostic lists and the structured triple,
folded through the same helper the impact and trace surfaces use, so the three
cannot drift. The helper takes no `BridgeMeta` precisely so this path — which
reads contracts.json and never opens a bridge — can share it.

The three registry states stay distinguishable, which is the point:
  - key absent: the registry predates the field and has no opinion about which
    indexes opened, so the key is omitted rather than invented as `[]`, and the
    listing reports a floor. It cannot say which repos the sync failed to read,
    so it cannot claim to be complete.
  - key present and empty: measured, clean, not truncated.
  - key present and populated: the repos, and a floor.

`incompleteRepos` is dropped on this surface alone: both lists it derives from
are returned verbatim beside it, and a third name for the same repos is drift
waiting to happen.

The import is lazy, matching `groupImpact` and `groupTrace` in this same class.
`cross-impact.js` statically pulls the native LadybugDB binding through
`bridge-db.js`, and `service.ts` is loaded by every `gitnexus group` subcommand
including ones that touch no database.

One fix inside the same file that this unit forced: the registry loader gated
`missingRepos` with a bare `Array.isArray`, which admits `[{repo:'x'}]`. That
was inert while nothing read the list, but this change both returns it and folds
it into the completeness answer — so an unreadable value would have been printed
as a repo name and would have flipped `truncated` on garbage. It now uses the
same `recordedRepoList` gate `group status` already applies to the same field.
`missingRepos` has always been required, so unlike `unreadableRepos` it has no
"not recorded" state to preserve and an unreadable value degrades to empty.

Mutation-verified: reverting the fold alone turns 14 tests red and leaves the
control — the contract and cross-link payload this tool has always returned —
green.

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

* fix(cli): stop dropping group contracts' completeness fields on the way out

`group contracts --json` destructured `{ contracts, crossLinks }` from the
service payload and rebuilt an object from just those two. Everything else the
service returned was discarded on the way to stdout — so the completeness
fields the MCP tool now carries were invisible at the CLI, and the two surfaces
disagreed about the same registry.

It prints the payload whole now. A field added to the service reaches `--json`
without a matching edit here, which is the point: the re-serialized subset was
a second place that had to be remembered, and it was not.

The human-readable path gains the same signal in words. A listing built from a
sync that could not read part of the group shows counts that are a floor, not a
census, and it named neither fact. It now says so and names the repos when the
registry recorded them — and says the sync did not record which repos it could
read when it did not, because a listing that cannot say what it is missing is
still incomplete.

Mutation-verified: restoring the re-serialized subset turns the `--json` case
and the control red.

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

* fix(group): tell a missing registry entry apart from an unreadable one

`group status` printed MISSING for both "this repo has no row in the registry"
and "the registry itself could not be read", so an operator whose registry.json
was corrupt was told every repo was unregistered — and sent to re-register them
instead of to the one file that was actually broken.

The two are now separate. `missing` keeps its old meaning and still flags every
unusable repo, so an older consumer is unaffected; `unresolvable` is additive,
always present, and carries the reason that produced it.

This is the one caller that has to make that distinction, so it takes the
strict global-registry read. `readRegistry`'s `catch { return [] }` collapses a
malformed registry into an empty one, which is indistinguishable from a genuine
absence and is exactly what produced the wrong label. The cost is accepted
knowingly and recorded at the call site: the strict read rejects the whole
registry when any row fails to identify a repo, so one malformed row renders
every member unresolvable — including members whose own rows are fine. That is
the honest verdict, and it is reported as an unresolved state rather than a
clean one.

Choosing between the two labels needs to know whether a row exists at all,
which `registryIdentifies` answers by mirroring the two tiers the resolver
matches a bare group-config value on — registry name, case-insensitively, and
repo path. It deliberately stops short of the hashed-id and partial-name tiers:
those exist to be generous about what an operator typed, while this only picks
a label, and a looser match would relabel a genuine registry miss as an
unresolvable row — the same conflation this change removes, pointed the other
way.

The plan's third failure mode — a row that resolves but whose storage path
cannot be opened — turns out to be unreachable: `loadMeta` returns null on
every error and `checkStaleness` catches everything, so nothing after
`resolveRepo` inside the try can throw. The reachable per-repo case is
`resolveRepo` itself throwing, as it does for two registered clones sharing a
name, and that is what the tests drive end to end through the real CLI. The
code still handles the plan's case correctly if those helpers ever start
throwing.

Mutation-verified: reverting the split turns 6 unit and 2 CLI cases red while
both controls — a genuine miss, and a healthy group — stay green.

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

* fix(cli): say what the preserve path actually does to contracts.json

The sync summary announced "Did NOT write contracts.json" on the branch that
writes it. The preserve path rewrites the file — keeping the previous sync's
contracts and cross-links, replacing only the two diagnostic lists — so an
operator who checked the mtime and found it moved was told the opposite of what
had happened, on the command this PR exists to make legible.

It now says the previous contracts were kept and names what changed.

The no-prior-registry branch is narrowed for the same reason. It claimed
nothing at all was written, and that is no longer true either: this path still
records the run against an existing bridge's metadata. The claim is now scoped
to contracts.json, which is the file it can actually speak for.

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

* fix(group): stop the total-failure log promising a preservation that did not happen

The warning fired before the prior registry was read, so it could only ever
promise one of the two things that might be true — and it promised the wrong
one to every group that has never synced: "keeping the contracts from the
previous sync" about a file that does not exist. The console line for that same
run, driven by `registryOutcome`, said the opposite.

It now lives inside the branch, after the read, with one message per outcome
chosen at the point the outcome is decided. The log and the console cannot
disagree, because the same fact selects both.

Both messages keep the warn level and the two repo lists.

Mutation-verified: reverting the split turns the no-prior-registry case red
while the preserved case — whose claim was already true — stays green. The
dry-run test's log filter was also widened to the sentence both messages share,
or the new wording would have made that assertion match nothing and pass
regardless.

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

* fix(group): make the bridge-failure warning describe what the code guarantees

The warning after a failed `writeBridge` promised that cross-repo impact would
report `truncated` until a sync succeeded. Nothing on that path produces that
signal.

The swap is the last step: `writeBridge` builds the new database in a staging
directory and only then moves the old one aside. A failure during the build
therefore leaves the previous sync's `bridge.lbug` exactly where it was, beside
the `meta.json` stamped for it — a pair that passes `bridgeMetaMatchesFile`
with the previous run's `unreadableRepos`. The next cross-repo query answers
`truncated: false` from superseded contracts, which is the opposite of what the
operator was told to expect, and worse than being told nothing.

The warning now says what is actually true: contracts.json is intact and
canonical, the bridge was not replaced, cross-repo queries may still answer from
the previous sync's contracts, and nothing marks them as superseded.

The metadata is deliberately NOT re-stamped to make the original promise true.
That would recreate exactly the metadata/database mis-pairing the stamping on
the preserve path exists to prevent, and the comment at the warning records it.

The claim is asserted against captured log output rather than left to the state
tests. Those check which pairs match and what the preserve path writes; every
one of them stays green while this sentence reverts to promising a truncation.
An unasserted user-facing branch is the defect class this change is closing, so
it does not get to close it while remaining one.

No filesystem shape makes the real `writeBridge` fail while
`writeContractRegistry` succeeds — they write into the same directory one line
apart — so the failure is armed through a pass-through wrapper on the file's
existing mock. It delegates byte-for-byte unless a test arms it, and is reset
around the new suite.

Mutation-verified: restoring the original wording turns its own assertion red
and nothing else.

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

* docs(mcp): name every registry outcome group_sync can actually return

The tool's description told agents `registryOutcome` is 'written' or
'preserved'. It has a third reachable value: 'no-prior-registry', returned when
nothing could be read AND there was no previous contracts.json to carry
forward. An agent calling this tool against a group that has never synced got a
value its own tool description said did not exist, and no way to tell it apart
from the case where the previous contracts survive.

The distinction is the whole point of the value. After 'preserved' there is a
registry to read — stale, but real. After 'no-prior-registry' there is nothing
on disk at all, so a following group_contracts or group_impact has no registry
rather than an old one. Those need different responses from the caller.

'not-attempted' stays undocumented because it is unreachable through this tool,
and a guard asserts it stays that way.

The code comment above the annotations claimed the preserve path does NOT write
contracts.json. It does — it rewrites the file, keeping the previous contracts
and cross-links and refreshing only the two diagnostic lists, which the CLI's
own summary was corrected to say a few commits ago. Left alone it would have
re-seeded the same wrong claim next to the text that now states it correctly.

Mutation-verified: deleting the 'no-prior-registry' sentence turns the guard red.

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

* docs(mcp): explain structural incompleteness on the impact tool and status resource

The impact tool's GROUP MODE paragraph described one cause of truncation — the
fan-out running out of room — and left an agent to assume that was the only one.
So a `truncated: true` carrying `truncationReason: 'incomplete-sync'` read as
"retry with a smaller scope", when retrying returns the identical floor forever:
the repos are absent from the bridge itself, and only a re-sync puts them back.
The old text also said the response carries the truncation fields "when it stops
early", which is wrong for that case — `truncatedRepos` names repos even when
ZERO crossings to them were attempted, because their contracts were never in the
bridge to cross to.

The paragraph now branches on the reason and gives each its remedy: 'timeout'
and 'partial' are runtime limits where a retry or a larger budget can help;
'incomplete-sync' is structural and the remedy is `group_sync`.

The reason union is now derived from an exported `as const` array rather than
written as a bare type. A type-only union gives a guard nothing to enumerate, so
the guard has to hand-list the members — and then it passes forever the moment a
fourth is added, which is the exact regression it exists to catch. The guard
iterates the runtime array instead. Verified by appending a probe member and
watching it go red, then removing it. The resolved type is unchanged; every
importer uses `import type` and none needed an edit.

The status resource said "Group index / contract staleness" and nothing about
the distinctions its payload now carries. It explains all of them: a repo absent
from the registry versus one whose entry could not be resolved, and the
`unreadableRepos` tri-state where an ABSENT key is not an empty one — absent
means the last sync never recorded what it could read, so cross-repo answers for
that group are a floor.

The description an MCP client actually receives lives in `getResourceTemplates`,
not in the context resource's inventory line the plan pointed at. Both now carry
the vocabulary, so the two surfaces cannot disagree about the same payload.

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

* feat(group): serialize group syncs behind a fail-closed per-group lock

Two concurrent syncs of one group could lose one another's writes. Both read
the prior registry, both built contracts, both wrote — last writer won, and the
loser's work was gone with nothing reporting it. A group sync is long and
expensive and is exactly the operation whose lost update destroys contracts.

`syncGroup` now takes a lock for the whole persist section, acquired exactly
once. `acquireIndexLock` is not reentrant, so a second acquisition anywhere
below would deadlock the happy path rather than an edge case; `withGroupSyncLock`
has one call site and nothing inside it re-acquires.

The lock lives on a dedicated `sync-lock` directory inside the group directory,
mirroring the registry lock's dedicated directory rather than reusing the
resource's own — a lock directory that could collide with a per-repo index slot
repeats a bug the registry lock's comment already warns about.

It fails CLOSED, which is the opposite of `withRegistryLock` and deliberately
so. That one degrades to unlocked because it guards a sub-second JSON merge on
a latency-critical path; here running unprotected is the outcome the lock
exists to prevent. Three exits are covered: a timeout, an unwritable lock
directory, and the lock-free degradation the primitive performs silently.

That third exit needed a change in `index-lock.ts`, and it is the one declared
exception to keeping this work inside core/group/. `acquireIndexLock` answers a
read-only or permission-denied filesystem with a no-op handle that is
byte-identical in shape to a real one, so a caller for whom lock-free is not an
acceptable outcome could not tell the difference. It now carries an optional
`lockFree` marker. The change is additive by construction: no signature moves,
no control flow changes, nothing about when or how a lock is taken changes, and
every caller that ignores the field behaves exactly as before.

A filesystem probe inside the group module was considered and rejected on
evidence: `selectBackend` returns `socket` on Linux and Windows, where
`acquireViaSocket` never touches the filesystem and this branch cannot occur —
so a probe would refuse syncs on the two platforms that never degrade while
missing the one that does.

The timeout ceiling is a named 600s constant passed explicitly. The magnitude
matches the primitive's own analyze-sized default because a group sync is
analyze-shaped and a legitimately queued second sync must be able to wait out a
full first one. Passing it explicitly is about the override, not the magnitude:
`resolveTimeoutMs` resolves `GITNEXUS_INDEX_LOCK_TIMEOUT_MS <= 0` to Infinity,
which would turn fail-closed into a hang.

Cross-process exclusion is proved with a real spawned holder, not an in-process
mock, which cannot demonstrate the property this exists for. The lock-free
scenario pins `GITNEXUS_INDEX_LOCK_BACKEND=file` — unpinned it would pass on
two of three platforms while measuring nothing — and produces the failure by
injecting EACCES on one syscall rather than by chmod, so it runs identically on
Windows instead of being skipped there.

The CLI reports the failure through pino rather than a bare stderr write, which
this package lints as an error to keep that migration moving, and the test reads
the `msg` field rather than a raw substring — matching on the raw text would
have passed only by accident of quoting and would go green again if the line
were downgraded.

Nothing is skipped on any platform, and the test is registered for the
cross-platform shards.

Mutation-verified: removing the lock acquisition turns 6 scenarios red;
removing the lock-free rejection turns the degradation scenario red on its own.

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

* fix(group): make the sync-lock timeout name a cause it can establish

The fail-closed lock surfaced the primitive's own timeout message to users for
the first time, and that message says the wait was on "another gitnexus
analyze" — a cause its detection path cannot establish. It is the same
confident-about-what-it-could-not-determine claim this PR exists to remove,
inherited rather than written.

The wrapper now throws its own. It names the group, the lock directory, the
operation, and the elapsed wait, and it says plainly that nothing was written.

The holder clause branches on `holderKnown`. The socket backend exposes no owner
metadata and reports a placeholder pid of -1, so on that backend — and on the
file backend's malformed or vanished-lock timeouts — the message says the lock
stayed held but the backend cannot identify who held it, rather than printing a
pid that means nothing.

The elapsed wait is measured by the wrapper. `IndexLockTimeoutError` carries
only `holder` and `holderKnown`; the figure exists solely inside the string
being replaced, so it had to be taken rather than read.

One pre-existing assertion changed with it: the timeout case asserted
`'Timed out after 600000ms'` from the inherited text, which is precisely the
message this replaces.

Mutation-verified: restoring the inherited message turns the three assertion
cases red and leaves the control — a real acquisition that succeeds — green.

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

* fix(group): stop a losing sync from downgrading the one that beat it to the lock

Serializing is not ordering. Both syncs run extraction outside the critical
section, so a total-failure sync that acquires second reads the winner's fresh
registry as `prior` and rewrites it with all-unreadable lists. The lock alone
does not prevent that — it only decides who goes second, and the loser then
overwrites a healthy registry with a description of its own failure.
Deterministically, not as a rare interleave.

The guard is a compare-and-swap on the registry file's own identity: stat
before acquiring, re-stat after, and write nothing when they differ. Identity is
presence plus size, mtime and inode — `writeContractRegistry` publishes through
write-then-rename, so a real replacement always changes the inode even if size
and mtime happen to collide.

Deliberately NOT keyed on `generatedAt`, for two independent reasons. It is
stamped when the registry object is built, before the lock is acquired, so a
winner that waited would write a value older than the loser's start. And the
preserve path carries it forward verbatim by design — it dates the contracts,
not the write — so after any preserve sync it does not date the write at all,
leaving the comparison blind on exactly the pairing this guards. A file-identity
compare also needs no cross-process clock agreement.

The skip reports the existing `preserved` outcome. Nothing was written and a
prior registry was kept, which is what that value already means; a new one would
falsify the guard asserting the sync tool's description names every reachable
outcome, and would fall through the CLI's outcome chain, which has no fallback.

The bridge metadata refresh is skipped too, which the plan did not specify.
`refreshPreservedBridgeMeta` stamps THIS run's repo lists into meta.json, and
meta.json is where cross-repo impact reads completeness — so writing it would
report as unaccounted-for exactly the repos the winning sync had just accounted
for. That is the same downgrade being refused, one file over. Skipping both is
what makes `preserved` an honest answer here.

Mutation-verified: removing the after-stat and the skip turns the three decisive
cases red while both non-misfire controls stay green.

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

* refactor(group): run the bridge swap inside the caller's critical section

The bridge swap needed the group lock, and could not take it: `syncGroup`
already holds it when it calls `writeBridge`, and `acquireIndexLock` is not
reentrant. Acquiring inside the swap would deadlock every sync on the happy
path rather than on an edge case.

So the body splits the way this repo already splits this shape — a lock-free
`writeBridgeUnlocked` whose precondition is that the caller holds the lock, and
a thin `writeBridge` wrapper that acquires it for direct callers, mirroring
`registerRepoUnlocked` / `withRegistryLock`. `syncGroup` calls the inner one;
everything else keeps calling `writeBridge` and is now serialized by it.

`writeBridge`'s exported signature is byte-identical to before, so no caller
changed and nothing about the exported surface moved.

The precondition is enforced by a comment naming the single production call
site, which is what the existing precedent does. A type could carry it, but the
repo's own answer to this question is a comment, and diverging here would make
this the odd one out for no additional guarantee.

`refreshPreservedBridgeMeta` is deliberately left unsplit. Its one caller is
already inside the critical section and it has no test callers, so an acquiring
wrapper would be dead code standing in for a guarantee the caller already
provides — and moving the lock inside it would be the second acquisition this
change exists to avoid.

Scope: this delivers writer-writer exclusion only. The reader-side promotion of
a leftover `.bak` into place runs on ordinary reads, outside any lock, and is
not claimed here — the pairing check remains the reader's defense. Confirmed as
live behavior while writing the crash-recovery test, which asserts on file
state rather than through `bridgeExists` for exactly that reason.

One test file beyond the two the unit named had to change: a suite mocks
`bridge-db` to inject a `writeBridge` failure and exercise the bridge-write
warning. Once the sync calls `writeBridgeUnlocked`, that fault was being
injected into a function the path no longer calls, and the test went red. The
mock is repointed.

Mutation-verified three ways. Pointing the sync back at the acquiring wrapper
deadlocks a single UNCONTENDED sync — the evidence that the nesting defect is
real and that this split is what prevents it. Removing the wrapper's
acquisition turns the direct-write exclusion case red. Making the lock-free half
acquire for itself turns the held-lock case red.

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

* test(hygiene): reach every tracked text file with the raw-byte guard

The guard claimed to protect tracked source from a raw NUL — the byte that
makes git classify a file binary and costs it its diff, its inline comments and
its three-way merge on GitHub. It matched on an end-anchored extension regex
covering the JavaScript family, so most of what this repo tracks was never
looked at: JSON, YAML, TOML, Markdown, snapshots, SQL, protobuf, the .NET
project files, the shell and batch scripts.

Worse, an extension regex cannot reach a file that has none. `Dockerfile`,
`CODEOWNERS`, `LICENSE`, the husky hook and every bare dotfile were unreachable
by construction — no amount of widening the pattern would have covered them —
so a second basename filter had to exist for the claim to be true.

It stays an allowlist rather than becoming "everything git tracks", because the
repo legitimately tracks binaries whose extensions must stay out.

The two filters together now collect every one of the 5000 tracked files except
31 — the 30 native prebuilds and one PNG — and those 31 are exactly the files
that carry a NUL. The allowlist no longer has a gap that is not a genuine
binary.

The planted-fixture cases route through the collector's own predicate rather
than straight into the scanner. The pre-existing fixture test bypassed the
filter entirely, so it could only ever prove the byte locator worked, never
that the collector would hand it the file — which is precisely how the gap
survived.

Mutation-verified both ways: removing the basename filter drops `.gitignore`
and `Dockerfile` from the planted results, and reverting the extension regex
drops `.json` and `.md`.

One added case is a preservation pin rather than proof — that tracked binary
formats stay out passes either way, and guards the allowlist from becoming a
denylist later.

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

* test(hygiene): stop the byte guard reading the vendored grammar tree

Widening the guard to every tracked text format also pulled in the vendored
tree-sitter grammars, and those are where the bytes are: four generated
`parser.c` files come to 62 MB between them, Kotlin's alone 33.7 MB. Excluding
that root drops 76 files but 66% of the bytes the scan reads — 97 MB down to
33 MB.

The exclusion is a single anchored prefix, matched case-sensitively with
`startsWith`, and both halves of that matter. A `vendor` path-SEGMENT match
would also drop first-party fixtures this repo tracks under directories named
`vendor` and `Vendor` — a Kotlin one, a PHP one, and three files under
gitnexus-web — silently narrowing coverage while the assertion pinned the loss
in place. Case-insensitivity would do the same to a `Vendor` directory at the
excluded root's own level.

The root is named in the guard itself, so the claim that it covers every
tracked text file stays honest about the one place it deliberately does not
look.

The cost comment was wrong and is now measured rather than estimated. It said
"the scan is ~10 ms" — ambiguous between locating the byte and reading the
files, and stale in its byte basis. Locating is ~14 ms; the reads dominate it
by two orders of magnitude, which is the actual reason for the concurrency pool
and the actual reason this exclusion is worth having. Every figure was
re-derived from the finished file rather than carried over from a draft.

The header's claim that `git ls-files` "never descends into vendor" was already
false — vendored code is tracked, so all 106 of its files were being reported
and read. Corrected here, where the distinction becomes load-bearing.

Registered in the cross-platform list first and given a shard weight second.
The weight table is only consulted for files already in that list, so a weight
entry alone is inert and the shard test filters unregistered keys without
complaining. The three-way split stays within 1.01x of ideal.

Mutation-verified three ways: a case-insensitive segment match, a
case-sensitive segment match, and a case-insensitive anchored prefix each turn
an assertion red.

The casing half was initially unfalsifiable — nothing tracked is named
`gitnexus/Vendor/`, so a tracked-set assertion could not distinguish it. Rather
than leave the claim unpinned or invent a fixture, it is pinned on the
predicate with a synthetic path; the tracked-set assertions pin the anchoring.

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

* test(group): make the strict-read test able to see which read ran

The file bound both registry exports to one mock:

    readRegistry:       (...args) => readRegistryMock(...args),
    readRegistryStrict: (...args) => readRegistryMock(...args),

so the case named for the strict read asserted a behavior it could not
attribute. Point the production call at the lenient export and every assertion
still holds, because the mock answers the same way whichever one is called.

That is not a hypothetical. With this file as it was, and `syncGroup` mutated to
call `readRegistry` instead of `readRegistryStrict`, all 32 tests passed — the
suite was blind to the exact substitution it exists to prevent, and the fix it
guards could have been reverted without a single red.

The exports now have separate mocks: the lenient one always resolves an empty
list, which is its real contract, and only the strict one is armed by the cases
that need a failure. The named case also asserts directly that the strict read
was called and the lenient one was not, so the attribution is explicit rather
than implied by an outcome.

No tests added — the unit is about what the existing ones can see.

Mutation-verified: the same substitution now turns 24 cases red, including the
named one, and everything stays green unmutated.

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

* test(group): pin the CLI output branches this PR introduced

The three sync outcomes and the status table's new labels had no assertions.
Every one of them is a sentence about what happened on disk, and this PR
corrected several that were false — a preserve branch that announced it had not
written the file it rewrites, a status table that called an unreadable registry
a missing entry. Text that describes state, with nothing pinning it, is how
those got wrong in the first place.

Six cases drive the real CLI end to end, through the two shapes that need no
indexed repo: members absent from the registry, and members registered at a
storage path with no index file, which makes every repo unreadable. The file
header claimed no LadybugDB-backed command was driven end to end; that is no
longer true and it now says so.

Each branch was suppressed in turn and its assertion goes red — all five that
the plan named.

One of those mutations first reported PASS, and the cause is worth recording: the
string being suppressed also appears inside a neighbouring branch's comment, so
the harness silenced the wrong line. That is a bad mutation, not a weak test.
The harness now asserts the marker it suppresses is unique before trusting the
result, and the redone check goes red.

The plan's sixth scenario is already covered by an existing case that asserts
both labels in one table, so it is not duplicated. A seventh case was added
beyond the plan: without a populated-list case, "prints neither line" would pass
just as well against a CLI that never printed that line at all.

Adds about 15s of measured spawn time locally; CI runs these against the built
dist, which is materially faster per spawn.

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

* test(group): assert the MCP payloads by exact shape, not by partial match

Nothing asserted what the group tools actually return. The sync response's
unreadable list and registry outcome, and the contract listing's incompleteness
fields, are documented in the tool descriptions an agent reads — and could have
been dropped in a refactor without a single test noticing.

The assertions are exact-shape rather than partial. A `toMatchObject` would let
a dropped key pass, which is precisely the regression these exist to catch: the
failure mode is an absent field, and a partial match is defined not to see one.
Absences are additionally asserted explicitly.

The tri-state has to survive the response boundary, and it is the reason exact
shape matters here more than usual. An absent `unreadableRepos` means the sync
never recorded what it could read, so the listing is a floor; an empty list
means it measured none; a populated list names them. Collapsing absent into
empty turns "we do not know" into "we checked, it is fine" — so a mutation that
replaces the conditional spread with `?? []` is covered specifically, not just
the outright deletion.

Mutation-verified per field: removing either sync forwarding line, deleting the
conditional spread, replacing it with the invent-empty form, dropping the
truncation triple, or hardcoding the provenance flag each turns an assertion
red.

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

* docs(group): stop the bridge input narrowing what unreadableRepos means

The same field had three definitions. The registry and the bridge metadata both
say it covers a repo this sync could not extract from — an index that would not
open, or an extractor that threw partway through, one bucket because the
consequence is one thing. The bridge input said only "whose index could not be
opened", which describes one cause and silently excludes the other.

It now points at the registry's definition instead of restating it a third
time. A definition written once and referenced cannot drift; three copies of it
already had.

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

* docs(group): record what the mtime pairing does and does not prove

The write-order fallback is a heuristic standing in for provenance, and a
future reader deciding whether to lean on it needs to know where it breaks
before they do. Both directions are now stated where the function is read
rather than only in the plan that introduced it.

The false-accept direction is a non-monotonic wall clock — mtime is realtime,
so an NTP step back, a snapshot restore, or container skew between the two
writes can leave a mis-paired set reading as ordered. Coarse filesystem
granularity is explicitly called out as NOT being that hazard, because it looks
like it: it collapses a pair written together to equal times, and equal is
accepted, which is the right answer for that pair.

The false-reject direction is any copy or restore that rewrites the database's
mtime after the metadata's. An intact legacy pair is demoted to a lower bound
and stays there until a sync re-stamps it, because nothing on the read path can
tell it apart from the swap window it imitates.

That second direction corrects a claim made while planning this work: that the
rule could only ever demote pairs already broken. It cannot. `cp -r` and
`rsync` without timestamp preservation both produce it on a healthy group, and
saying otherwise where the code is read would leave a future reader to discover
it the hard way.

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

* fix(storage): stop a corrupt registry quoting its own bytes into errors

`JSON.parse`'s SyntaxError embeds a window of the source around the failure —
V8 gives exactly ten characters either side — and the strict read rethrew it
untouched. The registry persists HTTPS remote URLs with their userinfo, so a
file that breaks next to one puts the credential into the error:

    Unexpected token 'L', ..."end.git"},LEAKCAN4RY"... is not valid JSON

The parse now has its own guarded region and reports the path and the failure
class, matching the two corrupt-registry errors already in this function.

The original error is discarded — not logged, not attached as `cause`. This
codebase's convention elsewhere is to hand the logger the Error so it captures
stack and cause, and following that convention here is precisely what would put
the byte window into the log. Under MCP stdio that log is written to the
client's log file on disk, so the thrown-error channel was never the only one
that mattered. The `catch` takes no binding, so the error cannot be reused by
accident later.

That was not theoretical: a sibling commit routes this message into
`unresolvableReason`, which `group status` returns to MCP clients and prints in
the CLI table. Every channel was traced — throw, cause, inspect with the full
chain, the logger, and both downstream consumers.

The leaking shape is narrower than it first appears, and worth recording. The
windowed message only fires when the parser fails at a value-start or trailing
position; a break inside a quoted string yields an unterminated-string error
carrying no window. So a plain mid-URL truncation does not leak — a short write
landing over a longer one does, leaving a URL fragment where a value was
expected. That is a reachable shape for the one machine-wide file every
gitnexus process writes.

The test asserts the message still names the path and the corruption class, not
only that the secret is absent. Asserting absence alone would stay green if the
message became empty.

Mutation-verified: restoring the raw rethrow brings the token back verbatim.

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

* docs(storage): drop the stale lenient call-site count

The docstring said keeping `readRegistry`'s signature untouched leaves "its
nine other call sites" unaffected. There were thirteen when the discrepancy was
noticed and fourteen by the time it was fixed. The same figure appeared in the
test file's header.

Replaced rather than corrected. A count in prose next to code that moves is a
claim that goes stale without anything failing — which is the defect class this
change set exists to remove, so re-seeding a fresh number would be repeating it
with a longer fuse. The argument was never about the quantity: leaving the
signature alone keeps every lenient caller provably unaffected whether there is
one or fifty.

Also withdrawn while here: the claim that the bridge schema-version guards
diverge between call sites. They do not — the two forms are complements for
every value a writer can produce, there are three sites rather than the two
claimed, and all three agree. Recording a divergence that does not exist would
leave a future reader chasing it.

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

* docs(group): add an auditable finding-to-commit map

The Definition of Done claims every review finding has exactly one commit and
that reverting it reintroduces that finding and no other. Without a map that
claim is only checkable by whoever holds the review report, which is one person
for a short time.

The map lists all 28 primary findings against their commits, the three findings
whose suggested fix was deliberately not implemented and what shipped instead,
and the four defects found while executing that no reviewer raised.

It also records the revert contract honestly. Revertability is
dependency-aware, not absolute: the shared completeness helper has three
consumers, so reverting it alone does not build. That coupled set is named
rather than left for someone to discover mid-revert.

Two sections exist because the work produced them, not because the plan asked.
Six claims in the plan turned out to be contradicted by the code — among them a
scope predicate that would have reintroduced the bug its unit was closing, and
an assertion about the mtime rule that was simply wrong. Recording only the
findings would leave the impression the plan was followed as written. Five
residual risks are listed for the same reason, including that R14 is not met on
this PR: the diff attribute works locally but GitHub reads it from the base
side, so this PR's own sync.ts stays binary in the web view and every PR after
it renders as text.

Not under docs/ — that path is gitignored, so a map written there would never
reach the PR and the audit it exists for could not be performed by anyone else.

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

* fix(group): read a version that is not a version as no provenance

Raised by the check bot on this PR, and real — the bot found one symptom of it;
the field splits four gates apart, not one.

`readBridgeMeta` accepted any numeric `version`, and `0` is this file's word for
"no provenance". A parseable but impossible value — negative, fractional — is
not a schema version, and each gate that reads the field disagreed about it:

  ensureBridgeReady      `> 0 && !== CURRENT`  → opens the bridge
  openBridgeDbReadOnly   `> 0 && !== CURRENT`  → opens the bridge
  bridgeExists           `=== 0 || === CURRENT` → says it is not there
  bridgeProvenanceUnknown `=== 0`               → reports the answer complete

Four verdicts about one file, and the last one is a fail-open of exactly the
class this PR exists to close: a bridge nothing can vouch for, reported as
fully accounted for.

The suggested fix was to widen the provenance check to `<= 0`. That closes the
reported symptom and leaves `bridgeExists` still disagreeing with both openers,
so it is fixed at the reader instead: a version that is not a positive integer
normalizes to the sentinel the gates were all written against. One change, four
gates agreeing by construction, rather than teaching each of them the same new
case and hoping the fifth reader remembers.

Infinity is covered too, though by the pre-existing type check rather than the
range one — JSON cannot carry it, so it arrives as `null`. Recorded at the test
so the case is not mistaken for proof of the range check.

Mutation-verified: restoring the loose numeric check turns the negative and
fractional cases red.

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

* fix(group): stop a malformed contracts.json reading as an unresolvable registry entry

Raised by the check bot on this PR. Its stated mechanism was wrong — `loadMeta`
returns null on every error and `checkStaleness` catches everything, so neither
can throw — but its conclusion was right, and there is a concrete path it did
not name.

`readContractRegistry` is a bare `JSON.parse(content) as ContractRegistry` with
no shape check, and the snapshot lookup guarded only the registry object:

    registry?.repoSnapshots[repoPath]

The `?.` covers `registry` being null, not `repoSnapshots` being absent. A
contracts.json without that field — a legacy file, a hand-edit, a truncated
write — throws `TypeError: Cannot read properties of undefined`, which lands in
the catch that labels failures as unresolvable GLOBAL-registry entries. So a
group whose own contracts file is malformed reported every repo as a broken
registry row, sending the operator to repair a file that was fine.

An error from one cause presented as another, which is the defect this PR has
been removing everywhere else.

The optional chain closes the crash. The try is also narrowed to the call that
earns the label: only `resolveRepo` sits inside it now, so "did not resolve"
describes something that actually failed to resolve rather than whatever else
happened to throw nearby. The comment records why the other two calls in that
block cannot throw, so the next reader does not have to re-derive it.

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

* refactor(group): give the completeness fold a module no native binding reaches

The shared fold ended up in `cross-impact.ts`, which statically imports
`bridge-db.ts` and through it the native LadybugDB binding. `groupContracts`
therefore reached it through `await import('./cross-impact.js')` — loading that
whole module graph to run a Set union and a ternary. Measured: 44-51ms and
8.4MB of RSS on first call, paid once per MCP server and once per
`gitnexus group contracts` invocation.

`completeness.ts` holds the vocabulary and the fold and imports nothing but
types. `service.ts` imports it statically; the lazy import and the comment
justifying it both go. `cross-impact.ts` re-exports so the three surfaces still
have one import site for the vocabulary.

Three other duplications collapse into the same move.

`traceCompleteness` was hand-writing `{truncated, truncationReason,
riskEpistemic}` — a third writer of the pair `truncationFields` exists to keep
mechanically linked (#2787), in the file the consolidation had just touched. It
calls the helper now.

`recordedRepoList` existed twice, byte-identical, one copy's docblock saying it
mirrored the other. That gate is the predicate the whole
absent-vs-empty-vs-populated distinction rests on, applied to the same two
lists on both the registry and the bridge — tightening one copy would have
fixed one surface silently. One definition now.

The trace's scope predicate compared repo paths with `===` while its sibling in
`cross-impact.ts`, added in the same change, went through `repoInSubgroup` with
a comment about not growing a second notion of membership. It had grown one:
the helper normalizes separators and strips trailing slashes, so the same
group.yaml spelling could be in scope for impact and out of scope for trace.

Also here: `registryIdentifies` was a third, weaker copy of the registry's path
rule — it skipped `realpath`, so a symlinked row would not match where the real
resolver would. It uses `canonicalizePath`/`registryPathEquals` now.
`contracts.json` is no longer respelled as a literal in `sync.ts`; `storage.ts`
owns the name it reads and writes. And the runtime-truncation predicate is
bound once instead of written out at both the flag and the reason, where
forgetting the second would label a retry-able answer `incomplete-sync`.

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

* fix(group): give the lost-the-race sync its own outcome instead of overloading preserved

A sync that finds contracts.json replaced while it waited for the lock reported
`registryOutcome: 'preserved'`. That value already meant something else, and the
two differ in exactly the thing the value is for: `preserved` rewrites the file
with this run's diagnostics; this path does not touch it and deliberately does
not record them.

So both surfaces stated something false about disk. The tool description told
agents `preserved` means "contracts.json was rewritten ... refreshing only
missingRepos/unreadableRepos to describe THIS run (the file changed)". The CLI
said "only the unreadable/missing repo lists were refreshed to describe THIS
run". On the lost-race branch nothing was written and the log line beside it
says so outright.

That is the defect class this whole change set removes, reintroduced by the
change set itself — and the reasoning recorded at the time makes it worse, not
better: a new value was rejected because it "would fall through cli/group.ts's
outcome chain, which has no fallback branch". A renderer limitation decided a
domain value, and the description then had to cover two states with one
sentence that fits one of them.

`superseded` is its own outcome now, described in its own words to agents and
rendered in its own words at the CLI. The registry on disk is FRESHER than this
response's diagnostics, which is the opposite of every other non-written
outcome and is why an agent needs to tell them apart.

The CLI renders from a `Record` keyed on the union, so the next outcome fails
the build here rather than printing nothing — the gap that made folding the
state in look like the cheap option.

The description guard is scoped per clause rather than over the whole string.
It forbade "untouched" anywhere, which was right when one clause could only lie
in that direction and wrong now that another clause is accurately untouched. It
also asserts the superseded clause says so, or the two collapse back into one
word for two states.

Found by the quality pass over this branch, not by review.

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

* test(group): read bytes and stat through one handle, not two path lookups

CodeQL flagged both sites as `js/file-system-race`, high severity, and it is
right about the shape. `stat(path)` followed by `readFile(path)` is two
independent path resolutions with a window between them — the classic
check-then-use race.

It also made the assertions weaker than they read. These two tests exist to
prove a specific file was left untouched, and two lookups can land on different
inodes, so "the bytes and the mtime are both unchanged" was not actually a
statement about one file. The distinction is the whole point here rather than a
technicality.

`snapshotFile` opens the path once and takes both answers from that handle. The
race is gone because there is no second lookup, and the assertion now genuinely
concerns one inode.

I had previously triaged these as below the ruleset's threshold and left them
for the repository owner. That was wrong: they carry
`security_severity_level: high`, and the branch ruleset gates on
`high_or_higher`, so they were blocking the merge rather than sitting under it.

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>
2026-08-26 09:37:16 +01:00
DuduPhudu
031e123731
fix(group): resolve HTTP consumers through configured clients and constant route tables (#3008)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(group): resolve HTTP consumers through configured clients and constant route tables

Cross-repo linking found almost no frontend consumers because the Node/TS
consumer pattern required two things application code never has: a receiver
literally spelled `axios`, and an HTTP path that is a string literal at the
call site. Real apps call a configured instance and pass the path by reference
from a shared route table, so both halves of every call live in other files.

Widen the pattern to any identifier receiver with an HTTP-verb method, then
admit the match only after PROVING the receiver is an axios instance —
following local aliases, default/named imports and `export *` barrels back to
an `axios.create(...)`, including when that call is an argument to a factory
that decorates and returns the instance. The proof gate is load-bearing:
EXPRESS_SPEC matches `router.get('/x', handler)` as a provider, so admitting a
receiver on spelling alone would re-emit every Express route as a consumer of
itself.

Resolve the path argument through the existing language-agnostic constant fold
(`constant-resolver.ts`, #2391) via a new JS/TS binding, mirroring how
`python-const-resolver.ts` binds the same core. The binding adds the two
JS-shaped facts Python has no analogue for: object-literal route tables
flattened to dotted literal keys (`API_ROUTE_PATH.LINKS`), and export aliasing
(`export default`, `export { a as b }`, `export *`). Templates and `+` concats
fold partially, so a mixed path keeps its known prefix instead of collapsing to
`{param}/{param}/...`.

Cross-file facts come from a `prepareRepo` pre-pass, the hook FastAPI prefix
resolution already uses. The three JS/TS plugins share one pass via a WeakMap
keyed on the orchestrator's memoized file list.

Every resolution floors to `null` (skip) rather than a guess: an ambiguous
import specifier, an unprovable receiver, or a fold that overruns its depth
leaves the call site exactly as unmatched as before. An unresolved path is a
missing contract; a wrong one is a false cross-repo link.

Measured on a real Next.js frontend (874 source files): consumer contracts
7 -> 160, none lost.

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

* fix(group): tighten the JS/TS HTTP consumer proof gates and bound the fold

Addresses the review findings on #3008. Widening the axios consumer query
moved precision out of the tree-sitter pattern and into runtime gates; most
of these are one of those gates leaking.

Keying
- scanBundle normalizes fileRel ONCE and uses that key for both the receiver
  gate and the path fold. isHttpClientRef read the raw value while the fact
  map is written under normalizeRel(rel), so any non POSIX path returned zero
  consumers and a key miss is indistinguishable from "not a client".

Proof
- containsAxiosCreate (subtree containment) becomes bindsAxiosClient: the
  instance must be the bound VALUE, or reachable inside the arguments of a
  wrapping call whose result is bound. An object literal, ternary, array or
  new X(...) binding no longer makes a cache or registry an HTTP consumer.
- A folded first argument must look like a path: no whitespace, not wholly
  numeric, and not starting with an unresolved term. The check runs on the
  ${...} to {param} normalized shape, so a placeholder whose source contains
  spaces does not drop an otherwise anchored path.
- A template or concat whose LEADING term never resolved returns null, which
  is what the docstring always claimed.
- The literal receiver axios with a literal or template argument keeps its
  pre-PR output verbatim, so the widening only adds detections.

Resolution
- resolveJsImport checks ambiguity across ALL candidate extensions, not within
  one, so a .ts/.tsx or .ts/index.ts collision skips instead of picking a
  winner. Two spellings of one module still resolve by precedence.
- A single segment bare specifier with no alias sigil never binds to a repo
  file, so a Node builtin or npm package cannot be "proven" an axios client.
- resolveExportedMember walks every export * edge and returns null when two
  barrels answer differently.
- Imports are collected in a hoisting pre-pass, so a client bound above its
  own import statement is still proven.

Termination and cost
- MAX_EXPR_DEPTH and MAX_CONCAT_TERMS bound the path fold, flattenConcat walks
  the left spine iteratively, and buildImportMap is explicit stack. A file
  nesting template substitutions 4000 deep threw RangeError out of scan, which
  sync.ts records as an unexplained missing repo with every contract dropped.
- MAX_FOLD_LENGTH applies to accumulated output, not per term, and to the raw
  literal fallback. The per term cap was a 2048x amplifier and the result is
  persisted into contractId.
- resolveJsImport is backed by a basename index and memoized per repo, and
  resolveConstant accepts the key set instead of rebuilding it per fold.
  2000 file repo with one bare npm import: 11074 ms to 1250 ms.
- prepareRepo measures its ceiling in bytes, parses inside the try, and skips
  the parse pass entirely when the string axios appears in no candidate file.
  It carries only file identities between its two passes, never their text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): let a path-shaped all-numeric consumer path through the gate

The shape gate rejected any wholly numeric path, which also dropped
`client.get('/123')`. The leading slash is the evidence that separates a
route from a constant that merely folded to digits: a bare "5000" out of
`CONFIG.TIMEOUT` still matches every one-segment provider route and is still
refused, while a path written as a path is kept and normalized to {param}
the same way it always was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* style: apply prettier to the changed files

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): decide the axios receiver on evidence, not only on its spelling

The bare name `axios` was trusted with no proof, which is right for the
convention and wrong for a file that binds that name itself:
`const axios = fakeFactory; const api = axios.create(); api.get('/x')` was
admitted as an HTTP consumer, and so was a test file whose `axios` is a mock
object with a `create` method.

extractJsModuleFacts now records whether the file declares its own top-level
`axios` binding, and the spelling is trusted only when it does not. The other
half of the same fact is that CommonJS was invisible: `const ax =
require('axios')` resolved to nothing at all, and the un-aliased form worked
only because `axios` happened to be the name the spelling shortcut trusted.
Requires are collected alongside imports now, so a receiver is admitted when
it IS the axios module (the bare spelling, or a declared import or require of
'axios' under any name) or when it traces to an `axios.create(...)` instance.

Verified across the receiver matrix: shadowed local, shadowed mock object,
CJS require aliased and not, ESM import aliased and not, express router and a
plain Map all land where they should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

---------

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>
2026-08-25 11:34:23 +01:00
ChunxueLi
3f5fbb05e0
feat(group+ingestion): resolve Java constant-based route paths (@PostMapping(ApiPathConstants.X)) (#2980)
* feat(group): resolve Java constant-based route paths via repo constant map

- prepareRepo builds repo-wide Java constant map (constant-definition files only,
  cheap regex gate; per-file try/catch so one bad file degrades not forfeits)
- bind parser language in prepareRepo (orchestrator hands over a bare Parser)
- scan() lazily overlays the importing file's own import table (extracted from
  the tree already in hand, zero extra parses) before folding operands
- foldJavaOperands resolves qualified refs (Class.CONST) + static imports +
  string concatenation against the merged view; unresolved refs are skipped,
  never guessed

Real-repo validation (winning-winex-opt, 23k Java files):
  providers 2 -> 1701 (1700 source_scan_resolved), cross-links 0 -> 589 exact
Unit: 14/14 (java-route-const-resolver.test.ts)

* fix(review): address bot review findings on PR #2980

- P2-1 (real): spring.ts route loop dropped every @value_expr match — the
  '!valueNode' guard ran before the operand branch, so ingestion emitted zero
  constant-referencing routes. Guard now accepts @value_expr when @value is
  absent; two downstream valueNode dereferences made conditional.
  Added 2 extractor-level regression tests (16 total).
- P2-2 (real): collectSpringTypes copied rawPath:'' for constant routes into
  the shared Spring inheritance view — now skipped there (fold happens in
  scan(); empty-path noise would leak into inheritance-based providers).
- P1-1 (false positive): Java 'static final' allows exactly one initializer
  (duplicate declarations are compile errors), so the Python-style rebinding
  shadowing cleanup does not apply — documented at the site.
- P1-2 (false positive): constant-resolver.ts and prepareDurableParsedFileChunk
  both exist on upstream main (#2391 / parsedfile-store.ts:562); the bot's
  'repository lookup' appears to have compared against a stale index.
- P3: removed dead FQN_CONTROLLER fixture.

Real-repo regression: 589 cross-links / 2423 contracts (was 2424 — the
dropped contract is the empty-path inheritance artifact fixed above).

* docs(cache): note Java constant-route capture set in the SCHEMA_BUMP ledger

The Java constant-route harvest (route-extractors/java-const-resolver.ts +
the spring.ts operand branch + the parse-worker Java constant harvest)
changes the worker capture set: a warm pre-feature cache replays
moduleConstants=0 captures verbatim and silently drops every constant-based
Spring route on unchanged files. After rebasing onto current main the
ledger already sits at 70, whose capture set post-dates and includes this
harvest, so v70 invalidates those caches — no additional bump is needed.

* fix(feign): guard @RequestLine against the constant-valued shape

A constant-valued `@RequestLine(SOME_CONST)` is captured as @value_expr,
not @value, so `valueNode` is undefined in that shape and the literal
dereference crashed the scan. Skip instead — folding verb+path literals
through the constant map is out of scope for this PR.

Found in maintainer review of #2980.

* fix(resolver): bound qualified-ref recursion depth for self/mutual import cycles

Maintainer review point: the qualified branch of resolveJavaConstant
recurses through resolveJavaImport without a guard — a self-import
(X = SelfConsts.X + ...) or a pair of mutually-importing constants
would recurse without bound before reaching the shared fold's
visited-stack, which only guards the bare-name path.

Bound the Java-qualified walk with a depth cap (32) and thread it
through every recursive call. Two regression tests use real repo
shapes (repoOf fixtures): self-import and mutual-import cycles both
terminate with null (skip floor), as before, but promptly.

Also drops the stray machine-local .gitignore entry that rode along
from the fork's dev branch.

* fix(routes): address round-2 review — provider hooks, FQN fold, interface nesting

F1 (High): production harvest silently dropped routes when the constants
class is not named *Constants (e.g. ApiPaths). The content gate is now
SYNTAX-driven (static-final String field or any class import) and lives in
the provider (moduleConstantHeuristic), not a shared-layer regex.

F2: shared ingestion layers no longer branch on language. The harvest and
the qualified-ref fold run through new provider hooks
(extractModuleConstants / foldRoutePathOperands); parse-impl resolves the
provider by filePath (getProviderForFile). Python wires the same hooks for
architecture parity.

F3: multi-segment FQN chains (com.example.ApiPaths.USERS) now flatten
recursively; verified via tree-sitter that the existing query already
captures the whole nested field_access — the gap was resolver-side only.

F4: implicit-final interface semantics no longer leak into nested classes
at type boundaries (JLS 9.5).

F5: nested same-name shadowing now drops the stale entry (rebind-drop,
matching Python #2391 semantics) instead of keeping the first binding.

Tests: 9 new unit tests (27/27) + real-pipeline e2e over a reviewer-shaped
fixture (non-*Constants class, cold run + warm parse-cache replay) — the
exact production gap unit tests missed.

* style: prettier --write on the two touched test files (CI format gate)

* fix(routes): address the open review findings on Java constant route folding

Answers every reproduced finding still open on #2980, plus the defects an
adversarial pass found in the first round of those fixes. The wrong-path group
each turned a *missing* fact into a *wrong* one, which is what this module's
skip-or-correct contract exists to prevent.

Wrong-path fixes

* Escapes were deleted from constant values. tree-sitter-java splits a
  `string_literal` around its `escape_sequence` children, so joining
  `string_fragment`s alone folded `"/user/{id:\\d+}"` — the standard Spring
  path-variable constraint — to `/user/{id:d+}`, and a pure-escape literal to
  the empty string. Worse, the LITERAL path keeps escapes verbatim, so one Java
  route had two irreconcilable spellings. `stringLiteralValue` now reuses
  `unquoteSpringLiteral`, the helper that literal path already uses. Java text
  blocks are excluded: that helper's `"""` arm would hand back the raw block,
  newline and incidental indentation included, so they keep the old skip.

* A constant-valued class prefix produced a truncated route. The new
  `@value_expr` query branches were `method_declaration`-only, so
  `@RequestMapping(ApiPaths.BASE)` left the prefix empty and the method route
  was emitted unprefixed — a path the application does not serve, where the base
  emitted nothing at all. Both subsystems now detect such a class and suppress
  its method routes, the rule `classesWithArrayPrefix` already encodes for the
  array form. The suppression covers ingestion's separate no-argument-mapping
  loop too, without which a bare `@GetMapping` under a constant prefix still
  shipped an empty-path Route while the group emitted nothing.

* A shadowed static import survived a non-foldable rebind. The rebind-drop
  deleted `literals`/`exprs` but not `imports`, so a name both static-imported
  and locally redeclared resolved through the stale import to the imported
  value instead of skipping (#2393's Python defect, reproduced for Java).

* `resolveJavaImport` guessed where its own docstring promised null. The
  nearest-shared-directory tie-break is gone: javac resolves duplicate FQNs by
  classpath order, so proximity can return a src/test fixture copy.

Parity and coverage fixes

* One constant-file gate, exported as `isJavaConstantFile` and used by both the
  ingestion provider and the group `prepareRepo` pre-pass. The two spellings
  disagreed on a constant INTERFACE — implicitly `public static final`, so it
  carries neither keyword — which the group admitted and ingestion rejected, so
  the group published a contract while the graph got no Route node. It is also
  modifier-order agnostic now, and its interface arm requires a String
  assignment so a javadoc mentioning "interface" no longer costs a parse.

* Import ambiguity is measured over constant-DEFINING files on both sides.
  Ingestion's harvest gate also admits import-only files, so handing
  `resolveJavaImport` every repo key let a duplicate FQN that defines nothing
  make ingestion alone floor to skip — reopening the same parity break in the
  same losing direction.

* Python's constant harvest is unconditional again. The gate added here
  required NAME immediately followed by `=`, so it dropped `API: str = "/api"`,
  `API: Final[str] = "/api"` and every composed constant whose RHS starts with
  an identifier — routes that already resolve on main. The worker now treats a
  missing heuristic as "harvest" rather than "skip".

* Enum and record declarations were traversed but never collected, so a
  `static final String` declared in one was absent from the map. The walk still
  descends the whole body, so a type nested in an enum-constant body is kept.

* Constants composed across files through a qualified ref never resolved:
  operands found inside an initializer went to the agnostic core, which only
  knows bare names, so `X = BConsts.Y + "/tail"` floored to null even
  acyclically. The Java binding now folds its own expressions — and carries the
  core's guards with them: a `visited` stack popped on unwind, a memo of
  successes, and `MAX_FOLD_LENGTH`. Without the memo a shared-descendant DAG
  re-folds each child per reference; because a chain of empty strings never
  accumulates output, the length cap could not stop it, and one route over a
  31-line constants file took 11 s at 28 levels on the main thread.

* Dropped the dead `com.java.lang.` type normalization.

Cache

* `SCHEMA_BUMP` 70 -> 72. Leaving it at 70 was justified by "the ledger already
  sits at 70, whose capture set post-dates and includes this harvest" — it does
  not: 70 was cut by fe3d7e56b for #2417/#2891, an ancestor of this base. With
  package.json untouched, `PARSE_CACHE_VERSION` was byte-identical across the
  merge, so every same-version warm cache replayed pre-feature captures and the
  feature was inert. 72 rather than 71 because open PR #3017 already claims 71
  with an identical pin test — the ledger's rule is the next value above every
  in-flight claim, not above origin/main.

Tests

* Regression cover for each fix above, including a gate-level test (the gate
  itself had none), an import-ambiguity test, a text-block test, and a 30-level
  shared-descendant DAG that fails by timeout if the memo is ever removed.
* New `group/java-const-route-parity.test.ts` drives `prepareRepo` + a
  three-argument `scan`. Every existing Spring parity guard calls `scan(tree)`
  with ONE argument, and the plugin drops constant-valued routes without a repo
  context — so those guards were structurally blind to this whole feature.
* The pipeline e2e now proves the warm run is a REPLAY (`usedWorkerPool` false)
  instead of only comparing route sets. It was not one: the test never persisted
  the durable ParsedFile store, so the "warm" run reparsed through the workers
  and would have passed with the cache round-trip completely broken.
* Its dist freshness gate covers every source the pipeline loads, not just
  parse-worker.ts, and prints the loud message the docblock promised.
* The self-import cycle fixture now actually self-imports, so it reaches the
  qualified-ref recursion and its depth cap.
* Removed the dead `WIN_POST_MAPPING` fixture and the claim behind it: Spring
  alias recognition is an exact-name map on this base, so `@WinPostMapping`
  extracts zero routes no matter how its value folds (#2883 is still open).
  Fixtures now use annotations this branch actually recognises.

* fix(routes): widen the Java constant-file gate to match its extractor

Answers the gitnexus-check round on 43a0ff290.

The gate was still narrower than the extractor it feeds, in two ways the
extractor explicitly supports:

* `static final String` was matched as an ADJACENT pair, but the extractor
  scans modifiers independently (`isStaticFinal`), so `static public final
  String PATH = "/x";` — legal Java — was extracted when parsed and never
  parsed, because the gate returned false.
* the type had to be the bare token `String`, but the extractor also accepts
  `java.lang.String`, so `public static final java.lang.String PATH = "/x";`
  was skipped the same way.

Both are the same defect class as the ingestion/group divergence this predicate
was introduced to prevent, one layer down: a cost gate that is narrower than
the thing it gates silently drops facts. The modifier run is now matched as a
span excluding `;{}()`, so every legal order and the qualified type name are
admitted while precision holds — a local `String s = "x"` inside
`static void f() { … }` still does not match, because reaching it from `static`
crosses `(`, `)` and `{`. `final` is deliberately not required: the gate may be
wider than the extractor, never narrower.

Also: the worker's harvest condition moves into `shouldHarvestModuleConstants`
in `language-provider.ts`. The rule that is easy to get backwards — a provider
declaring no `moduleConstantHeuristic` harvests unconditionally — was only
reachable by booting a worker, so the Python tests could assert the extractor
harvests and the provider declares no heuristic while a regression to
`provider.moduleConstantHeuristic?.(content)` still turned the hook off. The
tests now drive the predicate itself, plus the two branches around it.

One finding in that round is not reproducible: the parity helper is not made
unresolvable by its import-only fixture. Every `resolveJavaImport` call site
passes the fold state's `constantKeys` — files with `literals`/`exprs` — not
`repo.keys()`, so a same-FQN class defining nothing creates no ambiguity. That
filtering is what the helper exists to exercise, and the test is green.

---------

Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-25 09:41:57 +01:00
azizur100389
e87b1c3ffd
fix(php): gate imports by Composer autoload map (#2987)
* fix(php): gate imports by Composer autoload map

* fix(php): handle Composer catch-all mappings

* test(php): clarify Composer fallback coverage

* bench(php): fold Composer into canonical arm

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-25 07:56:41 +01:00
dependabot[bot]
94d53eda8e
chore(deps)(deps): bump js-yaml from 5.2.3 to 5.3.0 in /gitnexus (#3024)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.3 to 5.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.3...5.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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>
2026-08-24 12:45:52 +01:00
dependabot[bot]
dee06266b5
chore(deps)(deps): bump node-addon-api from 8.9.1 to 8.9.2 in /gitnexus (#3002)
Bumps [node-addon-api](https://github.com/nodejs/node-addon-api) from 8.9.1 to 8.9.2.
- [Release notes](https://github.com/nodejs/node-addon-api/releases)
- [Changelog](https://github.com/nodejs/node-addon-api/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nodejs/node-addon-api/compare/v8.9.1...v8.9.2)

---
updated-dependencies:
- dependency-name: node-addon-api
  dependency-version: 8.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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>
2026-08-24 09:45:30 +01:00
dependabot[bot]
06a6a24197
chore(deps)(deps): bump uuid from 14.0.1 to 14.0.2 in /gitnexus (#3025)
Bumps [uuid](https://github.com/uuidjs/uuid) from 14.0.1 to 14.0.2.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v14.0.1...v14.0.2)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 09:44:53 +01:00
Parafee41
11a60e6de3
fix(ingestion): index JavaScript module extensions (#3034) 2026-08-24 08:27:23 +01:00
dependabot[bot]
f1386a12de
chore(deps)(deps-dev): bump vitest from 4.1.10 to 4.1.11 in /gitnexus (#3027)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.10 to 4.1.11.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 08:23:42 +01:00
nerdCopter
aac7515d2a
fix(deps): override sharp >=0.35.0 to remediate libvips vulnerabilities (#2993)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
2026-08-19 07:21:32 -07:00
nerdCopter
e4b8a48042
fix(deps): override adm-zip >=0.6.0 to remediate memory allocation vulnerability (#2992)
Remediates GHSA-xcpc-8h2w-3j85 (DoS via crafted ZIP file 4GB memory allocation in onnxruntime-node).

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-19 04:22:42 +00:00
azizur100389
b77d6f662b
fix(kotlin): resolve imports from declared packages (#2990)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
2026-08-18 20:47:30 -07:00
Shane Thurston Wijaya
fc885a4bf3
docs(claude-skills): bind repository and worktree identity in multi repo skills (#2981) 2026-08-18 14:09:09 +00:00
azizur100389
87dc6c4d00
fix(go): gate imports by module path (#2984) 2026-08-18 14:31:40 +01:00
azizur100389
7f0ab16ffe
feat(routes): support JS data route tables (#2972)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-18 04:39:45 +01:00
MyShining
fe3d7e56be
feat(spring): detect non-HTTP handler entry points (#2891)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-16 15:16:21 +01:00
azizur100389
dac33d8056
fix(java): resolve imports from declared packages (#2955)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Resolve Java imports against parsed package declarations, expand package wildcards deterministically, and keep external imports unresolved when no in-repo package declares them.

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-15 11:53:28 +01:00
dependabot[bot]
c75c29047d
chore(deps)(deps-dev): bump @types/node in /gitnexus (#2971)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.1.2 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 09:17:39 +01:00
Gergő Magyar
28187bb3a7
fix(typescript): resolve imports against declared config, not path suffixes (#2953) (#2956)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(typescript): resolve imports against declared config, not path suffixes (#2953)

TypeScript/JavaScript/Vue import resolution ended in `suffixResolve`, which
answers "does any file in this repo have a path ending in this specifier?" and
answers it by dropping leading segments until something matches. That is not
module resolution, and it failed in both directions at once:

  - `@acme/telemetry/nest`, a registry dependency with no in-repo file, landed
    on the repo's only path ending in `nest/index.ts` — a false IMPORTS edge at
    confidence 1.0, indistinguishable downstream from a real one. The reporter
    measured 44 of 74 `apps/ -> packages/` edges landing on two such files.
  - `@repo/utils`, a first-party workspace package, resolved to nothing: its
    name lives in `packages/utils/package.json` and appears in no file path, so
    a path matcher cannot find it. Zero CALLS from 75 import statements.

Both come from the same missing input — nothing read the config that says what
exists — so both are fixed by reading it.

Replaces the suffix matcher on this path with the algorithm tsc and Node
actually run, in their order: relative/absolute, `#imports`, tsconfig `paths`
(longest literal prefix wins, every target tried), tsconfig `baseUrl`, then the
workspace package's own `exports`/`main`. A specifier none of those declare is
external, and resolves to nothing. There is deliberately no fallback.

New:
  - `typescript/tsconfig.ts` — every tsconfig/jsconfig in the repo with
    `extends` chains resolved, nearest-config-wins per file. The old loader read
    three filenames at the repo root, required `paths` to exist, and kept only
    `targets[0]` — none of which describes a monorepo, where `apps/web/
    tsconfig.json` is what governs `apps/web/src/main.ts`.
  - `typescript/module-resolution.ts` — the algorithm.
  - `typescript/file-candidates.ts` — 11 TS-family extensions, replacing a
    shared 39-entry list spanning every indexed language, so a TypeScript
    import can no longer resolve to a `.py` file.
  - `import-resolvers/node-workspace-packages.ts` — in-repo manifests, with
    `exports` subpath maps, patterns, condition nesting, and the restriction
    that a package declaring `exports` exposes only what it lists.

The per-pass `SuffixIndex` is gone from these three adapters: real resolution
derives nothing from the file list — every candidate comes from a declared
source and is checked with one `Set.has` — so there is nothing left to cache.
Their `*-import-index-reuse` guards and the JS index-vs-scan differential are
deleted with the mechanism they measured; the cross-language contract test
moves the three languages to its existing `KNOWN_UNINDEXED` channel, and pins
the exemption as a list so a fourth arrival is deliberate.

Python, Ruby, Java, Go and the rest still route through `suffixResolve` and are
untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* test(scope-resolution): assert every resolver refuses external imports (#2953)

One property, for all 16 registered resolvers: a specifier naming something
outside the repository must not resolve to a file inside it.

That is the property #2953 was filed against, and its violation is not a
missing edge but a fabricated one — an IMPORTS edge at full confidence between
two files with no relationship, which `impact` then reports as blast radius.
The mechanism is shared (`suffixResolve`), so the guard is too.

Every case pairs an external specifier with a DECOY: an unrelated in-repo file
whose path ends the way the specifier does. Without one a resolver that merely
found nothing would pass while holding no property at all, so each case also
asserts the decoy is reachable by the spelling that SHOULD find it — a typo in
a fixture cannot manufacture a pass.

Two fixtures had to be corrected before the results meant anything, and both
would have recorded a false gap:

  - C# reads its #1881 gate from scanned namespace evidence and fails OPEN
    without any, so passing `undefined` measured nothing. Armed, C# holds.
  - C++ was posting a pass on an extension mismatch (`vector` could never match
    `src/vector.hpp` whatever the resolver did). Given the header spelling, it
    does not hold.

Result: six hold it — TypeScript, JavaScript and Vue because they resolve
against declared config only (#2953); Python (#898) and C# (#1881) because they
gate the fallback on in-repo evidence; Rust because `::` never decomposes into
a path suffix, which the decoy-reachability arm confirms is a real pass rather
than a vacuous one.

Ten do not, and are recorded in KNOWN_GAPS with what each currently answers:
Java, Kotlin, Go, Ruby, PHP, Dart, Swift, C, C++, COBOL. The map is a work
list, not an allowance — the entries are ASSERTED, so a language that starts
holding the property fails here and its line gets deleted deliberately rather
than rotting into a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): admit only declared workspace packages, and fix four resolver defects (#2953)

Review of #2956 found one boundary bug and four correctness defects. The
boundary one is the same defect class this PR exists to fix, arriving from a
different direction.

## The workspace boundary (review)

`loadNodeWorkspacePackages` registered every `package.json` the repo-wide scan
found, and never read `pnpm-workspace.yaml` or a root `workspaces`
declaration. Finding a manifest is not the same as the workspace admitting one:
an app importing registry package `foo` would bind to an excluded fixture or
example that happens to declare `name: "foo"` — the false-positive half of
#2953, from a new source of evidence. This repository is the example, since
`test/fixtures/**` declares `@repo/utils` among others.

The admitted set now comes from the declaration — `workspaces` (array and yarn
object form), `pnpm-workspace.yaml`, `lerna.json`, with `!` exclusions and
`*`/`**` — plus the root package itself. A repo that declares no workspace has
exactly one package: the root. A negative fixture pins it, with a named package
outside the declared globs that must not resolve.

## Four defects

  - tsconfig `paths` targets were resolved against the config's own directory
    when it declared `paths` but inherited `baseUrl`. tsc resolves them against
    the EFFECTIVE base, so an extending config loaded the right alias pattern
    and pointed every target at the wrong directory.
  - two configs in one directory were ranked by directory-listing order, so
    `tsconfig.base.json` could govern instead of `tsconfig.json` and a config's
    own `paths` went invisible. Found by the test written for the fix above.
  - an unexported package subpath also tried `<dir>/src/<subpath>`. Nothing
    declares that mapping; it is the same kind of guess this PR removes, and
    the import it "resolved" is broken in the real project too.
  - `imports` pattern keys (`"#internal/*"`) were looked up exactly, so a valid
    `#internal/foo` never matched. `exports` and `imports` now share one
    matcher, which is where they should never have diverged.
  - a relative specifier climbing past the repo root was silently clamped, so
    `../../../secret` from `src/main.ts` became `secret` and could resolve a
    root file it never named.

## Test rigor

The conformance suite asserted less than it claimed. The decoy-reachability arm
only checked non-empty, so five cases paired `reachesDecoy` with a different
file than `decoy` and passed while establishing nothing; the KNOWN_GAPS arm
likewise accepted any in-repo answer instead of the recorded one. Both now
assert the exact file. The reachability arm runs only for languages that HOLD
the property — for a gap language the recorded-answer assertion IS that proof,
and for Swift and COBOL no other spelling exists, since `Foundation` and
`EXTERNAL` name the in-repo directory and copybook as well as the external
module, which is precisely why those resolvers cannot tell them apart.

## Benchmarks

Both `--check` guards were red, and both were reporting something true.

`import-target`: the ts-family arms resolved 0 of 3200 imports. Their corpus is
bare specifiers with no config, which the deleted `suffixResolve` answered
without one — so the arms measured an empty branch while printing a clean
scaling ratio. Each now carries the config its corpus is spelled for, and the
`deep` arm's uniform prefix reaches it. THE FINGERPRINTS THEN MATCHED THE
RECORDED BASELINES EXACTLY: same corpus, same targets, once the config it
always implied is passed explicitly. Retained per-pass index went from
26 745 296 B (js, ts) and 28 884 016 B (vue) at 32 000 files to 0-16 B, because
these resolvers no longer build one; they move to the `HEAP_BOUNDED` tier rust
already occupies for the same reason. Depth ratio moved 2.0 -> ~2.2 and the
budget goes to 2.6: candidates now carry the 16-segment baseUrl prefix, so each
`Set.has` hashes a longer string — linear in path LENGTH, independent of file
COUNT.

`scope-capture`: TypeScript capture fingerprint drift, caused by this PR's 12
new `.ts` fixtures entering the corpus. Attribution is exact rather than
inferred — moving that one fixture directory aside returns the fingerprint to
`f719163e…` byte-for-byte with `fixture_count` back at 155 and all 15 languages
passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): honour exports fallback arrays, paths precedence and package extends (#2953)

Second review round. Four findings, judged against what this tool is: a static
analyser building a code graph, not a compiler. The bar is resolving what the
project DECLARES, on a checkout that may never have been built or installed,
and never inventing an edge.

  - `exports` and `imports` ARRAYS were skipped. An array is Node's ordered
    fallback list, and `{"./feature": ["./dist/feature.js", "./src/feature.ts"]}`
    is exactly what a workspace package publishes to mean "built output, or
    source". Skipping it dropped the declaration entirely and left the package
    looking as though it exported no subpaths. The source arm is the one that
    matters here, because `dist/` is build output and is not indexed — and for
    a static analyser the build need not have run at all.
  - an exact `paths` pattern did not reliably outrank a wildcard. `a` and `a*`
    both match `a` with the same literal prefix length, so sorting on length
    alone left tsc's exact-wins rule to declaration order.
  - package-form `extends` (`"@acme/tsconfig"`) was refused outright. Not
    indexing `node_modules` is different from not READING it, and a shared
    internal base is where a monorepo puts the `paths` its packages import
    through. It is now read from disk, walking `node_modules` up from the
    extending config the way Node does, and absent on an un-installed checkout
    it degrades to whatever that config declared itself.

    The test pins what tsc actually does with such a base rather than what one
    might hope: `extends` never rebases `baseUrl`, so a package base's paths
    point at the package's own directory. That is why a published base rarely
    contributes aliases a repo's files resolve through, and why the
    `@tsconfig/*` family — which sets `target` and `lib`, never `paths` — is a
    no-op here either way.
  - CodeQL flagged `String.replace('*', …)` in two places as replacing only the
    first occurrence. Node subpath patterns and tsconfig `paths` both allow AT
    MOST one `*`, so that IS the specified behaviour — but the spelling states
    it by accident and reads as the replace-all footgun. `substituteStar`
    slices at the known index and says the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): treat `exports` as the whole interface, and keep empty tsconfig scopes (#2953)

Third review round. Two findings, both valid, both cases of this resolver being
laxer than the thing it models — which is the direction that fabricates edges.

  - `exports`, when a manifest declares it, is the package's ENTIRE public
    interface: Node ignores `main` outright and refuses any subpath the map
    does not list. This resolver already honoured that restriction for
    SUBPATHS and not for the package ROOT, which is the same rule. A manifest
    exporting only `"./feature"` therefore still answered a bare `@repo/pkg`
    with `main` or `src/index` — an edge for an import that does not resolve in
    the real project. Legacy and conventional root candidates are now offered
    only when there is no `exports` field at all.

  - a tsconfig declaring neither `baseUrl` nor `paths` was dropped rather than
    kept as an empty scope, so `tsconfigFor` fell through to an enclosing
    config. A package whose own tsconfig declares no `baseUrl` — meaning its
    non-relative specifiers are package lookups — silently inherited the repo
    root's aliases instead. An empty scope is the accurate answer for such a
    file, and only a scope can express it.

Both are pinned at the level they broke: the manifest arms assert what
`readManifest` produces, not a hand-built package, since the resolver honouring
empty entries and the loader producing them are different claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:13:04 +01:00
dependabot[bot]
ac5626b160
chore(deps)(deps-dev): bump tsx from 4.23.11 to 4.23.12 in /gitnexus (#2958)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.11 to 4.23.12.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.12)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 07:52:01 +01:00
Gergő Magyar
77360e1043
fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) (#2939)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912)

Interface-dispatch fan-out walked the subtype closure with generic arguments
erased, so `IValidator<string>` and `IValidator<int>` — one declaration, one
subtype list — were indistinguishable and a call through the first reached
`IntValidator.Check(int)`, a target no runtime dispatch can produce.

The arguments were already in the capture, unread: every language anchors
`@reference.inherits` on the whole base node while `@reference.name` keeps the
erased base. `ReferenceSite.typeArguments` is therefore derived generically in
`scope-extractor.ts` from the anchor's own spelling — no per-language query
changed — covering C#, Java, TypeScript, Kotlin, Go (`Base[int]` embedding),
Python (`Base[User]`) and Swift; Rust and Dart anchor on the bare name and get
nothing, which reads as "unknown". `preEmitInheritanceEdges` is the only code
that pairs a heritage site with a resolved (subtype, supertype), so it records
the instantiation there and hands it to the dispatch pass.

The closure is then walked carrying a substitution, as a type checker would:
`Wrapper<T> : IValidator<T>` binds T to the receiver's argument and stays
reachable from every instantiation, while its own subtypes are matched against
that binding. An incompatible hop is skipped without being marked seen, so a
type reachable by a second, compatible path still gets its edge, and without
descending, since its subtypes inherit the mismatch.

Pruning happens only on positive evidence that two instantiations differ.
Unknown arguments on either side, an arity that does not line up, an unresolved
qualified spelling of the same simple name, or an argument that might be a type
variable the language never captured all keep the target. Telling an uncaptured
type VARIABLE from a concrete type is the crux: `typeParameters` is absent both
for a non-generic declaration and for every declaration in a language whose
query omits `@declaration.type-parameters`, so the pass reads the evidence in
front of it — one run resolves one language, so a single generic declaration
anywhere in it proves the captures record parameters. A language recording
neither arguments nor parameters keeps exactly its pre-#2912 fan-out.

Type arguments are compared as resolved declarations rather than spellings, so
`Models.User` and an imported `User` are one type; the new optional
`ScopeResolver.normalizeTypeArgument` hook canonicalizes a language's predefined
aliases, implemented for C# (`string` ≡ `String`) where mixing the spellings
would otherwise delete a real implementor.

Fan-out cap, skipped-target reporting, overload selection and non-generic
closure behaviour are unchanged. SCHEMA_BUMP 60 -> 64: the heritage arguments
are a parse-time capture, so a warm cache would replay pre-fix sites and leave
the filter silently inert on unchanged files (61/62/63 are claimed by open PRs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* fix(scope-resolution): close the two generic-dispatch gaps (#2912)

The first commit left two shapes on the pre-#2912 fan-out. Both are now
covered, and the second one turned out to need a route the pipeline did not
have at all.

**Folded receivers (Cases 0 and 3b).** `this._validator.Check(x)` is typed by
the compound fold, and the fold answers with a CLASS — which is exactly what
loses the instantiation, since `IValidator<string>` and `IValidator<int>` fold
to one declaration. The fold now reports the SPELLING it typed each receiver
position from, through a pure side channel (`recordReceiverType`) added to the
one helper every declared-type route already shares plus the two return-type
routes; resolution is unchanged whether or not a caller passes it. The reader
keeps the last report and uses it only when it names the class the fold
returned, so an intermediate position cannot lend its arguments to another
class. This covers the dependency-injection shape the issue is really about —
a field-held generic interface — and multi-hop chains, where it is the last
hop's spelling that types the receiver.

**Rust and Dart heritage.** Neither recorded arguments, for two different
reasons, so both routes exist now:

  - Rust's `@reference.inherits` anchor is the trait identifier INSIDE a
    `generic_type`. Widening the anchor would move the site's range, and that
    range is part of every inheritance edge's id, so the arguments arrive
    through a new `@reference.type-arguments` sub-tag instead.
  - Dart's `implements` / `with` never become reference sites at all: they
    travel as heritage MARKERS and their edges are emitted by the language
    hook. The arguments ride the marker payload as an optional fourth field
    (dropped, not encoded, when the spelling contains the marker delimiter),
    and `ScopeResolver.emitHeritageEdges` now receives the same sink
    `preEmitInheritanceEdges` writes to, so whichever pass emits an edge
    records that edge's instantiation. Dart also gained the
    `@declaration.type-parameters` capture, without which its own type
    VARIABLES are indistinguishable from concrete arguments and
    `class Box<T> implements Validator<T>` would be pruned from every
    instantiation.

Note this makes Rust and Dart record their instantiations; it does not make
them fan out. Interface dispatch still fires only for a receiver whose folded
type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class`
receiver has no secondary targets to filter. Widening that gate emits new
edges for several languages and belongs to its own issue.

**Two matcher rules the wider coverage exposed.** A WILDCARD names a set of
types rather than one — `Repo<? extends User>` holds a `Repo<User>`, and
Kotlin's `Repo<*>` / `Repo<out User>` say the same — so a position with one on
either side is unknown; nullable spellings trip the same test, which costs a
little precision in the safe direction. And insignificant whitespace inside a
nested spelling (`Map<string, User>` vs `Map<string,User>`) is no longer a
difference.

One expectation changed in the #2833 field-receiver matrix: a
`Repo<Repo<User>>` receiver no longer reaches `UserRepo implements Repo<User>`.
That edge is precisely the false positive this issue is about, and the primary
edge to the interface's own declaration — which is what the matrix row exists
to prove — is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* refactor(scope-resolution): apply the quality pass to the #2912 change

Three cleanups, no behaviour change.

**One balanced-list scanner, not two.** `erasedTypeApplication` and
`typeApplicationArguments` each carried a copy of the same fiddly scan — one
bracket list, balanced, closing on the last character, non-empty — differing
only in what they did with the result. Both now call `balancedTailList`; the
rule that rejects `User[][]` and `Repo<User>?` lives in one place instead of
being free to drift between two.

**The receiver's arguments are parsed after the gates, not before them.**
`emitInterfaceDispatchFor` takes the receiver's declared SPELLING and parses it
itself, once the owner is known to be an Interface with subtypes. Every one of
the five cases calls it unconditionally and the overwhelming majority of
receivers are concrete classes that return at the first line, so the parse was
running per resolved receiver site to be discarded immediately. Case 4 and Case
6 now hand over the string they already hold, and the folded-receiver helper
returns the recorded spelling rather than parsing it.

**One question gates the whole instantiation apparatus.** Inside the closure
walk, the graph-id lookups now hang off "is the supertype's instantiation
known?" — false for every non-generic receiver and for every language that
captures no heritage arguments, which is what makes those walks cost exactly
what they cost before #2912.

Also lifted the argument-route choice in `pass5CollectReferences` out of a
nested ternary into a named `heritageTypeArguments`, where the reason the
explicit sub-tag wins over the anchor text can be stated once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* test(scope-resolution): cover generic interface dispatch in Kotlin and Go (#2912)

Extends the #2912 dispatch coverage past C#/Java/TypeScript. No production
code changes — the derivation is language-agnostic by construction
(`heritageTypeArguments` reads the heritage anchor's own spelling), so the
question was only which languages actually reach the filter.

Kotlin rides the shared heritage pre-pass; Go reaches the same filter from
the other side, matching implementors structurally while the receiver's
`Validator[string]` spelling carries the instantiation. Both are confirmed
to prune the mismatched implementor.

Each language gets a NON-GENERIC control asserting the fan-out still reaches
every implementor. Without it the `not.toContain` assertion passes just as
well when a language emits no dispatch edge at all — which is what Dart,
Python and Rust were measured doing for this receiver shape, generic or not.
They are deliberately not asserted on here: a "filtered correctly" test over
a path that never fans out measures nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* test(bench): re-baseline the Rust and Dart capture fingerprints for #2912

The Rust trait-impl and Dart heritage capture changes this branch makes are
additive TEXT on existing matches — each carries the instantiation the clause
was written with — so they drift the scope-capture digest without adding or
removing a match. The baselines were never re-measured when those captures
landed, which left `measure.mjs --check` red on this branch independently of
the merge.

Re-measured rather than hand-edited. Rust's capture_groups_fp (3556) and
fixture_count (202) are unchanged across the move, which is the evidence that
this is digest drift and not a capture-set regression. The other 13 languages
are byte-identical; 15/15 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* refactor(scope-resolution): quality pass over the #2912 change

Cleanup only — no behavior change. Findings from a four-angle review (reuse,
simplification, efficiency, altitude), applied where they were verified.

Reuse / duplication:
* `stripTrailingCallSuffix` was a second copy of `matchingOpenParen`'s backward
  balanced-paren scan. Both now live in `template-arguments.ts` beside
  `balancedTailList`, for the reason that helper was shared in the first place:
  two copies of a scan this fiddly are free to disagree.
* The two call-return arms of the compound fold repeated the same four-part
  expression character for character; they share `classOfReturnType` now, the
  return-type twin of `classOfDeclaredType`, which keeps the "look up by
  rawName, report the erased application" pairing in one place.
* `pipeline/run.ts` implemented first-writer-wins twice — once in the pre-pass
  and once in the provider sink. One store, one sink, one rule; the pass keeps
  its `Set<string>` return and the callable-flow-only arm stops building an
  empty map to satisfy a widened return shape.

Simplification:
* `subtypeParametersComplete` dropped a disjunct that could never decide: every
  `subDef` reaching it comes out of the same loop that sets
  `languageCapturesTypeParameters`, from exactly those defs.
* The heritage-argument lookup asked "is the supertype's instantiation known?"
  three times; `superGraphId` now gates the block once.
* `TypeArgumentResolver` and `HeritageInstantiationResult` un-exported — no
  consumer outside their module.

Efficiency (all on the per-site dispatch walk):
* `resolveSupertypeArgument` captures only the site, so it is built once per
  site instead of once per subtype visited; the subtype's scope id is looked up
  once per subtype instead of once per argument position.
* `erasedTypeApplication` no longer runs on every fold hop through a call — the
  spelling is built only once the lookup has found a class, since it is
  discarded otherwise.
* `normalize`+`compact` computed once per side rather than twice.
* Regex literals and the identity `normalize` fallback hoisted to module scope.
* C# `System.` prefix stripped with `startsWith`/`slice` instead of a regex.

Verified: tsc clean, build clean, 1994 scope-resolution unit tests, 171
generic-dispatch + generic-field-receiver integration tests, 15/15 capture
bench fingerprints unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* style: apply Prettier to the two files the quality pass reformatted

Whitespace only — `quality / format` (npx prettier --check .) was red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(scope-resolution): close the generic-dispatch review findings (#2912)

Addresses the gitnexus-check review on #2939.

A repeated type variable was rebound rather than unified: `class C<T> :
Pair<T, T>` accepted a `Pair<string, int>` receiver, with `T = int` silently
replacing `T = string` and the bogus substitution carried to the next hop.
It now unifies, and prunes only on the same positive evidence the concrete
path demands — an undecidable repeat keeps the target with no binding.

A type PARAMETER of the declaration enclosing either side is now recognised
and never compared. `subtypeParametersComplete` is evidence about the
SUBTYPE's parameter list and says nothing about a `T` written at the call
site, so `void Run<T>(IValidator<T> v) { v.Check(x); }` pruned every
implementor: unbounded, `T` grounds to nothing; bounded, it grounds to its
BOUND. Both read as a difference of type. That is the missing-edge failure
this filter is built to avoid, and it is the common dependency-injection
shape in C#, Java and Kotlin.

Making that recognition reliable is why generic METHODS now capture
`@declaration.type-parameters` in C#, Java and Kotlin — TypeScript already
did, which is why its generic functions never had the defect. The capture
feeds the existing `bindsTypeParameter` guard, so a method-level `T` also
stops resolving to a same-named class in every other lookup.

C# alias normalization additionally strips the `global::` qualifier, which
`import-decomposer` already unwraps elsewhere: `global::System.String` read
as unequal to `string` and pruned a live implementor.

The C# captures golden fixture is regenerated for the new capture; the
extractor reads `@declaration.type-parameters` generically, so no reader
changed. SCHEMA_BUMP 64 already covers these capture changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(scope-resolution): close the two remaining gitnexus-check findings (#2912)

`balancedTailList` counted ONE bracket family, so a crossed pair slipped
through: scanning `Foo<Bar]>` it never sees the `]`, reaches the final `>` at
depth zero, and reports `Bar]` as a balanced argument list — which
`typeApplicationArguments` then splits and `erasedTypeApplication` rebuilds a
spelling from. It now tracks a stack of expected closers, so every closer must
match the opener it actually closes and a crossed pair declines to `undefined`,
the "unknown" both callers already fail open on. Well-formed mixed nesting
(`List<Dict[a, b]>`) is unaffected.

C# `normalizeTypeArgument` stripped `System.` from every qualified spelling, so
`System.Custom` answered `Custom` and compared equal to an unrelated `Custom`
elsewhere in the workspace. The strip is now earned: a keyword answers from the
alias table first, and the qualifier is dropped only when what remains IS a
predefined type. `System.Custom` is returned as written and goes to the identity
comparison instead — the step that can actually tell two declarations apart.
`global::System.String` still meets `string`.

Both are pinned by unit tests, including the well-formed mixed nesting and the
`global::`-qualified ordinary type that must keep its qualifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* docs(csharp): record why a shadowed `String` keeps its implementor (#2912)

Answers a review finding rather than changing behavior.

A workspace may declare its own type named `String`, shadowing the BCL simple
name, and the alias table then reads `IValidator<String>` as the `string`
instantiation and keeps that implementor. That is the SAFE direction, not an
oversight: pruning instead would rest on the belief that two spellings differ,
which is the missing-edge failure `generic-instantiation.ts` exists to avoid.

Resolving rather than normalizing cannot settle it either — the identity
comparison needs a `definitionId` from both sides, and a built-in name carries
none, so "built-in versus workspace-declared implies different" would be a new
prune with no positive evidence behind it. The cost is one surplus edge for that
pair, which is exactly the pre-#2912 fan-out and no worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* refactor(scope-resolution): pair the receiver spelling with the class structurally (#2912)

The fan-out needs the spelling a receiver position was typed from, because the
class the fold returns has lost the generic arguments. That was carried by a
PASS-LEVEL mutable holder, written by every declared-type lookup anywhere in the
fold and read back through a def-id coincidence check, with the holder cleared
by hand before each call site. Three things were load-bearing and none were
enforced:

* the reset had to be remembered at every call site. It was not: the Case 3b
  retry (`rawName` then `rawName + '()'`) reset once, BEFORE the first attempt,
  so a spelling reported by the attempt that failed could be attributed to the
  one that succeeded.
* the holder outlived every resolution, so a site that resolved through a route
  reporting nothing could read the previous site's spelling if the def ids
  happened to line up.
* the pairing itself was inferred from "whichever lookup reported last", not
  from the fold's own bookkeeping — losing branches (an MRO walk that moved on,
  a step later folded past) report too.

`foldReceiverChain` already had the answer and threw it away: its final
`FoldState` holds `def` and `declaredType` produced by the SAME step. It now
reports that pairing last, so the structural route is the one that stands.

`resolveCompoundReceiverTyped` returns `{def, declaredSpelling}` and owns a sink
created and read within the single call, which is what removes the reset
discipline — a local cannot be forgotten, and each of the two retry attempts
carries its own. The def-id guard stays as the check that a report names the
class actually returned.

Behavior is unchanged: 1975 scope-resolution unit tests, 177 generic-dispatch
and generic-field-receiver integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:50:53 +01:00
azizur100389
3d4a95360d
fix(java): materialize record component accessors (#2936)
* fix(java): materialize record component accessors

* fix(java): ignore receiver params in record accessor arity

* fix(java): address record-accessor review findings (#2917)

Five findings from the tri-review of #2936.

P1 — a synthesized callable evicted a source-written one from the method map.
`getMethodInfo` keyed its per-class map by `name:line`, but a callable that is
SYNTHESIZED at a position that is not its own declaration shares its owner's
line: a record's implicit accessor is minted at the component, and a C# 12
primary constructor at the owner's `parameter_list`. Both are appended last by
their extractor, so on a single line the synthesized entry overwrote the
explicit method's MethodInfo and both definitions collapsed onto one id —
`record P(int x, int y) { int x(int s) {...} }` lost `P.x#1` and rebound the
arity-1 call to the zero-argument accessor. Adds a required `MethodInfo.column`
and keys the map by `name:line:column` through a single `methodInfoKey` helper.
Required, not optional: an absent column would key an entry no lookup could
reach — a silent, whole-language loss of enrichment instead of a compile error.
All three lookup sites move together; the file's own lockstep docblock warns
that a half-applied change loses caller edges silently rather than dangling.
This also fixes the same collision in C#, which never touched record code.

Degenerate component names no longer mint a node. tree-sitter's zero-width
MISSING recovery token satisfies `name: (identifier)`, so `record M(int x, y) {}`
minted an empty-named Method whose returnType was the neighbouring `y`; and the
grammar admits `underscore_pattern` in the same field, which the query rejected
but the scope path accepted, so `record R(int _) {}` left a scope declaration
with no node behind it. One `isRecordComponentName` predicate now gates all
three emitters — query suppression, scope synthesis, and the method extractor —
so they cannot drift apart again.

Component annotations reach the implicit accessor (JLS 8.10.3 / 9.7.4) by
reusing the shared `extractAnnotations` helper. Deliberately over-approximate
and commented as such: `@Target` lives in another file and parsing is per-file.

`explicitZeroArgAccessorNames` is memoised per record node. It was rebuilt on
every component capture — O(components x body members) for one record, measured
at ~4x per 2x input — while the scope path already hoisted the identical call.

Docs: the `java-local-types` baseline now stores the `capture_groups_fp` its own
note cites, the SCHEMA_BUMP ledger no longer claims a v65 that nothing holds,
and `shouldSkipDefinitionCapture` documents that `defaultLabel` may be ignored.

Scope-capture fingerprints are unchanged (`measure.mjs --check` PASS, 15
languages): the bench corpus contains no degenerate components, so the new
predicate is inert on it. SCHEMA_BUMP stays 67 — this branch's existing claim
already covers the changed worker output; re-check it against origin/main before
merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* docs(ingestion): reunite the overload-suffix JSDoc with typeTagForId

The block describing the `~type1,type2` same-arity discriminator was stranded
above `buildCollisionGroups` when that function was inserted between it and the
`typeTagForId` it documents (#658). Adding `methodInfoKey` in this branch parked
it directly above yet another unrelated function, which gitnexus-check flagged.

Moves the comment down to the function it describes. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

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>
2026-08-13 08:43:11 +00:00
azizur100389
cdc98a9cf8
fix(java): capture enum interface heritage (#2935)
* fix(java): capture enum interface heritage

* fix(java): harden enum heritage dispatch

* test(java): refresh synthetic capture baselines

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-13 07:04:00 +01:00
Gergő Magyar
d540b00184
fix(check): stop reporting erased and deferred imports as initialization cycles (#2934)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
2026-08-12 17:09:32 +00:00
Gergő Magyar
2be508e796
fix(mcp): stop scaling the detect_changes query with the diff's hunk count (#2915) (#2930)
* fix(mcp): map diff hunks to symbols without per-hunk OR conditions (#2915)

`detect_changes` folded one `(n.startLine <= $hunkEndI AND n.endLine >=
$hunkStartI)` pair per diff hunk into a single WHERE clause, one query per
changed file. A machine-generated file (cache JSON, lockfile, golden fixture)
diffs at thousands of hunks with `-U0`, and the expression tree that produces
overflows LadybugDB's recursive evaluator copy on a TaskScheduler worker
thread: a bare SIGBUS with no error output where secondary threads get 512 KB
of stack (macOS), a swallowed 30s query timeout where they get more (Linux),
which the CLI then printed as "No changes detected." with exit 0.

Coalesce each file's hunks into sorted, disjoint ranges and run the overlap
test in JS instead. Only ranges that overlap or abut are merged, so the union
covers exactly the lines the raw hunks covered. Query text and parameters are
now identical whether a file changed in 1 place or 100,000, and files are
queried in batches of 100 rather than one full node scan each.

Reproduced on Linux by running the engine with macOS-sized (512 KB) thread
stacks: 2,500 hunks passed, 3,333 and 4,000 segfaulted — matching the reporter's
macOS threshold table. After the change the same repo maps a 100,001-hunk diff
in 2.1s with no crash.

Also fixes a line-base mismatch the rewrite exposed: graph rows are 0-based
(#2377) while git hunk lines are 1-based, so the raw comparison shifted every
symbol one line up. An edit to a symbol's LAST line reported nothing changed —
a one-line function whose body was edited was invisible to the pre-commit gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* fix(cli): say when a detect_changes result is partial (#2915)

When a graph query fails, `detect_changes` swallows the error, sets
`partial: true` and leaves the counts at zero (#2283). The CLI formatter never
read that flag, so a degraded run printed "No changes detected." and exited 0 —
the pre-commit safety gate reporting a clean bill of health for a check that
did not complete. Print the partial note in both the empty and non-empty
branches.

Also restore the `Symbol` placeholder for rows whose label came back as an
empty string: the changed-symbol mapping now keeps `''` instead of dropping it
to undefined, so the formatter needs `||`, not `??`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(mcp): bound the hunk→symbol query and simplify the overlap helpers (#2915)

Cleanup pass over the #2915 fix. No change to which symbols detect_changes
reports, except that a node matched by two changed paths is now reported once.

* Push a per-file [lo, hi] span into the query. Coalesced ranges are sorted and
  disjoint, so a file's whole touched span is free, and the engine can drop the
  symbols outside it instead of shipping every row in the file across the native
  boundary. Measured on a 400-file batch against a 25k-node index: 546ms/13,870
  rows before, 84ms/1,555 rows after, identical kept set. Depth stays constant
  (two comparisons per file, not per hunk), so #2915 cannot come back — the JS
  test still rejects symbols landing in the gaps between hunks. The struct-list
  parameter was verified against @ladybugdb/core 0.18.3 and 0.19.1.
* Convert hunks into the graph's 0-based space once, at the point they are
  grouped, with the existing `toZeroBasedLine`. Every comparison downstream is
  then base-neutral, and `toDisplayLine` goes back to being what its doc says it
  is: an MCP response-boundary converter, not a filter input.
* Deduplicate matched nodes by id. `ENDS WITH` is a plain string suffix, so a
  diff touching both `README.md` and `pkg/README.md` counted the same node
  twice (169 duplicates in 13,870 rows on a real 400-file diff). Pre-existing,
  free to fix now that the rows are shaped in one place.
* Drop the positional `?? sym[N]` row fallbacks in this block.
  `executeParameterized` returns `getAll()` rows, which are alias-keyed objects,
  so the fallbacks were dead — and they coupled the mapping to RETURN column
  order, which is what made adding a column a renumbering exercise.
* Build the path→hunks map in one pass, so "every value is coalesced" holds at
  every point rather than being repaired by a second loop. Simplify
  `coalesceHunks` (the length<2 branch and the sort tiebreaker changed nothing)
  and state `hunksOverlapRange` as a standard half-open lower bound.
* Document `partial` in the detect_changes tool description. The CLI now prints
  it, but the MCP client — the main consumer of the pre-commit gate — was
  getting the flag as an undocumented raw key.
* Tests: pin the query text as identical for a 1-hunk and a 3,000-hunk diff
  (replacing a magic length bound), pin the 0-based bounds parameter, pin the
  dedup, and fold two near-identical row mocks into one helper. Temp dirs now
  come from the shared pool helper, whose cleanup is per-directory and
  Windows-lock aware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* feat(mcp): bound and batch the hunk→symbol query, and anchor its path match (#2915)

Follow-up review pass on the #2915 fix, implementing every remaining finding.

* Push a per-file `[lo, hi]` span into the query. Coalesced hunks are sorted and
  disjoint, so a file's touched span is free, and the engine drops the symbols
  outside it instead of shipping every row in the file across the native
  boundary. Measured on a 25k-node index, 400-file batch: 546ms/13,870 rows
  before, 84ms/1,555 after, identical kept set. Depth stays constant (two
  comparisons per file, not per hunk), so #2915 cannot return. The struct-list
  parameter was probed against @ladybugdb/core 0.18.3 and 0.19.1 first; the
  index-subscript form `$paths[i]` does not parse.
* Anchor the path match: `n.filePath = b.path OR n.filePath ENDS WITH b.suffix`
  where suffix is the path with a leading separator. A bare `ENDS WITH` is a
  plain string suffix, so a diff touching `lib/a.py` also reported a symbol from
  an indexed `src/mylib/a.py` — a file the diff never touched. This is the form
  `explain` already uses. Pinned by an integration test against a real engine
  (it fails 3/3 with the un-anchored predicate).
* Run batches a few at a time. `executeParameterized` checks a connection out of
  the 8-connection per-repo pool for the duration of a query, so parallel calls
  never share one — the same reason ~15 other queries in this file already run
  under `Promise.all`. `allSettled`, so one failed batch degrades the result to
  `partial` instead of discarding the batches that succeeded beside it.
* Deduplicate matched nodes by id, and count `changed_files` as distinct paths:
  a path can appear twice in one diff (a rename reported alongside an edit).
* Cap the listed symbols at 1,000 with `symbols_truncated: {listed, total}`.
  A repo-wide diff otherwise puts an unbounded array in one MCP payload — the
  CLI has `--limit`, an MCP client has nothing. Counts are never capped, so the
  risk level and the CLI's "... and N more" still see the true total.
* Extract `chunk` / `mapBatches` / `LBUG_QUERY_BATCH_SIZE` into
  `core/lbug/query-batch.ts`. Every query built from a caller-sized array has
  this ceiling; the shape now has one name and the measured batch size is
  recorded where it is defined rather than in three constants under three names.
* Move hunk grouping and the 0-based conversion into `coalesceHunksByPath`, at
  the parse boundary. `parseDiffHunks` stays faithful to git (1-based, like the
  `@@` headers it reads), consumers compare graph-native values, and the
  conversion is unit-testable instead of living in the backend.
* Document `partial` and `symbols_truncated` in the detect_changes tool
  description — the MCP client is the main consumer of the pre-commit gate and
  was getting both as undocumented raw keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(core): batch every remaining repo-sized query list (#2915)

`detect_changes` was not the only place building query text from a
caller-sized array. `core/wiki/graph-queries.ts` interpolated the whole file
list of a module into four `IN [...]` literals, growing the query with the
repo — flat breadth rather than the nested depth that crashed #2915, but the
same unbounded shape, and the one the repo's own `DELETE_FILES_CHUNK_SIZE`
precedent already chunks elsewhere.

All four now run one query per batch and merge in JS. The membership arms need
care, and each is documented where it happens:

* `getIntraModuleCallEdges` batches the caller arm only. A per-batch callee arm
  would drop a call from batch 0 to batch 2, both inside the module, so that
  predicate moves to JS against the whole set. Results are now sorted: the
  single-query form had no ORDER BY, and batch order would hand the entire
  30-edge window `formatCallEdges` keeps to the first 100 files (#2787).
* `getInterModuleCallEdges` keeps the SAME batch list in its `NOT` arm. That is
  sound — a file outside the module is outside every batch — and it preserves
  the null handling: `NOT null IN [...]` is null, so the original dropped edges
  to a node with no filePath, where a JS-only `!has(undefined)` would admit
  them. ORDER BY and LIMIT move to JS because a per-batch limit would cut rows
  before the cross-batch membership filter ran.
* `getProcessesForFiles` keeps `LIMIT` inside the batch: `stepCount DESC, id` is
  a total order, so a process in the global top-N is in its own batch's top-N.

Also adopt the shared `chunk()` at the hand-rolled slice loops in
`lbug-adapter.ts`, `embeddings/http-client.ts` and `run-analyze.ts`. The loops
whose index fed a progress callback or an error message use
`chunk(...).entries()`, which removes the `i / SIZE` and `Math.floor(i / SIZE)`
arithmetic rather than reproducing it. No batch size changed.

One trap that survived tsc and is worth naming: after renaming a loop variable
away from `chunk`, a leftover `chunk.length` silently resolved to the imported
FUNCTION's arity, reporting `chunkSize: 1` for a 200-path batch. Only
`lbug-query-importers-batch`'s exact-value assertion caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor: name the line-base conversions and share the symbol line (#2915)

The 0-based-graph vs 1-based-elsewhere rule was open-coded in five places with
the reasoning living only in comments — the same rule that, applied by hand and
skipped once, hid every last-line edit from `detect_changes`.

* Add `toOneBasedLine` beside `toZeroBasedLine` in `ingestion/utils/line-base.ts`
  so the module owns both directions, and adopt it at the four CFG/PDG join
  sites in `pdg-impact.ts` and the two in `local-backend.ts`. This is NOT
  `line-display.ts`'s `toDisplayLine`, which is documented as a response
  boundary converter with an `undefined` passthrough; the joins need
  arithmetic, and the guards that produce `Number.NaN` for an absent line are
  kept verbatim.
* `http-route-extractor.ts` probed graph spans with a bare `line - 1` and a
  20-line comment. It calls `toZeroBasedLine` now; the `?? pick(line)` fallback
  arm is untouched, so which node is picked cannot change (the clamp differs
  only for a negative line, which no emitter can produce).
* Extract `formatSymbolLine`: `detect-changes-format.ts` and `eval-server.ts`
  rendered the same `type name → filePath` line. One behavior note — the two
  were not byte-identical, and eval-server had no placeholder on `name`, so a
  definition with an empty name rendered the literal `undefined` and now
  renders `?`. Both `definitions[]` shapes set name from a graph row, so this
  is unreachable in practice, and printing `undefined` into LLM-facing output
  is the bug, not the intent.

`||` (not `??`) in the placeholders is deliberate and documented: a node label
can come back as an empty string and still needs the placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* perf(wiki): bind the module file list instead of splicing it into the query (#2915)

The wiki's four `IN [...]` sites interpolated every file of a module into the
query text, so the text grew with the repo — the shape that overflowed
LadybugDB's recursive evaluator copy in `detect_changes`. The previous commit
chunked them, which worked but cost real complexity: the callee arm had to leave
Cypher and be re-implemented in JS, DISTINCT had to be re-established across
batches, and ORDER BY/LIMIT had to move to JS so a per-batch window could not cut
rows the cross-batch filter still needed.

Binding the list as a parameter removes the reason for all of it. The text is
constant at any list length, and measured against a real index a bound list is
~3x faster than the equivalent literal (5,000 items: 139ms vs 459ms; 20,000:
598ms vs 1,686ms). Every predicate goes back into Cypher, including the `NOT ...
IN` arms whose null handling is load-bearing — `NOT null IN [...]` is null, so a
callee with no filePath is dropped by the engine, where a JS membership test
would have admitted it.

Verified on this repo's own index: a 2,000-path bound list returns 14,856 rows in
877ms.

Also collapses the per-process step query into one grouped `p.id IN $ids` fetch —
105ms to 13ms for 20 processes — and drops `fileListLiteral`, `callEdgeKey`,
`compareProcessHeaders` and the batching loops with it. `compareStrings` was a
byte-identical re-roll of `compareCodeUnits` (src/lib/utils.ts), including its
#2787 rationale; it now calls the shared one.

Intra-module edges are sorted where the original had no ORDER BY: `formatCallEdges`
keeps only the first 30, and an unordered cut keeps a different subset per machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(core): one home for batching, and a backstop for the shape that crashed (#2915)

`chunk` moves to `src/lib/utils.ts`, the repo's generic-utility home: it is an
array helper, and leaving it in `core/lbug/query-batch.ts` made an HTTP
embedding client import batching from the graph-DB namespace. `query-batch.ts`
keeps what is actually about queries — the measured `LBUG_QUERY_BATCH_SIZE`, the
concurrency helper, and the ceiling — and now documents the preference the wiki
change proved: bind the list as a parameter first, chunk only when you cannot.

`mapBatches` becomes `mapConcurrent`: nothing about it is batch-specific, and it
now has non-query callers. Its body is a per-item try/catch plus `Promise.all`,
so ordering comes from the primitive rather than from unwrapping a settled
union. The wave barrier stays — measured against a rolling window it is 538ms vs
532ms on a 1,000-file diff, whose per-batch times spread only 1.35x.

Adopted at the loops that were still hand-rolled: `file-hash.ts`,
`cluster-enricher.ts` (its progress callback now accumulates `batch.length`
instead of clamping an index), `filesystem-walker.ts` and `language-config.ts`
(wave scheduling with `allSettled`, which is exactly `mapConcurrent`).
Deliberately not adopted, each for a stated reason: the analyzer-identity probe
runs as a standalone `node -e` script with no module resolution; the embedding
sub-batch loop slices two parallel arrays and breaks early;
`walkRepositoryPaths` reports progress from inside each wave, which
`mapConcurrent` cannot express.

`warnIfQueryTextUnbounded` is the backstop: #2915 died in native code with no
message, and a query built by concatenating a caller-sized list is the shape
that gets there. Wired at both execution chokepoints (`pool-adapter`'s
`executeParameterized`, `lbug-adapter`'s `executePrepared`/`streamQuery`; their
`executeQuery` siblings delegate and are covered once). It never throws — a long
query the engine can actually run must not start failing on a heuristic — and it
is deliberately absent from the raw write path, where a node's `content` is
inlined and a large source file would warn legitimately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(mcp): name the path-match rule, and key detect_changes by node id (#2915)

* `path-predicate.ts` names the three ways a caller's path can match a stored
  `filePath` — `exact`, `pathSuffix`, `fragment` — instead of each call site
  copying whichever idiom its neighbour used. A bare `ENDS WITH` is a plain
  string suffix, which is how a diff touching `lib/a.ts` came to report a symbol
  from `src/mylib/a.ts`; the loose `CONTAINS` sites are loose ON PURPOSE (a user
  hint of `src/mcp` should match a directory fragment), and naming the modes is
  what lets a call site choose rather than inherit.
* `detectChanges` kept four structures over one row set — an array, a dedup Set,
  an id list and an id→name Map — that had to stay in sync by hand. One
  id-keyed Map is all of them; insertion order is preserved, so every output is
  byte-identical.
* `symbols_truncated: {listed, total}` becomes `truncated: true`, the key
  `explain`/`pdg_query`/`trace` already use. The true total was always in
  `summary.changed_count`, so the nested object said nothing the existing
  vocabulary could not.
* `GraphLineRange` is now a distinct type from `DiffHunk`: they carry the same
  two fields in different bases, and mixing them IS #2377. The name means a
  1-based hunk cannot reach `hunksOverlapRange` without a conversion between.
* `coalesceHunksByPath` accumulates raw ranges and coalesces once per path
  rather than re-sorting on every occurrence.
* `chunk` adopted at this file's own five loops — the point of extracting it —
  including two locals named `chunk` that shadowed the import. That shadowing is
  not cosmetic: it is how a leftover `chunk.length` silently became the
  function's arity earlier in this branch.

One bug caught by the real-engine integration test and worth naming: Cypher
comments are `//`, not `--`. A `--` comment inside the query string made
LadybugDB reject the whole query at PREPARE, which `detect_changes` swallows
into `partial` and renders as "No changes detected." Every mocked unit test
passed. Prose stays out of query strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(test): share the git-repo bootstrap, and move the shared formatter out (#2915)

`formatSymbolLine` lived in `detect-changes-format.ts` but is rendered by
`eval-server`'s query formatter too, so a `query` formatter imported from a
`detect_changes` module. It moves to `src/cli/format-symbol.ts`; both callers
import it from there. The `||`-not-`??` fallbacks stay documented — a node label
can come back as an empty string and still needs its placeholder.

`test/helpers/temp-git-repo.ts` gives `initGitRepo(dir, identity?)` and
`commitAll(dir, message)` to the ~10 test files that hand-rolled the same
`git init -q` + two `git config` + `add -A` + `commit` sequence. It takes a
directory and never owns one, matching `temp-dir-pool.ts`'s split of lifecycle
from seeding; the identity is a parameter because the existing consumers
genuinely disagree about it, and each keeps exactly what it configured. Four
files stay hand-rolled for stated reasons — pinned author dates for a
deterministic digest, remote handling, `--allow-empty`, and the `-c key=value`
form that never persists to the repo.

Test trims: the `formatSymbolLine` fallback cases collapse into one `it.each`
table (the case pinning that BOTH consumers emit the helper's exact line stays —
no table row can express it); two `line-base` cases that were compositions of
their neighbours go; and `detect-changes-path-anchoring` runs its
`detect_changes` call once in `beforeAll` instead of three times, keeping the
three named failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* perf(mcp): filter the batched hunk query before the engine materialises (#2915)

`UNWIND $bounds AS b MATCH (n) WHERE …b…` compiles to a CROSS_PRODUCT whose
build side is a RESULT_COLLECTOR over the whole filtered node table: only the
`n`-only predicates get pushed below the accumulate, so neither the anchored
path match nor the [lo, hi] span could reduce the scan. Measured at 1M nodes:
+242 MB for one batch and +922 MB for the four concurrent ones, paid even for a
one-file diff — and at a 268 MB buffer pool the query died with `Buffer manager
exception` where the old per-file query completed, landing in `partial:true` +
`changed_count:0`, the #2915 false clean by another route.

Adding the batch-wide, `b`-free disjunction as a redundant leading conjunct
lets the planner push it below the accumulate: EXPLAIN now shows it as FILTER[2]
directly under SCAN_NODE_TABLE[0]. It is a provable superset of the correlated
predicate, so it cannot drop a row the correlated filter keeps. 10x less memory,
~20% faster, identical result sets.

Also in detect_changes:
- Sort rows on (filePath, startLine, id) before the 1000-symbol cut. The cut was
  slicing engine row order — measured 5 distinct orders across 8 runs on one
  connection, the #2787 class this branch fixes 200 lines away in the wiki.
- Chunk `symIds`, the one caller-sized list left unbatched: 500k ids measured
  4.0 GB RSS. Binding keeps the query TEXT constant, which is all the unbounded
  guard measures, while the bound VALUE stayed repo-sized.
- Prefer exact path equality and widen to the anchored suffix only for paths
  that matched nothing, so a root README.md stops reporting pkg/*/README.md.
- Report `risk_level:'unknown'` rather than 'low' when a query was swallowed.
  A degraded pre-commit gate must not read as an all-clear.
- Pass --no-ext-diff --src-prefix=a/ --dst-prefix=b/. `diff.noprefix` in a user's
  gitconfig makes git emit `+++ f.py`, which parseDiffHunks cannot match, so every
  run printed "No changes detected." and exited 0 before any query ran. A diff
  that parses to zero files now raises `partial` instead of the clean branch.
- `labels(n)`, not `labels(n)[0]`: labels() returns a scalar string here, so the
  subscript was always '' and `type` never carried a label.
- Validate IMPACT_MAX_CHUNKS. The chunk() adoption turned an entry condition into
  an exit condition, so a non-numeric value ran every chunk instead of none.
- Record why four-way concurrency is safe here, and scope the arm64 sequential
  comment to the query it was written for (#496).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(cli): fail the detect_changes gate instead of exiting 0 when it degrades (#2915)

The secondary half of #2915 was that a swallowed query failure printed
"No changes detected." and exited 0, so a shell pre-commit gate passed on a
broken analysis. This branch added the PARTIAL text. It did not change the exit
status, so `gitnexus detect-changes && git commit` still proceeded.

`detectChangesCommand` passed a STRING to `output()`, and `output()` sets a
failing code only for an OBJECT carrying `error` — under a comment calling
itself "the one place that keeps scripted callers honest". A string never
matches, so this command opted itself out of the only mechanism the file
provides. It was broader than `partial`: the formatter also renders a backend
`{error}` payload as text, so hard failures exited 0 too.

Fixed narrowly in `detectChangesCommand`, following the object-first shape
`checkCommand` already uses, rather than widening `output()`'s shared contract —
every one of its other seven callers already passes an object and is unaffected.
One code for both `error` and `partial`: `&&` only distinguishes zero from
non-zero, and a softer code for `partial` would invite `|| [ $? -eq 2 ]`
exemptions that reopen exactly this hole.

`truncated` deliberately stays exit 0 — only the listing is capped, while the
counts and risk are computed over the full set, so the verdict is sound and
failing on it would fire on every large-but-healthy diff.

Also wires `truncated` through the formatter, which this branch had left as a
producer-only flag while `partial` went end to end, with the note in both
locales and no count of its own so the existing "... and N more" line stays the
sole numeric report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(wiki): restore step order and symbol labels, and cut the edge list in Cypher (#2915)

Found by running the queries against a real engine, which nothing did before:
this branch's regrouped `withSteps` returned step traces OUT OF ORDER.
`ORDER BY pid, r.step` combined with `WHERE p.id IN $ids` silently drops the
second sort key — `proc_1_incrementalupdate` came back 2,7,1,3,4,5,6.
`ORDER BY step` alone is correct, and so was the pre-branch per-process query,
so this was introduced by the batching. `formatProcesses` prints
"${s.step}. ${s.name}", so every module and overview page was getting scrambled
execution traces. The mocked suite passed 112/112 before and after.

`labels(x)[0]` is always the empty string: labels() returns a scalar string and
the subscript is 1-based over its characters ([1] is "F"). `prompts.ts` renders
"${s.name} (${s.type})", so all 5,027 exported symbols reached the LLM as
"name ()".

`getIntraModuleCallEdges` shipped every edge to use 30 — measured 18,299 rows
and 851 ms with all 2,079 paths bound, against 30 rows and 94 ms with
ORDER BY + LIMIT in Cypher, which the sibling `getInterModuleCallEdges` twenty
lines below already did. The determinism fix (#2787) was right; the placement
was not. `compareCallEdges` goes with it — it was intransitive when a name was
null or empty, so `Array.sort` was input-permutation dependent, i.e. the
nondeterminism it was added to remove.

Deletes the positional row ABI this branch newly documented. The vendor
declaration is `getAll(): Promise<Record<string, LbugValue>[]>` — string keys
only — and `row[0]` probes back `undefined`; the same PR deleted ~30 identical
fallbacks from local-backend.ts. They were already stale here: `withSteps`
prepends `p.id AS pid`, so `toProcessStep` was reading the pre-branch layout.
Rows are now typed by alias, so renaming an `AS` is a compile error. `??` for
`||` so a step of 0 or an empty label keeps its own value.

Tests: a real-engine integration suite covering all seven exported queries
(PREPARE included — the trap that shipped a `--` comment on this branch), and
the four holes that let the ordering bug through — a vacuous order assertion, a
LIMIT never reached by a 2-edge fixture, a fake that returned rows pre-ordered
and ignored ORDER BY, and a hardcoded `type: 'Function'` that hid labels().

The step-ordering fixture is empirically sized: 2 processes never reproduced the
bug, ~400 step edges was intermittent, 710 (20 processes x 26-45 steps) hit
11 of 11 runs. Seeded descending and interleaved so no grouping looks sorted by
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* refactor: put the shared helpers where their callers are, and make their contracts true (#2915)

`mapConcurrent` moves to lib/utils.ts beside chunk(). Nothing about it is
query-specific and it already had filesystem callers, while its docstring
justified concurrency safety through the per-repo connection pool — an argument
that does not apply to fs.readFile. This is the precondition the branch's own
commit message stated ("it now has non-query callers") and then did not apply.
LBUG_QUERY_BATCH_SIZE and warnIfQueryTextUnbounded genuinely are query-specific
and stay.

`pathMatch`/`PathMatchMode` deleted: zero callers, and none of the three sites
its docstring cited were migrated, so the tree carried the abstraction and the
copies it was written to replace. `pathSuffixOf` stays and the module now
documents the anchoring rule it actually implements.

Contracts that were not true:
- QUERY_TEXT_CEILING_BYTES was compared against `cypher.length` — UTF-16 code
  units, not bytes — so non-ASCII query text was undercounted and the reported
  KB was wrong. Buffer.byteLength now, behind a `length * 3 <= ceiling` early
  return so only text over ~21 KB pays for the count.
- chunk(items, NaN) returned [[]], against a docstring promising never to return
  an empty slice, and mapConcurrent's Math.max(1, NaN) propagated it — which
  would have resolved [] for non-empty input with no error, read as "no results"
  by every call site.
- GraphLineRange claimed a 1-based hunk could not reach hunksOverlapRange
  without a conversion, but it was structurally identical to DiffHunk so tsc
  accepted one with no diagnostic, and coalesceHunks<T extends GraphLineRange>
  actively laundered the base while its accumulator was still DiffHunk[]. The
  useless generic is gone and a one-line phantom on each interface makes the
  claim real; a bare {startLine, endLine} literal still satisfies both, so no
  construction site needs a cast.

Pure deletions no longer vanish. A -U0 deletion emits `+N,0`, which
parseDiffHunks dropped, so the file survived with no hunks, no query ran, and
detect_changes reported `changed_files:1, changed_count:0, risk_level:'low'` —
"No changes detected." for a commit that deleted a function. A unified diff
spells an empty range as the line before it, so the anchor is line N alone:
a symbol containing the deleted text also contains N, while extending to N+1
would claim a symbol that merely starts after the gap — the widening
coalesceHunks guarantees it never does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* docs: say that a partial or truncated detect_changes is not a clean gate (#2915)

The gate itself now fails loudly, but the instructions every agent reads still
described a zero as a result. Fixed at the source: AGENTS.md's gitnexus block is
generated from a template in cli/ai-context.ts and injected into every user's
repo, so the sentence goes there and AGENTS.md/CLAUDE.md are regenerated through
the real code path (which also picks up a pre-existing `analyze --index-only`
drift the committed docs were behind).

That block is under a test-enforced size cap with 30 characters of headroom, so
the 144-character clause was paid for in the same currency: the header
exhortation, which the Always Do list restates as MUSTs with commands, and a
verbatim repeat of the detect-changes command in the regression-compare example.
3549 of 3552. Worth noting for whoever adds the next line — #2899 replaced an
absolute cap with a 0.65 ratio to let "a legitimate clause fit without
ceremony", but set the ratio flush against the block's then-current size, so it
is a ratchet with no ratchet.

The canonical block does not make the skills redundant: three of the four
install channels ship skills without touching AGENTS.md, --skip-agents-md does
the same in-repo, and a user-trimmed gitnexus:keep block legitimately has no
Always Do section — in those repos the skill file is the only carrier. Precedent
agrees: the risk:UNKNOWN rule is deliberately carried in both places. So one
sentence each in gitnexus-work (the commit gate), gitnexus-impact-analysis
(beside the UNKNOWN paragraph) and gitnexus-refactoring, whose post-hoc "verify
only expected files changed" is the worst of the three because a degraded result
makes it vacuously pass. gitnexus-taint-analysis is left alone: its audience is
always inside this repo, where the canonical block loads.

All copies mirrored to npm, plugin and cursor. The cursor copies are condensed
checklists rather than byte-mirrors, so they carry the equivalent note placed
where it governs every detect_changes line in the file — and nothing tests that,
since standard skills are fragment-checked rather than byte-compared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* refactor: break the seven small import cycles gitnexus check reports (#2915)

`check` reported 11 cycles. Five are paths inside a single 257-file strongly
connected component in core/ingestion (call-extractors / cfg visitors /
utils/ast-helpers), with a second 26-file component behind it — fixing those
paths would only make check print different ones, so both are left for their own
PR. This closes the seven that are genuinely separable, taking the graph from 9
strongly connected components to 2.

Six of the seven were one value import plus one `import type` edge. tsconfig
sets neither verbatimModuleSyntax nor isolatedModules, so those edges erase
entirely — the cut is a graph and readability change with no emitted-JS
difference. Each moved type went to a leaf module, with a re-export left behind
only where an importer outside the change actually needed it:

- cli/ai-context <-> cli/skill-gen: GeneratedSkillInfo -> cli/generated-skill.ts.
  One importer, no package export surface, so a clean move with no re-export.
- cli/analyze-config <-> cli/analyze (+core/run-analyze): AnalyzeOptions ->
  cli/analyze-options.ts. Re-export kept because a test imports it from
  analyze.js. run-analyze needed no edit — cutting the one type edge collapses
  the 3-file component into a DAG. Its own same-named AnalyzeOptions is a
  different interface and was deliberately not merged.
- ingestion/import-resolvers/types <-> ingestion/language-config: type-only in
  BOTH directions, so it had no runtime existence at all. ImportConfigs has no
  importers outside the pair and is the return type of loadImportConfigs, so it
  moved into language-config. Side effect worth having: the shared resolver
  types module no longer names a single language, which is an AGENTS.md rule for
  core/ingestion shared pipeline code.
- ingestion/di-extractors barrel <-> spring: DiResolver and the two match types
  -> di-extractors/types.ts, following the import-resolvers/types.ts precedent.
- scope-resolution/walkers <-> workspace-index: WorkspaceResolutionIndex ->
  workspace-index-types.ts. Re-export is load-bearing — 9 src importers, 4 test
  files, and a dynamic import() at contract/scope-resolver.ts. Moving the value
  isClassLike instead was rejected: ~15 value importers, and it is documented as
  a pair with isShapeLike.
- server/analyze-worker <-> analyze-worker-core: the WorkerMessage protocol ->
  analyze-worker-protocol.ts, a declarations-only leaf.

storage/branch-index <-> storage/repo-manager was the one genuine two-way
runtime cycle: branch-index called getStoragePaths/loadMeta, repo-manager used
branchSlug/BRANCHES_DIR. branch-index's header conceded the cycle and argued it
was ESM-safe because neither side calls across at module-evaluation time — a
guarantee resting on call ordering rather than structure. Folding
resolveBranchPlacement back the other way does not help, because
BranchSummary.stats is typed RepoMeta['stats'], so RepoMeta had to move either
way. Extracted storage/repo-meta.ts, a leaf importing only fs and path, holding
the metadata read primitives; repo-manager re-exports the public names so all
54 RepoMeta and 50 loadMeta importers are untouched. The moved block diffs
byte-identical against HEAD.

Verified beyond typecheck, because the worker entrypoint is the risky part and
nothing in the suite forks it: emitted analyze-worker.js still contains exactly
one runtime import, and forking the real worker over IPC boots it through
entry -> core -> protocol -> terminal-claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* refactor: apply the reuse, simplification, efficiency and altitude cleanups (#2915)

The one that mattered: the degradation exit code was fixed at the wrong depth.
`output()` has never inspected `partial` — it tests `error` only — so putting
the check in `detectChangesCommand` left every other tool exiting 0 on a
degraded run. `partial` is cross-tool vocabulary: query (enrichmentDegraded ||
ftsPartial), impact (!traversalComplete, perSymbolEnrichmentCapped) and the
mode:'pdg' envelope all emit it. A truncated impact traversal returns a short
caller set and an under-ranked risk, then exits 0 — so `gitnexus impact … &&
<edit>` proceeds, in the tool AGENTS.md makes a MUST gate before every edit.
The justification also cited checkCommand as precedent, but checkCommand passes
STRINGS too — it was the second command already hand-rolling around this gap,
while output()'s docstring called itself "the one place that keeps scripted
callers honest". output() now takes an optional renderer and fails on error OR
partial; two hand-rolled sites go away and three tools are covered instead of
one. truncated stays exit 0 (only the listing is capped) and checkCommand's
cycleCount policy stays put.

Efficiency, all re-measured on the 25k-node index:
- The process lookup was chunked with LBUG_QUERY_BATCH_SIZE, calibrated for the
  opposite query shape — that constant is for a whole-node-table scan where more
  items amortise the scan, while this is an `id IN $ids` probe where round trips
  dominate. 20k ids: 617ms at 100, 261ms at 1000. New LBUG_ID_PROBE_BATCH_SIZE,
  documented against its sibling so they cannot be re-merged. This also settles
  the older "chunking this query is a regression" measurement — that was chunk=100.
- The sort comparator re-coerced fields ChangedSymbolRow already types, O(n log n)
  redundant conversions (+31-38%). Row shape probed directly: alias-keyed, no
  positional keys, numeric columns are JS numbers.
- exactlyMatchedPaths built two throwaway arrays; one loop instead (40k rows
  11.4ms -> 4.5ms).
- The integration fixture seeded 710 step edges one round trip at a time; one
  UNWIND instead. File wall time 6.91s -> 3.63s. Fixture size unchanged — its
  docstring records the threshold below which the bug stops reproducing, and the
  mutation check still fails 3/3 when ORDER BY step is reverted.

Reuse and simplification:
- CALL_EDGE_LIMIT existed in four places; its own docstring predicted the drift
  it then caused. prompts.ts owns it now — it is a zero-import leaf so the
  direction cannot cycle, and had graph-queries.ts owned it the four suites that
  vi.mock that module would have left slice(0, undefined), silently returning
  every edge in exactly the tests meant to police the cap.
- Six dead positional row fallbacks survived the rewrite in the loop this branch
  re-indented, in the same PR that deleted the identical ABI from graph-queries.ts.
- Two test files independently modelled the same labels() scalar-string quirk.
  Deleted the wiki one — the file's own new header says semantics belong in the
  real-engine test — and kept projectTypeColumn, the only instrument that can see
  the bug for the detect_changes query.
- makeRepo onto the shared git bootstrap (the eleventh copy of the sequence the
  helper was extracted to own), the duplicate diff-args unwrapper merged into
  test/helpers, hand-rolled comparators onto compareCodeUnits, real-timer sleeps
  replaced by wave-released promises with a strengthened per-wave assertion.
- Re-exports trimmed to what is actually imported, a cross-reference this branch
  invalidated by moving mapConcurrent, and a "~20% faster" claim that does not
  survive at real index sizes (1-9%; the 10x memory win does).

Also adds the drift guard the new doc text lacked: fragment coverage for the
partial/truncated paragraph in every skill copy and in the managed AGENTS.md /
CLAUDE.md block. Falsifiability checked — none of those fragments exist at the
merge base.

Not done here, deliberately: 27 live labels(x)[0] projections remain across
impact/context/query/trace and MCP resources, with four load-bearing workarounds
that have begun depending on each other and one that fabricates rather than
degrades. That is a semantic change to five agent-facing tools and wants its own
PR, scoped to delete the workarounds too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(cli): restore the detect-changes subcommand in the regression example (#2915)

Caught by the gitnexus-check bot on the PR. The regression-review fallback in
the injected mandate rendered as `--scope compare --base-ref "main" --repo .`
with no command, so anyone copying it invokes the runner with an option as its
first argument.

Self-inflicted, and by exactly the mechanism flagged when it landed: the block
is under a test-enforced size cap (#856) that had 30 characters of headroom, so
adding the partial/truncated clause required paying for it, and the 38-character
"repeat" that was dropped turned out to be the subcommand rather than a repeat.

Paid for the restoration out of the clause instead — both parentheticals are
gone, since `partial` and `truncated` are already defined in the tool
description this text points at. Block is back under the cap at 3548/3552.

Notably the cap has now been raised four times (2700 -> 2900 -> 2950, then
0.55 -> 0.65) each with the argument that the new line is load-bearing, and it
has now also caused a user-facing defect. It is not functioning as a budget.
Left at 0.65 here rather than making it five: moving the threshold to fit one's
own text is how it got here. Worth restructuring separately.

The fragment guard added a commit ago caught the rewording immediately, which
is what it is for; its fragments now pin the two policy claims rather than the
prose around them, since that prose is what gets re-trimmed under the cap.

Also verified and NOT changed: the bot's other error, that detect_changes
compares 1-based hunks against 0-based graph lines. `bounds` is built from
`coalesceHunksByPath`, which applies `toZeroBasedLine` to both ends at the
grouping boundary, and both a mocked and a real-engine test pin an edit landing
on a symbol's last line. The bot read `parseDiffHunks` in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(core): reject a fractional chunk size, and stop the truncation note overclaiming (#2915)

All five from the gitnexus-check bot's pass on the previous push; two were
introduced by the cleanup round that preceded it.

`chunk` guarded with `Number.isFinite`, which admits a fractional size — and
that one does not fail, it DUPLICATES. `slice` truncates its indices while `i`
does not, so size 1.5 yields slice(0, 1.5) = items 0-1 then slice(1.5, 3) =
items 1-2, putting item 1 in two batches; a caller batching a query would send
it twice. A size is a count, so `Number.isInteger`. Unreachable today (every
caller passes a constant) but the guard existed precisely for the unreachable
case, and the NaN half of it was already there.

`mapConcurrent`'s per-item degradation contract had a hole: `onError` is
caller-supplied and was invoked outside a try, so a throwing reporter rejected
`settle`, rejected the whole `Promise.all` wave, and discarded the neighbouring
successes the function exists to preserve. Reporting a failure must not become
one.

The CLI truncation note asserted "the counts and risk level still cover all of
them", which is true only when `truncated` fires alone — with `partial` the
counts are summed from the batches that succeeded. It now varies: a distinct
string when both flags are set, saying the counts are a lower bound. This is the
same claim already corrected in the tool description; the CLI text still had the
old one.

The di-extractors contract docstring claimed the barrel re-exports everything
from it. That stopped being true when the re-export was trimmed to what is
actually imported, one commit earlier.

The real-engine wiki test claimed to prepare "every exported query" and omitted
`getInterModuleEdgesForOverview`, which `generateOverview` calls. Added — it
aggregates in JS over `getInterFileCallEdges` rather than issuing its own
Cypher, so the note says why it is in a prepare test.

Verified and NOT changed: the bot's other error, that detect_changes compares
1-based hunks against 0-based graph lines. `bounds` is built from
`coalesceHunksByPath`, which converts both ends at the grouping boundary
(storage/git.ts), and two tests pin an edit landing on a symbol's last line.
The remaining seven findings are changed-symbol heads-ups with no signature
change; their callers' suites are green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(mcp): make the IMPACT_MAX_CHUNKS fallback actually fire (#2915)

The validation added earlier this branch used `Number.parseInt`, which takes the
numeric PREFIX: '1.5' parses to 1, satisfies `Number.isInteger`, and silently
caps enrichment after a single 100-item batch — the opposite of the fallback the
comment beside it promised. `Number` instead, so a fractional value is rejected
and falls back to 10.

The emptiness check is load-bearing rather than defensive: `Number('')` is 0 and
0 is a legitimate value here (enrich nothing), so an UNSET variable would
otherwise mean "enrich nothing" rather than "use the default".

Behaviour table, old vs new: '1.5' 1 -> 10 (the bug), and undefined/''/'  '/
'10junk'/'-2'/'all' -> 10, '0' -> 0, '3' -> 3, ' 5 ' -> 5 all unchanged. So the
only case that moves is the reported one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:51:17 +01:00
Gergő Magyar
054641cafa
fix(scope-resolution): resolve a package whose directory name repeats higher in the path (#2881) (#2929)
* fix(kotlin): resolve a root-level package whose name repeats higher in the path

`getKotlinFileIndex` built its `dirChildren` buckets under two guards
inherited from the pre-index per-import scan rather than from anything
Kotlin requires: a `startsWith` test that skipped the bucket when the
path began with the package name, and an `indexOf` equality that
demanded the parent be the FIRST occurrence of `/<name>/` in the path.

`s` is taken as `dir.slice(i + 1)` at each `/`, so `dir` ends with `/s`
by construction and the file always IS a direct child of a directory
named `s`. The guards therefore dropped legitimate buckets:

  data/src/main/kotlin/com/example/data/Repo.kt   (leading, startsWith)
  top/data/mid/data/Repo.kt                       (mid-path, indexOf)

`import data.helper` resolved to null against both. Only the fan-out
tier was affected — `data.Repo` answers from `suffixByStem`, which
carries no such guard — which is why the shape looked narrow enough for
#2872 to preserve rather than change inside a performance PR.

Both guards are removed. The rule stays "the parent directory is named
`s`" — a name that appears in the path without being the parent
(`top/data/mid/Repo.kt` for `data.something`) is still not a child, and
a new case pins that.

Widening is filtered downstream for the fan-out tier, which hands the
finalize pass a candidate list (#1759), but NOT for the tier-1 fallback,
which commits to `children[0]` unfiltered — and that is where most of
the change lands: 149 of the 235 moved corpus records are a different
first child against 32 wider arrays. Both are deliberate. A narrower
bucket for the first-child tier alone would keep its answers identical
and would also leave `import data.*` — a wildcard, which strips to
`data` and lands on exactly that tier — resolving to null on the very
shape this fixes.

Both Kotlin benches are re-baselined deliberately, with the drift
measured rather than accepted:

  - bench/kotlin-import-target: 235 of 19968 distinct records moved.
    54 null -> resolved (the fix, and exactly the +54 in non_null),
    181 answers that changed within a now-larger bucket. Zero buckets
    lost a member, zero results were dropped, and every reselected
    answer's parent directory is the queried package segment. The
    corpus is untouched, so `cases` is unchanged and the fingerprint
    covers the same surface as the value it replaces.

  - bench/import-target: the collide arm needed a corpus edit beside
    the new numbers. Its `d % 7` slice imported `com.example.vendor{d}`,
    a package that exists nowhere, purely to mirror the unique arm's
    nested-slice MISS; with that slice now resolving, leaving it would
    have left collide at 1100 against small's 1153 and broken the
    same-workload invariant the arm is built on. That assertion is what
    caught it.

The gate controls were re-run against the new baseline, including one
the fix makes newly plausible: a HALF fix that drops only `startsWith`
and keeps the `indexOf` check still fails the fingerprint, so a partial
fix cannot land quietly.

Two gates moved with the code rather than being left behind:

  - kotlin `heap_reading_bytes` and `heap_ceiling_bytes` are re-recorded
    together as `_heap_reading_note` requires (48073096 -> 48200224,
    +0.264%, ceiling still 1.5x). The note says why that is small: the
    heap corpus is built with HEAP_PAD 8, so no path can begin with a
    suffix of its own directory and the leading-segment half of the old
    rule is invisible to that arm.

  - `depth_budget` 2.4 -> 2.2. Deleting two string comparisons per
    directory component is per-depth work, so the depth band fell from
    1.44-1.51 to 1.27-1.40; left at 2.4 the gate's headroom would have
    drifted from ~1.6x to ~1.8x without anyone deciding to loosen it.

`package-dir-index.ts` documents the same first-occurrence rule as
universal, and it is not any more: Go, Java and C# still carry it and
still have the shape. Fixing them means re-baselining three languages
and editing the verbatim pre-change scans that
import-target-index-parity.test.ts keeps as the specification, so it is
a separate change — the comment now says so instead of describing a rule
one of its readers no longer follows.

Fixes #2881.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e

* fix(scope-resolution): drop the first-occurrence directory rule for Java, Go and C# too

#2881 was reported against Kotlin, but the rule it removed was never
Kotlin's. It is what the pre-index per-import scan happened to compute —
`indexOf` for the package directory, then "nothing after the match holds
a slash" — and every resolver built to reproduce that scan inherited it.
Three still had it, and all three reproduced the reported defect:

  java   data/src/main/java/com/example/data/Repo.java  `import data.*`  -> null
  java   top/data/mid/data/Repo.java                    `import data.*`  -> null
  csharp Models/src/App/Models/User.cs                  `using Models;`  -> null
  csharp a/Models/b/Models/User.cs                      `using Models;`  -> null
  go     a/internal/auth/b/internal/auth/svc.go   import "internal/auth" -> null

Controls (`top/data/Repo.java`, `a/b/internal/auth/svc.go`) resolve, so
these are the rule firing rather than an unrelated miss.

Four sites, all reduced to "the file's parent directory ends with the
queried path":

  - `package-dir-index.ts` `matchingDirs` (Go, Java, C# without csproj):
    the `indexOf` equality becomes `endsWith`, which also subsumes the
    length guard it needed — a shorter haystack is false instead of
    comparing -1 to -1.
  - `csharp.ts` `matchingDirPositions` (csproj step 3): same, and still
    deliberately UNANCHORED, so `src/SubModels` keeps answering `Models`.
  - `csharp.ts` csproj step 2: `indexOf` -> `lastIndexOf`, EXCEPT for an
    empty `dirPrefix`, which must keep `indexOf`. Its needle is a bare
    '/', and step 3 answers that query from `singleSegmentDirs` ("exactly
    one directory deep"), which only the first occurrence expresses; with
    `lastIndexOf` there, step 2 accepts every `.cs` in any directory and
    diverges from step 3. The csproj parity test catches it.
  - `go.ts` `resolveGoPackage`: `indexOf` -> `lastIndexOf`. No production
    caller, but the parity harness copies it verbatim as its spec.

The two C# csproj sites must move together. Fixing only step 3 makes
`Lib.Models` return step 3's superset instead of step 2's segment-aligned
answer.

Risk is not symmetric across the three. Go's consumer is a fan-out list
and the finalize pass materializes one IMPORTS edge per element, so
widening only ADDS edges. Java and C#-without-csproj commit to a single
file through `firstFileDirectlyInPkgDir` with no downstream filter, so a
widened bucket can also change which file an already-resolving import
binds to — java's collide fingerprints moved while its resolved count
did not, which is exactly that. C#'s leg is additionally gated by
`csharpSuffixFallbackAllowed` (#1881) before resolution runs.

Gates:

  - Twenty fingerprints re-baselined across go, csharp and java (five
    arms plus the top-level alias each). resolved 979 -> 1153 small,
    4064 -> 4681 large for go and csharp; 1100 -> 1153 / 4456 -> 4681 for
    java. No `distinct_outcomes` moved.

  - csharp and java hit the same collide-arm trap Kotlin did: both sent
    their `d % 7` slice to a namespace that exists nowhere purely to
    mirror the unique arm's nested-slice MISS, so once that became a hit
    the arms resolved fewer imports than `small` and the same-workload
    assertion failed. Both now use their arm's ordinary spelling.

  - GO WAS NOT GATED AT ALL and the corpus had to change to make it so.
    Its nested slice repeated only the last segment (`src/pkg{d}/internal/
    pkg{d}`) while a Go query addresses the whole package path, so the
    directory never ended with the query and the rule was never reached —
    every go arm sat unchanged through the resolver fix. `uniqueDir` and
    `collideDir` now repeat the shape at the granularity Go queries.
    `languages.go.heap.path_segments` 13 -> 14 follows from that.

  - `csharp_csproj`'s heap reading moved -0.79% (stable across runs) and
    is re-recorded with its ceiling: the step-2 filter decides which lazy
    `getFilesInDir` maps the probe forces. Everything else stayed within
    +/-0.03%, which is this box's jitter — `_heap_reading_note`'s claim
    that the readings reproduce to the byte across processes did not hold
    here, and the note now says so.

The three parity harnesses keep VERBATIM copies of the pre-change scans
as their specification, so each copy was updated with the resolver and
the cases that pinned the rule now pin its removal. Two of them left the
`mustBeNull` set in the shared harness — they resolve now, which holds
them to the stronger "pin a winner" bar the rest of that arm uses.

Refs #2881.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e

* perf(kotlin): intern dirChildren keys per directory and compact the buckets

Two optimizations to `getKotlinFileIndex`, both output-identical, kept
because they were measured and a third was dropped because it was not.

1. PER-DIRECTORY KEY MEMO. The component walk over `dir` cut one `slice`
   per component per FILE, and every slice after the first file of a
   directory is a freshly allocated string that hashes to a key the map
   already holds and is then dropped. The key list is a pure function of
   `dir`, so it is interned once per DIRECTORY. Measured -18.4% to -21.7%
   of the build at 32 000 files; zero retained cost, the memo dies with
   the frame.

2. BUCKET COMPACTION at the freeze loop. `addChild` mints `[raw]` and
   pushes, and V8 grows a backing store by `old + old/2 + 16`, so the
   SECOND child takes a 1-slot store to 17 and every bucket then retains
   its overshoot. 61 144 buckets at 32 000 files, 52.9% of their slots
   empty, 88 B each. `slice()` on freeze: -5 397 768 B, -11.20%, and the
   predicted 5 382 507 B lands within 0.03% of it. Same fix and the same
   accounting as the python `byBasename` note this repo already carries.

   `length === 1` is skipped deliberately. A bucket that never grew is
   already exact, so slicing it allocates a second array to save nothing
   — on a corpus of single-file packages the unguarded form costs 31% of
   the build for zero bytes.

DROPPED: merging the `dirChildren` walk into the `suffixByStem` walk.
It measures -0.10% at 32k, +0.23% at 100k and +0.40% at one file per
directory, all inside a base-vs-identical-copy noise floor of -2.3% to
+3.1%, and it does not compose usefully with the memo — the second scan
it deletes is exactly the scan the memo makes rare. Only its provably
free half is kept: `stem.lastIndexOf('/')` in place of `norm.lastIndexOf`,
one backwards scan instead of two, exact because an extension carries no
'/'.

Neither optimization is visible to the correctness fingerprint, which is
the point and also the risk: it observes the index only through the four
resolver tiers, so a key-order move no corpus query reaches would survive
it. Correctness therefore rests on a structural comparison of all three
maps — key insertion order, values, bucket contents in order, frozen-ness
— over 1234 corpora in both iteration orders, 14 808 comparisons, zero
failures. The fingerprint, `cases` and `non_null` are unchanged and MUST
NOT be re-baselined by this commit.

Gates that did move, both because a reading and its budget move with the
code rather than when CI goes red:

  - `heap_reading_bytes.kotlin` 48 200 224 -> 42 802 456 with its ceiling
    at 1.5x. A memory WIN passes every arm, so nothing forced this.
  - `depth_budget` 2.2 -> 2.0. The memo turns a per-file component walk
    into a per-directory one, which is precisely the per-depth work this
    arm exists to see: the band went 1.27-1.40 -> 1.20-1.26, and 2.2 held
    over it would have drifted from ~1.6x headroom to ~1.9x.

The gate controls were re-run against the optimized builder, including
one this change makes newly plausible: keying the memo on the directory's
LAST SEGMENT instead of its full path drifts the fingerprint
(36a4e9dad313, non_null 13310 -> 13305). That is the memo's whole
safety argument stated as a test — its key decides which key set a
directory contributes — and it is the one way this optimization could
move an answer. The bucket-cap control was re-run too, since compaction
now rewrites the same buckets.

Also recorded, from measuring a reuse this repo had been invited to make:
replacing `dirChildren` with the shared `package-dir-index` is
output-identical (0 divergences over 107 948 answers) and passes every
arm of the kotlin bench at 1.37x-1.50x — while costing 409x per fan-out
and 8114x on `import data.*` at 200 matching directories on a corpus this
bench does not carry. `_blind_spot` in the kotlin baselines now says so,
with the memory the trade would have bought (26.2%, 12.18 MiB) and the
corpus arm that would have to exist first.

Refs #2881.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e

* fix(scope-resolution): close the gaps a four-lens review found in the #2881 change

Correctness review found no defect in the shipped resolvers — the `endsWith`
rewrites, the C# empty-prefix guard, the memo's purity, the `stem` vs `norm`
derivation, the Map re-`set` during iteration and Go's `substring` arithmetic
were each attacked with running code and each held. Everything below is a gap
in what the change ASSERTS, measures or claims.

UNGATED BEHAVIOUR, now covered:

  - `import-resolvers/go.ts` had no test at all. Its rule changed, the shared
    bench drives the indexed leg rather than this one, and a revert was caught
    by nothing. `go-package-resolve.test.ts` pins the membership rule and, more
    usefully, pins that Go's two independent legs agree on it — they disagreed
    before #2881 and a divergence here means the LanguageProvider hook and the
    ScopeResolver hook hold different views of a package.
  - The memo and the compaction are output-identical, so no fingerprint sees
    them and reverting either leaves both benches green. `kotlin-index-internals
    .test.ts` asserts them directly: the memo hit path against the miss path,
    two directories sharing a component-suffix keeping separate buckets (the one
    way a coarser memo key could move an answer), and that the bucket handed out
    is the cached, frozen, compacted array on both the sliced and the skipped
    path. The comment claiming this was "asserted structurally" previously
    pointed at nothing in the repo.

GATES:

  - Six ratio budgets in `bench/import-target` were slack: the measurements they
    bound got faster and the numbers were left alone. kotlin depth 3.4 -> 2.8,
    go 1.6 -> 1.4, csharp 2.2 -> 2.0, java 2.2 -> 2.1, kotlin collide_scaling
    1.8 -> 1.65, go 5.5 -> 5.1, each holding the headroom the old value
    expressed. The absolute ms ceilings are deliberately untouched: they carry
    runner-contention headroom, and a ratio is runner-speed-invariant where a
    millisecond is not. This is the failure the branch already fixed one
    directory over and missed here.
  - The `csharp_csproj` heap re-baseline is REVERTED. Base and branch both
    measure ~73.10e6 three runs each; the recorded 73703384 was simply not
    reproducible, and re-recording it would have dropped that language's derived
    floor 0.8% for no reason belonging to this change.
  - kotlin's collide arm was blind to the rule it was re-baselined for — a full
    revert of the Kotlin guards left both its fingerprints unmoved, because
    `com/example/models` is not a suffix of `…/models/inner/models`. Deepened to
    repeat the whole queried path; those two fingerprints are the only ones that
    moved for it. The same deepening on the java and kotlin UNIQUE arms was
    measured and REVERTED: ten more fingerprints, java's heap reading up 43%,
    and no coverage gained, because progressive stripping lands those queries on
    the same file either way.

SIMPLIFICATION:

  - `go.ts` now states the predicate as ends-with like its three siblings,
    instead of keeping the `indexOf` shape with `lastIndexOf` swapped in.
  - C# csproj step 2's direct-child filter is dead for a non-empty prefix —
    `getFilesInDir`'s keys ARE segment-aligned directory suffixes, so it cannot
    reject, and measurement agrees over 12 008 pairs. Only the empty-prefix case
    does work, and only that case remains.
  - `addChild` had one call site left; inlined. The memo's double read of its
    own lookup is gone. The V8 byte accounting duplicated verbatim between the
    resolver comment and the baselines note now lives only in the note.
  - Four copies of the same ternary in the csproj parity harness collapse onto
    one hoisted `dirTrail`; two locals in the java harness were named for the
    branch that was deleted.

CLAIMS THAT WERE WRONG:

  - `package-dir-index.ts` said "the four resolvers agree again". It is six, and
    the sixth is the evidence: `import-resolvers/jvm.ts` has answered the same
    question with `lastIndexOf` since #488, so before #2881 Java's and Kotlin's
    LanguageProvider hook and their ScopeResolver hook disagreed about which
    files a package holds.
  - The `uniqueDir` docblock claimed the last segment IS the query granularity
    for csharp/java/kotlin. They query the whole dotted path first and reach the
    tail only through stripping — which is why the partial-revert control fires
    on the go arm alone, now stated instead of implied.
  - Three parity harnesses described themselves as verbatim copies of the
    pre-change implementations; they were edited by this branch, so they are
    re-derivations of the current spec, a weaker claim their headers now make.
  - The shared harness header still listed the removed rule as current, the
    `DIRS` docblock still justified shapes by a divergence that no longer
    exists, and `measure.mjs`'s tier-two docblock plus `_heap_bound_note` still
    counted nine bounded languages when `HEAP_BOUNDED` derives to three — this
    branch had dutifully updated a kotlin bound in a list no gate reads.
  - `_blind_spot` told the next reader to build a repeated-leaf arm that already
    exists in the sibling bench, with a budget that already fails the swap.

Both baselines are also re-serialized to preserve each note's original escaping,
undoing ~20 KB of no-op churn an earlier revision introduced by round-tripping
the JSON.

Refs #2881.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e

* perf(scope-resolution): drop the string each membership test built per candidate

The three `endsWith` membership tests each minted a decorated copy of the
directory once per candidate, per import. The decoration cancels:

  ('/' + D + '/').endsWith('/' + P + '/')  <=>  D === P || D.endsWith('/' + P)
  (D + '/').endsWith(P + '/')              <=>  D.endsWith(P)

Verified exhaustively rather than argued — every pair of strings up to length 5
over `{a, b, /}` including the empty string, 132496 pairs, 0 divergences, with
the match count reported beside it because two predicates that agree on `false`
everywhere also show 0 divergences. `matchingDirs` 32.58 -> 8.22 ns/candidate
(3.96x), `matchingDirPositions` 64.9 -> 18.4 ns. C#'s deliberate unanchoredness
survives verbatim: `src/SubModels` still answers `Models`.

`resolveGoPackage` was the opposite of a win — the rewrite in this branch left
the `'/' + path` cons the old `includes` guard used to short-circuit, and the
first `endsWith` forces V8 to flatten it once per file. Working on the raw path
with an explicit start index is 4.8x faster than that and 1.78x faster than the
code before this branch. It also now reuses `resolveGoPackageDir` instead of
re-deriving six of its lines.

Three claims these files make are corrected while they are open:

- `package-dir-index.ts` argued the rule was accidental because a sixth
  implementation never had it, "wired as `importResolver` by
  `languages/{java,kotlin}.ts`" and therefore live. It is wired and not read:
  `provider.importResolver` is consumed only at `import-target-adapter.ts:74-75`,
  and that module's exports have no importer outside their own unit test, while
  its docblock claims it is threaded through `finalizeScopeModel`. The argument
  survives on the pre-index-scan derivation; `jvm.ts` is evidence about how the
  predicate was written, not about live behaviour. Whether those resolvers
  should be deleted or wired is left as an open question.
- `csharp.ts` derived the empty `dirPrefix` case from "any path whose first
  slash is its last", which is wrong in both directions: `src/X.cs` satisfies it
  and emits no empty key, `a//X.cs` violates it and does. The conclusion stands
  and the filter stays — it is what rejects `a//X.cs`.
- Step 2 returns on its first push, so widening it also suppresses step 3's
  unanchored leg. The narrower answer is the more precise one, but it was an
  unstated output change.

`SuffixIndex.getFilesInDir` now states the segment-alignment its callers rely on,
bounded as a guarantee about what may be RETURNED — php's root-anchored index
answers only the equality arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kim7UK3kPhF1ZRZDuuvnus

* fix(scope-resolution): say what the widened bucket actually does downstream

The comment justifying the widening claimed a bucket that is too wide is
"filtered downstream" by the finalize pass. It is not, for the edge that
matters. `finalize-algorithm.ts` mints one draft per candidate, each keeping its
own `targetFile`, and the File->File emitter in
`graph-bridge/imports-to-edges.ts` tests only `targetFile === null` and
`targetFile === sourceFile` before adding an `IMPORTS` relationship at
confidence 1.0 — it never reads `linkStatus`. The `localDefs` filter from #1759
constrains `targetDefId` and the `BindingRef`; every extra bucket member is an
unconditional file-level edge regardless. Measured on an Android-shaped layout,
one `import data.load` goes from 5 to 6 edges, all six unresolved.

No filtering is added here. Whether an unresolved candidate should produce that
edge at all is a design question about the graph bridge, not about this bucket.

The published drift census — 149 first-child reselections, 32 wider arrays, 54
null -> resolved — has no bucket for a fourth class this change introduces.
Tier 3 precedes tier 4, so a bucket the guards used to leave empty returned null
and let the progressive strip run; a populated bucket stops tier 4 entirely,
turning a bound answer into a candidate list that need not carry the symbol.
Re-running the census with a shape classifier finds that class ZERO times over
the corpus, and the zero is the finding: the shape reproduces by hand, and this
bench's own generator at 4000 repositories hits it 4-12 times per seed. The
fingerprint cannot gate what the corpus cannot express — the same blindness the
go arm carried until #2881 widened it.

Two further claims are brought back in line with what shipped. The memo's
docblock said `kotlin-index-internals.test.ts` asserts the key set, key
insertion order and bucket order "over the built maps"; that file says it works
through the resolver's observable surface and omits key order deliberately. The
mutation matrix bounds it honestly: a mis-keyed memo is caught, a deleted one is
not, and the compaction's only instrument is the bench heap ceiling.
`findKotlinDirectoryChild` no longer claims to return "the same file the scan
used to return" — that is precisely what moved.

Structural, no behaviour: `let keys` sits with its consumer instead of 33 lines
above it, the archaeology moves to the docblock, `tight` -> `compacted`,
`dirEnd` -> `lastSlash` (the name three sibling builders use), and the one-use
`MutableDirChildren` alias goes with the `addChild` it existed for.

`finalize-algorithm.ts` annotates `targetFiles` as `readonly string[]` so
`Array.isArray`'s `any[]` predicate can no longer widen a frozen cached bucket
into something `.sort()` compiles against. The runtime freeze stays; it is the
backstop for every other call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kim7UK3kPhF1ZRZDuuvnus

* test(scope-resolution): gate the edges #2881 moved but nothing watched

Every widened-shape test in the branch used a one-file corpus, so not one of the
149 first-child reselections was pinned — the tier that commits to `children[0]`
unfiltered had no test that could see which file it commits to. Kotlin and Java
now pin that choice absolutely, in both insertion orders, for the member path
(tier 3, both members) and the wildcard path (tier 1, one file) separately,
saying plainly that both candidates are valid members and the only tie-break is
file-set iteration order.

The tier-3-preempts-tier-4 class gets its first gate, with a control that makes
it a transition rather than a fact. The bench corpus holds zero instances, so
this case is the only thing standing between that behaviour and a silent
revert.

C# gains three absolute arms, because its differential harness cannot see any of
them — the legacy copy was edited in lockstep with production, which the file's
own header admits. One pins the empty-`dirPrefix` filter the branch calls
load-bearing and which nothing defended: deleting the guard leaves the whole
suite green but changes the answer, so the arm was verified to fail with the
guard removed and pass with it restored. Java gains the negative control Kotlin
already had.

`kotlin-index-internals.test.ts` stops implying coverage it does not have. The
mutation matrix is recorded in its header: deleting the memo passes every arm
(it is output-identical by construction), deleting the compaction's `slice()`
passes every arm (a JS array's capacity has no reflective surface), while
mis-keying the memo fails three and compacting-but-never-storing fails two. Four
arms were added that do fail under those mutations. V8's growth steps were
re-measured — 1, 19, 46, 86 with growth at lengths 2, 20, 47, 87 — so the old
1/17/41 model, which under-counted the slack at 40 files by 6x, is gone.

`go-package-resolve.test.ts` drops four `as never` casts that were hiding
nothing (`GoModuleConfig` is structurally satisfied), and pins vendor/, testdata/
and nested-go.mod directories, which merge into the importing package — a
pre-existing unmodelled gap, verified present before #2881 and documented as
such rather than blamed on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kim7UK3kPhF1ZRZDuuvnus

* test(bench): gate the bucket compaction, and publish the whole drift taxonomy

The compaction shipped with no gate anywhere. Deleting `bucket.slice()` while
keeping the freeze moves no fingerprint, no count and no test — only retained
heap, 42805256 -> 48184784 B (+12.57%), byte-identical across three runs. Note
the direction: compaction reclaims, so losing it makes the reading GROW, which
no floor can see. `heap_ceiling_bytes.kotlin` tightens 64203684 -> 46000000
(1.5x -> 1.0747x of the reading), leaving the regression 4.8% clear above the
ceiling and the reading 7.5% below it. The band is derived from first principles
in `_heap_compaction_gate` (~61000 buckets x 11 spare slots at Node 22's 1->19
step) so it can be re-checked rather than trusted, and the note carries the
triage rule: heapUsed accounting drift moves every arm, so kotlin alone over its
ceiling is a lost compaction.

`_gate_controls` claimed the two optimizations rest on a structural comparison
over 1234 corpora in both iteration orders. No such probe exists in the tree. It
now names the test that does exist and lists what it actually pins, and says
key insertion order is unasserted by design.

`_provenance` gains the full shape classification behind the 235 moved records:
149 string -> string, 38 null -> string, 16 null -> array, 32 array grew, and
zero of every other transition — including `string -> array`, the
resolved-becomes-unresolved class the old taxonomy had no bucket for. The
harness was validated byte-exactly first: driven over this corpus the base
resolver reproduces ebf1790bf1 / 13256 and head reproduces d91110bee3 / 13310.

`measure.mjs` loses a paragraph asserting the C# unique slice repeats the whole
queried path, directly above the paragraph explaining it is leaf-only
deliberately and the code that makes it so. Acting on the deleted half resolves
the csproj arm to zero. While measuring: the csharp collide arm is NOT blind —
its fingerprint already moves across #2881 — but both csharp_csproj arms are,
because `getFilesInDir` keys on segment-aligned suffixes and neither nested slice
is one. Closing that needs a corpus redesign and four re-baselines; recorded, not
attempted.

One number changes in either baselines file, and it tightens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kim7UK3kPhF1ZRZDuuvnus

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:26:39 +01:00
Octopus
0fa547ccdc
feat: refresh MiniMax model and endpoint configuration (#2780)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
2026-08-11 18:11:47 +00:00
Gergő Magyar
5f9648744c
fix(storage): strip credentials from remote URLs before they are persisted (#2914) (#2928)
`git config --get remote.origin.url` returns whatever the checkout was
configured with, and the HTTPS token form
`https://x-access-token:<token>@host/owner/repo` is how CI checkouts and
credential helpers routinely authenticate. `getRemoteUrl` kept that value
verbatim, so it reached `~/.gitnexus/registry.json` and the per-repo meta,
and MCP `list_repos` echoed it back — repository discovery doubled as
credential disclosure.

Three edges, one helper:

- `stripUrlCredentials` drops `user[:password]@` userinfo from http(s) URLs.
  `ssh://git@host/…` and SCP-like `git@host:owner/repo` are left alone: that
  is an SSH user name, not a secret, and rewriting it would repoint the
  sibling-clone fingerprint (#2054) for every registered repo.
- `getRemoteUrl` strips at capture, before the existing host lower-casing —
  that regex treats the whole `user:pass@host` span as the host, so it was
  also mangling the credential's case on the way to disk.
- The registry sanitizes on read AND write, so a `registry.json` (or a
  per-repo meta copied forward by a re-register) written by an older version
  is neither emitted nor rewritten with the credential still in it.

Also strips both URLs from the clone/remote mismatch error in
`assertRemoteMatchesRequestedUrl`, which is echoed to API callers and the
server log.

Sanitized values compare equal to a freshly captured remote on both sides,
so sibling matching, drift checks and `--name` inference are unchanged.


Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:19:24 +01:00
Gergő Magyar
22d3c2ad74
fix(cli): stop churning the committed agent guides, and nudge --index-only (#2907) (#2927)
AGENTS.md and CLAUDE.md are the agent guides teams commit, and the injected
block carried live symbol/relationship/flow counts. Those counts move with any
code change, so every reindex rewrote a tracked file and produced a spurious
diff that had to be restored by hand before committing real work.

The write is now skipped when the volatile counts are the only delta. Counts are
substituted with placeholders — not deleted — before the comparison, so
--no-stats REMOVING the parenthetical is still a material change that writes
through; only a numbers-only difference is suppressed. Both the verbose path and
the gitnexus:keep path go through the same rule, and a project rename, a template
change, or a base_ref change still rewrites as before. Live counts remain
available from `gitnexus status` and `gitnexus://repo/{name}/context`.

Two smaller churn sources go with it:

- The file was CREATED without a trailing newline while every update path writes
  `.trim() + '\n'`, so the analyze right after committing a freshly created
  AGENTS.md dirtied it purely to append that newline.
- `--no-stats` left the per-cluster `(N symbols)` counts in the skills table,
  which are exactly as volatile as the header parenthetical the flag removes.

The stale-index hook recommended plain `gitnexus analyze` — the variant that
rewrites those tracked docs — so an agent following the nudge verbatim reindexed
with the most invasive flags. `formatAnalyzeCommand` takes `indexOnly` and the
three hook call sites (Claude, plugin copy, Antigravity) pass it; the injected
"Index stale?" line and the MCP context resource's `re_index` hint name the same
`--index-only` form. Full `analyze` stays the documented way to refresh the docs
and skills.

Both resolve-analyze-cmd.cjs copies stay byte-identical.


Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:30:14 +01:00
dependabot[bot]
6d2c2f68ee
chore(deps)(deps-dev): bump tsx from 4.23.5 to 4.23.11 in /gitnexus (#2925)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.5 to 4.23.11.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.5...v4.23.11)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 11:48:00 +00:00
dependabot[bot]
d3687259d0
chore(deps)(deps): bump @ladybugdb/core in /gitnexus (#2924)
Bumps [@ladybugdb/core](https://github.com/LadybugDB/ladybug) from 0.19.0 to 0.19.1.
- [Release notes](https://github.com/LadybugDB/ladybug/releases)
- [Commits](https://github.com/LadybugDB/ladybug/compare/v0.19.0...v0.19.1)

---
updated-dependencies:
- dependency-name: "@ladybugdb/core"
  dependency-version: 0.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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>
2026-08-11 12:25:21 +01:00
Gergő Magyar
740f0a4e57
fix(skills): publish gitnexus-plan artifacts on macOS without an interpreter (#2905) (#2922)
* fix(skills): anchor gitnexus-plan safe writer on macOS (#2905)

The safe generated-plan writer refused to run on anything but Linux.
`requireDescriptorAnchoring()` hard-gated `process.platform !== 'linux'`
because every name it resolves went through `/proc/self/fd/<fd>/<child>`,
and publication went through `renameat2(RENAME_NOREPLACE)`. macOS has
neither, so `write-plan` and `read-plan` failed on every input and
`snapshot` failed whenever a materialized path was absent.

Node cannot perform openat-style directory-relative resolution on macOS
at all: `node:fs` exposes no dir_fd parameter, and `fcntl(F_GETPATH)` is
a snapshot string that XNU reconstructs from the name cache, so using it
would reintroduce the exact race this helper exists to prevent. Python
does expose the *at() family via dir_fd, and macOS has renameatx_np with
RENAME_EXCL, so the anchoring borrows the interpreter the writer already
spawns for renameat2.

Anchoring now goes through a backend with two implementations. The Linux
one keeps the original expressions, flags, ordering and error strings.
The Darwin one runs each operation in the integrity-checked python3: it
re-walks the chain from the repository root with O_DIRECTORY|O_NOFOLLOW,
asserting the caller's recorded device, inode and mode at every level
before acting. A chain that fails that assertion reports a dedicated
anchoring errno and never ENOENT, so a moved parent cannot be read as an
absent file. Node holds an open descriptor on every chain element for the
anchor's lifetime, which pins the inodes so their numbers cannot be
recycled between spawns, and that coupling is re-checked on the way into
every request rather than left implicit.

A filesystem that answers ENOTSUP to RENAME_EXCL is a refusal, never a
fallback to a replacing rename. Every other platform is still refused.

The suite had silently skipped on every non-Linux runner, so it is now
gated on linux-or-darwin and registered in the cross-platform test list,
which puts it on the macos-latest CI matrix.

Disclosed rather than papered over: operations that must hand Node a file
descriptor are anchored in the helper and then opened lexically with
O_NOFOLLOW and identity-compared. A racer can force a mismatch, which
aborts, or land on the inode the anchored walk already found, which is
harmless. A perfect ABA inside that window is impossible on Linux and
detected in all but its narrowest form on macOS. The reference doc says
so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* test(skills): normalize the anchoring-gate fixture repo on Windows

The two capability-gate tests are the only ones in this file that run on
Windows, and both failed there: `createBaseRepo` returned the path
`os.tmpdir()` gave it, which on Windows is the 8.3 short form
(C:\Users\RUNNER~1\...). `assertRepository` compares fs.realpathSync of
the caller's path against the realpath of `git rev-parse --show-toplevel`,
and plain realpathSync does not expand short names while git always
reports the long form, so the helper rejected its own fixture with
"--repo must be the Git worktree root" before either platform gate was
reached.

Resolve the fixture with the native resolver, which returns the canonical
long path. No-op on platforms where the two already agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* test(skills): skip the darwin backend gate on Windows

Spoofing process.platform does not spoof fs.constants. Windows Node
defines no O_DIRECTORY, so a darwin-spoofed run there refuses at the
anchoring-flag check and returns that message instead of ever reaching
the python3-backend branch the test exists to cover.

Skip it on win32 rather than loosening the regex, which would also let a
macOS run pass on the wrong message. The sibling test still asserts the
Windows refusal on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(skills): tighten the macOS anchoring backend

Quality pass over the Darwin backend. No behaviour change was intended
on the success paths; the guarantees are the same or stronger.

Structural:

- openChildRead now proves identity inside the backend instead of by
  comment. It was returning a raw descriptor from a lexical open, with
  the "callers always compare against the preceding anchored stat"
  invariant enforced across four call sites in prose — and since the
  Linux predicate is a literal `return true`, a fifth caller that forgot
  would have been an unanchored open on macOS that Linux CI could not
  see. It routes through darwinAdoptAnchoredFile, which already did
  open-then-compare-then-close-on-mismatch for createChild.

- recordAnchoredAbsence shares one prefix walk per snapshot instead of
  re-walking from the repository root for every absent cited path. With
  three absent paths under a three-deep prefix that is 12 helper spawns
  down to 6 and 12 retained descriptors down to 4. citedPaths is
  caller-supplied and unbounded, so the descriptor retention was the
  real problem; the cache is now the sole close owner. This does change
  Linux descriptor lifetime — prefixes stay open for the snapshot rather
  than only the tail, deduplicated across paths.

- assertRepository and the sibling realpath comparisons use
  realpathSync.native. Windows hands back 8.3 short names that plain
  realpathSync preserves while git reports the long form, so `snapshot`,
  which is not platform-gated, could reject a worktree root by quoting
  that same directory back at the user. The fixture workaround that
  papered over this for the new gate tests is gone.

Efficiency, all measured at ~13.5ms per helper spawn:

- consume the identity mkdir already computed rather than re-stat it
- act on renameNoReplace's return value rather than spending two stats
  re-deriving what it already reported
- drop a duplicate anchored stat taken twice in a row in movePathToVault
- import ctypes only where it is used; 19 of 20 spawns never touch it

Simplification: pins folded into the descriptors the handle already
carried, an unreachable refreshAnchorTail branch and the dead
darwinHardenedOpen mode parameter removed, the four copies of the spawn
options collapsed, the spawn-and-parse shared between the probe and the
request path, the unreachable launch-path fallback and a redundant memo
deleted, and the helper's dispatch made a real elif chain with leaf name
and mode validated at one chokepoint rather than per operation.

The two chain encodings were left alone deliberately: merging them would
have grown triple fields on Linux for no Linux benefit and changed the
Linux validatePlanParent comparison. The double re-stamp that motivated
the merge is contained in one named helper with the hazard documented.

Rejected candidate interpreters now say which dir_fd operations were
missing instead of producing a generic refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(skills): publish plans with link(2) and drop the interpreter

The macOS backend spawned python3 for two jobs: openat-style resolution,
which Node cannot do, and a no-replace rename. Only the first is actually
unavoidable, and the second was carrying the whole dependency.

link(2) is a no-replace publish. It is atomic, it fails EEXIST when the
destination name is taken, and it refuses a symlinked destination without
following it — the same guarantee renameat2(RENAME_NOREPLACE) and
renameatx_np(RENAME_EXCL) give, reachable from plain fs.linkSync. The
published file is the same inode as the verified temporary, so the
downstream identity checks hold by construction rather than by argument.

That removes the interpreter from Linux entirely, since /proc already did
the resolving there, and it removes ctypes, libSystem, RENAME_EXCL and the
ENOTSUP handling from macOS. Deleted with them: the trusted-executable
validation, the held-descriptor exec and its two-tier probe, the capability
probe, the JSON request protocol, and both embedded Python programs. The
helper drops from 3047 to 2327 lines.

macOS keeps the part that genuinely cannot be done in Node, and now does it
without a subprocess: a lexical O_NOFOLLOW walk that holds an open
descriptor on every directory in the chain and re-proves the chain either
side of every step. Pinning is load-bearing — an open descriptor keeps its
inode number from being recycled, which is what makes the recorded
identities trustworthy across steps.

The guarantees are no longer symmetric and the docs say so plainly.
/dev/fd/<fd> is a devfs node, not a magic link: opening it works, resolving
through it does not, open("/dev/fd/<fd>/child") returns ENOENT and realpath
returns /dev/fd/<fd> — measured on macOS 26 rather than inferred. So Linux
makes a parent swap impossible while macOS detects one and aborts.

Also fixes the writer on 9p mounts, where renameat2(RENAME_NOREPLACE)
returns EINVAL and publication failed every time; link(2) succeeds there.

Tests 174 -> 154: dropped 29 fixtures that drove the deleted Python program
directly, added coverage for the link publish, for a macOS parent swap
caught through the pinned chain, and for a spoofed-darwin round trip that
asserts no /proc path reaches the hooks, which the portable backend now
makes runnable on Linux CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* fix(skills): drop O_NOFOLLOW_ANY, guard trailing slashes, handle link edge cases

macOS CI rejected our hardened directory open with EINVAL on 30 tests. The
flag O_NOFOLLOW_ANY was ORed into every open on the theory that XNU ignores
unrecognized open bits, so it would be inert where unsupported. That theory
is wrong, at least combined with O_DIRECTORY. The Python design never hit
it because the walk ran inside the interpreter; once Node did the opening,
every Darwin directory open went through it.

Removed rather than probed. The per-component O_NOFOLLOW walk is what
delivers the guarantee, and cap-std — the closest reference implementation
of this problem — has not adopted O_NOFOLLOW_ANY either. A fixture now pins
the exact flags of every directory open under a spoofed darwin, so the next
failure names the flag instead of printing a stack trace. With the flag
gone the two backends' directory open became identical, so it is no longer
a platform concern at all.

Three findings from researching the prior art, all now covered:

Trailing slashes. CVE-2026-39822 escaped Go's os.Root because
open(fd, path, O_NOFOLLOW) follows symlinks when the path ends in "/". It
reproduces here: with docs a symlink, opening "docs" is ENOTDIR but "docs/"
succeeds into the attacker's directory, and path.join preserves the slash.
We were safe only by construction, and only for repo-derived names — the
generated temporary and vault artifact names never passed through the
validator. The guard now sits at anchoredChild, the single place a name
becomes a path, so it holds for every caller.

link() can lie on NFS. Per link(2) BUGS, the return code may be wrong if
the server creates the link then dies before replying; open(2) NOTES gives
the remedy, which is to stat the source and treat a link count of 2 as
success. Implemented, with the man-page reasoning in the comment so it is
not later removed as paranoia.

Filesystems without hard links now fail loudly. EPERM, ENOTSUP and EMLINK
say so and refuse to fall back to a replacing rename. Git falls back and
accepts losing collision detection because its objects are content
addressed; that reasoning does not transfer to a named plan destination.

Durability was already correct — the temporary is fsynced before
publication and the parent directory immediately after — but the comment
now records why the parent fsync is required for link as it was for rename,
and the honest limitation that fsync is not a write barrier on macOS while
F_FULLFSYNC, which Node cannot reach, is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(skills): shrink the anchoring seam and fix two CI breaks

Four quality reviews over the pure-Node writer. Two real breaks, one
drift that had already happened, and a seam that was sized for a design
we deleted.

The macOS round-trip fixture asserted that every observed path started
with join(repo, 'docs/plans'). Reproduced on Linux by handing the helper
a repo reached through a symlink, which is the shape macOS gives us via
/var to /private/var: assertRepository realpaths the repo, so the handle
builds paths from the resolved form while the fixture holds the form it
passed in, and the prefix can never match. The assertion now proves the
same thing without depending on the prefix — a lexical resolution always
contains a docs/plans segment and /proc/self/fd/<fd>/<name> never does.

Two publish fixtures sat in the capability-gate describe, the one block
deliberately not skipped on unsupported platforms, while this PR added
the file to the Windows matrix. They test link(2), not the gate, so they
moved to SAFE_WRITE_FIXTURES.

validatePlanParent restated verifyLexicalChain's loop without the
try/catch that converts ENOENT and ENOTDIR into the parity message, so a
raw errno could escape a function with a dozen call sites. It was masked
on Darwin only because parentStillResolves catches first. It now calls
the helpers, which also removes a second full chain walk per call there.

openVerifiedFile adds O_NONBLOCK so a FIFO swapped in at the target name
cannot wedge the process on open, and only Darwin was calling it. The
operations are now shared, so Linux gets it by construction rather than
by a per-backend decision.

The backend is five methods rather than ten. The platform difference is
two things — how a name becomes a path, and what guard wraps an
operation — so the five operations became shared functions over a
`verified` hook that is run() on Linux and the pinned-plus-lexical
sandwich on Darwin. openChildRead always runs the identity adoption, so
that proof is structural rather than a comment about what callers must
remember. Selecting the backend is a registry that throws on an unknown
platform instead of a ternary defaulting to Linux, which surfaced seven
dead bindings that ran before the capability gate and made win32 report
the registry error instead of the refusal.

Snapshot capture no longer re-walks a prefix per record: 36,018 lstats
to 6,384 and 162ms to 130ms on 2,000 dirty files across 100 directories,
with a byte-identical global_dirty_digest. Absence anchoring is now
bounded at 4096 pinned directories and refuses rather than evicting,
because closing a cached descriptor would break the pinned chain of a
guard already recorded — the inode-recycling hole the pins exist to
close.

The test suite no longer cache-busts its imports. That existed for the
memoized python3 descriptor, the file's only mutable module binding,
which is gone; the suite drops from 10.0s to 8.2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:24:53 +01:00
Gergő Magyar
135bcae03d
fix(go): resolve out-of-repo package qualifiers, and stop reporting an undecided interface check as a decided negative (#2873) (#2921) 2026-08-11 10:36:59 +01:00
Gergő Magyar
414c1a5693
fix(storage): give every registry write its own tmp path (#2888) (#2920)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(storage): give every registry write its own tmp path (#2888)

`writeRegistry` staged the global registry through a FIXED
`~/.gitnexus/registry.json.tmp`. The rename is atomic with respect to
readers, but the tmp path is not private to the writer, and that file is
the one file every gitnexus process on the machine writes. Two of them
starting together stage through the same inode: the second `writeFile`
overwrites the first's bytes, the second `rename` moves that inode onto
`registry.json`, and the first's own rename then finds nothing at the
source and rejects with

  ENOENT: no such file or directory, rename '<home>/registry.json.tmp' -> '<home>/registry.json'

which kills the MCP server, because it lands on the startup path
(`mcpCommand` -> `LocalBackend.init` -> `refreshRepos` ->
`listRegisteredRepos({validate:true})`) where nothing catches — the
client just reports "Server disconnected".

#2716's `withRegistryLock` serializes the callers and hides this in the
normal path, but it deliberately degrades to UNLOCKED after a 5s
`IndexLockTimeoutError` (availability over serialization), so the window
is still live. Measured on this branch's parent with 12 concurrent
processes pruning a stale registry while another process held the
registry lock: 4/12 crashed with the trace above. Same harness with 24
processes and no lock contention: 0/24. So the write itself has to be
collision-proof rather than relying on the lock.

`writeMetaFile` (repo-manager), `writeBridgeMeta` (group/bridge-db) and
`writeContractRegistry` (group/storage) already carried the correct
shape — random tmp suffix, `'wx'` + `0o600`, `retryRename` — as three
byte-identical copies, none of which cleaned up its tmp file on failure.
Rather than adding a fourth copy, that sequence moves to
`writeFileAtomic` in storage/fs-atomic.ts (beside `retryRename`, which
it uses) and all four writers call it. The helper also unlinks the tmp
before rethrowing: with a fixed name a leaked tmp was self-limiting
because the next writer overwrote it, but a random suffix would drop a
fresh orphan beside the target on every failed publish.

Second half of the same crash: the prune write inside
`listRegisteredRepos({validate:true})` is housekeeping, not the caller's
request. Every caller consumes the returned `valid` array and the prune
set is recomputed from scratch on the next validating read, so a failed
write costs a retry, never correctness — while rethrowing it took down
the whole MCP server. It is now caught and warned about, which also
covers the read-only-home and full-disk variants of the same startup
death.

Note: `registry.json` is now created `0o600` (it inherited the umask
before, typically `0o644`), matching what `gitnexus.json` has always
used. A rewrite tightens the mode on existing installs.

Verified: the five new tests in
test/unit/repo-manager-registry-atomic-write.test.ts all fail on the
parent commit — four with the exact ENOENT above — and pass here; the
process-level repro goes 4/12 -> 0/12 crashes with the lock held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXSu2fTmm7EZGDVquWeBrL

* refactor(storage): trim the atomic-write helper and its guards

Follow-up polish on the #2888 fix, no behaviour change except where noted.

- `writeFileAtomic` drops the `mode` parameter (no caller ever varied it)
  and inlines `0o600`, and gains an `attempts` pass-through to
  `retryRename`. The prune write in `listRegisteredRepos` now passes
  `attempts: 1`: it discards a failure anyway, so the 300ms of rename
  backoff bought nothing and was spent holding the registry lock, on a
  path with a sub-500ms cold-start budget (`gitnexus augment`) and on MCP
  startup.
- `saveMeta` serialises `meta` once instead of once per written file.
  `meta` carries a `fileHashes` entry per file — 263KB and ~420us on this
  repo, linear in file count — and it was being stringified twice per
  save, several times per analyze. `writeMetaFile` was a one-line
  forwarder after the previous commit, so it folds into `saveMeta`.
- Comments: the four writers were each restating the primitive's
  contract, and the #2888 narrative appeared in four files. Kept one
  authoritative copy in the helper, one registry-specific note at
  `writeRegistry` (why the lock is not enough), and deleted the rest.
- Tests: new test/unit/storage/fs-atomic.test.ts covers the primitive
  behaviourally — published bytes, `0o600` on the result, three
  concurrent publishers to one target all resolving, no leftover tmp and
  intact previous content when the publish fails. That is what the
  source-text regexes in insecure-tempfile.test.ts were approximating, so
  those shrink to the one thing regex is good for: this module does not
  hand-roll a tmp path. The registry test drops the assertions the
  primitive now owns, an unused `fs.writeFile` capture, a type alias with
  two `as unknown as` casts the sibling harnesses do without, and moves
  its two path-only temp repos to `beforeAll`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXSu2fTmm7EZGDVquWeBrL

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:42 +01:00
Gergő Magyar
5cfa402346
fix(fts): keep binary payloads out of the description column, confine an unbuildable index to its own table (#2919)
* fix(fts): keep binary payloads out of the indexed description column

Issue #2889 reports embedded binary and serialized data reaching LadybugDB
through `description`. The vector is real, but not for the reason the report
gives, and the detector that was supposed to stop it cannot see it.

Every file enters the pipeline through a lossy `utf-8` decode — the CSV
emitter's own content cache reads with `fs.readFile(path, 'utf-8')`, and so
does the parse worker. An invalid byte sequence therefore never survives as
invalid bytes; it is replaced with U+FFFD. `isBinaryContent` counted control
bytes and DEL only, and charCode 0xFFFD is neither, so a wholly corrupt
payload scored as clean text: on a real repro, a Vue/JS file carrying a class
file constant pool produced the description `用户服务 handles 数据 <7×U+FFFD>MethCw`
and the detector returned false. Counting U+FFFD toward the existing 10%
threshold is what makes the function see the case it exists for. A legitimate
source file carries no replacement characters at all unless it was
mis-decoded, and a handful still score far under the bar.

`formatFtsDescription` then gates on it. `content` has always been gated
inside `extractContent`; `description` never was, so a symbol whose doc
comment is really a slice of an embedded payload had that payload copied
verbatim into an FTS-indexed column. Empty string rather than a sentinel:
unlike `content`, a description has no reader that needs to be told why it
is missing.

This does not address the `Failed calling LOWER: Invalid UTF-8` build error
itself. That error cannot originate in this layer — every value handed to
COPY is encoded from a JS string, which is always well-formed UTF-8. The two
other gaps the issue names are a no-op and dead code respectively; see the
pull request for the evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT

* fix(fts): confine an unbuildable index to its own table

One untokenizable row cost far more than its own table's index.
`createSearchFTSIndexes` let the first rejection leave the loop, and by then
`dropFTSIndex` had already run for that table — so the failing table ended
with no index, and every table after it in `FTS_INDEXES` order was never
reached. On a fresh build, or on the incremental path where
`dropSearchFTSIndexes` clears all of them up front, those later tables ended
with no index either. `verifySearchFTSIndexes` never ran to report it,
because the throw skipped it.

That is the mechanism behind the multi-table degradation in #2889: the report
lists Function, Method, Property and Variable as failing together, which is
loop control flow, not four independent bad rows. It also explains why
`--repair-fts` felt useless — repair runs the same loop, so it stopped at the
same table and left everything after it unbuilt, then failed with a list of
missing indexes and no reason attached.

Each index now builds inside its own try/catch and the run continues, so the
damage stops at the table that actually holds the bad row and repair can
recover everything else. Failures are returned rather than thrown so the
caller sees all of them instead of the first: `buildSearchIndexesOrDegrade`
names every failing table with its raw LadybugDB message, and repair appends
those reasons to the missing-index error.

The aggregate failure class is computed per failure, with integrity winning.
Classification checks capability signatures first, so folding the messages
into one string would have let an untokenizable row mask a genuinely broken
write and downgrade an abort into a degrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT

* refactor(fts): verify before reporting, and fold the derivable state away

Cleanup pass over the two #2889 commits. No behaviour change except the
verification ordering, which was a real placement error.

`buildSearchIndexesOrDegrade` reported build failures and returned BEFORE
`verifySearchFTSIndexes` ran. A partial build is exactly when "the other
tables are fine" needs proving rather than asserting, and a stale
name+content-only index succeeds at build time while leaving description
search broken (#2299). Verification now always runs, and a table that failed
to build is subtracted from the missing list so it is reported once, with its
reason, instead of twice.

`FtsIndexBuildFailure.failureClass` was `classifyFtsBuildError(error)` stored
beside the string it derives from — two fields that had to agree, and a test
about loop isolation that broke if classification rules changed. Classify at
the one place that asks.

`describeFtsIndexBuildFailures` becomes `summarizeFtsIndexBuildFailures` and
owns the whole sentence, including the denominator only this module knows.
Analyze and `--repair-fts` were rendering the same failure two different ways.

`isBinaryContent` drops the `slice` for a bounded loop and folds the U+FFFD
arm into the existing predicate — the two arms had identical bodies over
provably disjoint conditions. Measured on this box: 349ns vs 388ns per 200
character description, and it skips a SlicedString allocation past 1000
characters. Its doc moves onto the exported function whose contract changed.

Tests: three isolation tests collapse into one (same setup, three channels),
the duplicate capability-class test folds into the existing single-rejection
test, the two integration tests become one graph covering both emission
branches, and the CJK unit case goes — an equality check on one code point
cannot be reached by a CJK character, so it could not fail. `afterEach` uses
`resetAllMocks` so every mock's `...Once` queue is drained, not just one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:11 +01:00
Gergő Magyar
18bc51dfd2
perf(import-resolvers): index every scanning resolver, consolidate the memo, gate every registered language (#2911)
* perf(import-resolvers): build buildSuffixIndex's dirMap lazily (#2903)

`buildSuffixIndex` eagerly built three maps. `dirMap` is the array-valued one —
one entry per directory suffix per file, so O(files x depth) in entries and
array churn — and only four call sites ever read it, all via `getFilesInDir`:
`import-resolvers/{php,csharp,jvm}.ts` and `import-resolvers/configs/python.ts`.

Ruby (through workspace-file-index), the TypeScript scope resolver, Vue's
import-target and the include-extractor never ask a directory question, and
built it anyway. Since #2880 these indexes are retained for a whole resolution
pass rather than rebuilt per import, so that waste is now resident memory.

Deferring it to the first `getFilesInDir` call is behaviour-identical — same
key, same descending-suffix order, same per-bucket push order, same
`substring(lastIndexOf('.'))` extension clamp. The builder assigns the MAP on
completion, so a repeated miss cannot rebuild it.

Measured on `buildSuffixIndex` alone, 32k paths, index built and
`getFilesInDir` never called:

  C# layout, 13 segments   79,018,680 -> 66,580,488 B   -15.74%
  Ruby layout, 11 segments 60,752,792 -> 48,656,856 B   -19.91%

and on the whole retained WorkspaceFileIndex the bench measures:

  csharp 32k  73.62 -> 61.76 MiB   ruby 32k  55.26 -> 43.69 MiB

When `getFilesInDir` IS called the footprint is unchanged, so the deferral is
never a loss. No new retention: all five construction sites already hold both
input arrays alive beside the index.

The laziness is pinned structurally rather than by timing. The test's corpus is
a `string[]` whose elements are accessor properties, so an indexed read is
observable and the read count IS the pass count: 14 after construction, still
14 after any number of get/getInsensitive, 28 after the first `getFilesInDir`,
28 after five more. Memoizing the decision instead of the map would read 42.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(php): resolve imports from a per-run index, not a scan per import (#2901)

PHP was the last language whose import resolution scanned the workspace per
import. Both `resolvePhpImportTarget` and `resolvePhpImportTargetInternal`
materialized two full arrays from the Set on every call, then passed
`undefined` as the `index` argument — so `resolvePhpImportInternal` fell
through to `suffixResolve`'s linear `findIndex`, once per extension per path
part. Measured at 20,000 files: 96.40 ms per import.

**Handing it the shared SuffixIndex would have moved IMPORTS edges.** All three
index-fed sites answer a different question than the scan they short-circuit,
each found by differential with a concrete witness:

  1. `getInsensitive` — the scan leg is `allFiles.has(path)`, exact whole-path
     with no case-insensitive counterpart; the shared index answers a ci SUFFIX
     probe.
  2. `getFilesInDir` — the scan is root-anchored `startsWith(nsDir + '/')`;
     `dirMap` is keyed on every directory SUFFIX, so a vendor copy can win.
  3. `suffixResolve` — the scan's `endsWith('/' + S)` matches only a PROPER
     suffix; `buildSuffixIndex` indexes j=0, so a root-level `Foo.php` starts
     resolving `use Foo` where it returned null.
  3b. the scan's `endsWith(p) || lower.endsWith(lower(p))` has a second
     disjunct that subsumes the first, so it is purely first-in-Set-order and
     case-insensitive; `get(S) || getInsensitive(S)` lets a case-exact hit
     anywhere beat an earlier ci hit.

So this is not Ruby's #2880 shape. Both sites take `getWorkspaceFileIndex` for
the memoized arrays and hand the internal resolver a PARITY `SuffixIndex`
memoized on the same Set identity: `getInsensitive` disabled, `get`
implementing the scan's real rule via the shared ci lookup plus one O(files)
whole-path correction map, `getFilesInDir` root-anchored in Set order.

  no composer.json    96.40 -> 0.036 ms/import steady state
  with composer.json 100.19 -> 0.068 ms/import steady state

Also closes PHP's last per-import traversal, in `import-resolvers/php.ts`: its
namespace-directory scan ran whenever `getFilesInDir` came back EMPTY, not
merely when no index was supplied — despite the comment above it claiming
"only when SuffixIndex unavailable". An empty bucket is already the answer, so
the scan could only confirm it, at one full pass per import whose namespace
matches a PSR-4 prefix but whose directory has no direct `.php` child
(measured 11 traversals for 10 imports; now 1). Moving it into the `else` is
safe because the bucket is a SUPERSET of what the scan finds — a root-anchored
direct child `nsDir/<x>.php` has its directory exactly equal to `nsDir`, and a
directory is always one of its own suffixes, so both index shapes contain it.

Nine mutations of the new code are caught, including M1 "pass the raw shared
index" (the naive fix) at 23 arms. The adapter guard reads 600 instead of 1
under a defensive `new Set(allFilePaths)` — the #1918 P1 hazard the unit
differential is structurally blind to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(java): index import resolution instead of scanning per import (#2908)

Java scanned the whole workspace twice per import: once for the three-tier
direct match, and again INSIDE the progressive prefix-stripping loop — so a
single unresolvable import cost one full pass per stripped segment. No WeakMap,
no index, and it is registered in `SCOPE_RESOLVERS`, so it ran in production.

This is byte-for-byte the C# shape #2878 fixed, so Java now reads the same
machinery: `getWorkspaceFileIndex` for `normToRaw` + the segment-suffix index,
and a Java-owned `PackageDirIndex` WeakMap over `buildPackageDirIndex(_, n =>
n.endsWith('.java'))` read through `firstFileDirectlyInPkgDir`. Structure
mirrors C#'s `narrowContext` / `resolveDirectMatch` /
`resolveByProgressiveStripping`.

  20k files, 256 imports, 7-in-8 unresolvable:  8.05 -> 0.62 ms/import
  steady state once the index is built:         0.0036 ms/import

Tie-breaks preserved, and Java's are NOT identical to C#'s:

  - tier 1 `break`s on the exact match, so an exact whole-path hit wins even
    when a suffix or directory-child hit came earlier in iteration order —
    hence `normToRaw.get` before `index.get`, which conflates them;
  - the stripping loop instead returns at the FIRST hit of `f === tailFile ||
    f.endsWith('/' + tailFile)` and only yields its directory child after the
    scan completes, so the conflated `index.get` is the correct lookup THERE.
    Applying tier 1's exact-wins rule inside the loop is a real behaviour
    change (mutation M6);
  - `.*` wildcard stripping stays ahead of everything;
  - `firstFileDirectlyInPkgDir` reproduces Java's at-root/at-nested predicate
    exactly, including the first-`indexOf` rule — proved algebraically rather
    than assumed: the `atRoot` branch matches iff `dir === pathLike`, which is
    `D.indexOf(P) === 0 === D.length - P.length`, and the `atNested` branch's
    first occurrence in `f` is the first occurrence in `D` shifted by one.

Six mutations are caught; a seventh (swapping the two index builds) is a true
equivalence and is recorded as such. Hand-derivation also corrected four cases
where the legacy code resolves and I had predicted null — including
`java.util.List` reaching a local `util/List.java`, because Java has no
in-repo-namespace gate like C#'s #1881. That is preserved here and filed
separately as #2910; the parity test pins it so the fix is visible.

The adapter guard reads 800 instead of 2 under a defensive
`new Set(allFilePaths)`. Two traversals is correct: the workspace index and the
package-dir index are separate WeakMaps and each iterates the Set once, the
same accounting as C#.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(cobol): index COPY resolution instead of two scans per statement (#2908)

`cobolScopeResolver.resolveImportTarget` ran two full workspace scans per
`COPY`, each calling `path.extname` + `path.basename` + `.toUpperCase()` on
every entry: tier 1 over `.cpy`/`.copybook`, tier 2 over `.cbl`/`.cob`/
`.cobol`. No WeakMap, no index, and registered in `SCOPE_RESOLVERS`.

Two uppercased-basename maps, one per tier, filled in a SINGLE pass over the
Set and memoized on Set identity. Lookup is
`copybooks.get(upper) ?? sources.get(upper) ?? null`.

  20k files, 500 COPY operands:  3879-4082 -> 10.5-11.7 us/import  (~350-369x)
  steady state once built:       0.253 us/import

Tie-breaks preserved:

  - TIER ORDER. A `.cpy` match beats a `.cbl` match even when the source file
    appears EARLIER in Set-iteration order. This is the one a naive
    single-map rewrite silently breaks, so it gets its own fixture.
  - Within a tier, first in Set-iteration order wins (`if (!tier.has(...))`,
    mirroring the scans' first-match return).
  - The key is built with the identical call sequence,
    `basename(fp, extname(fp).toLowerCase()).toUpperCase()`, so `Foo.CPY` still
    keys under `FOO.CPY` rather than `FOO`.
  - `path` stays in the loop rather than hand-rolled `/`-slicing, so backslash
    handling is unchanged on every platform — pinned by a `dir\sub\BOOK.cpy`
    case.

All six mutations are caught: collapsing the tiers, within-tier last-wins,
dropping the target uppercase, dropping the extension lowercase, hand-rolled
slicing, and the adapter's defensive copy. The first five are caught by the
differential and are invisible to the adapter guard; the sixth is the reverse,
which is the layering working as intended — the guard reads 600 instead of 1.

`COBOL_SOURCE_EXTENSIONS` was being re-allocated on every call; hoisted to
module scope beside `COPYBOOK_EXTENSIONS`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(csharp): index the csproj leg's namespace-directory scan (#2902)

#2878 moved C#'s no-csproj leg onto memoized indexes; the csproj leg kept a
per-import full scan in `resolveCSharpImportInternal` step 3, measured at
~1.10 ms per import at 50,000 `.cs` files.

**The fix the issue proposed would have moved edges.** It suggested skipping
the fallback when an exhaustive index is available, on the assumption that
step 2's `getFilesInDir` answers the same question. It does not: step 2's
`dirMap` is keyed on segment-aligned directory suffixes, while step 3's
`normalized.indexOf(dirPrefix + '/')` is an UNANCHORED substring match, so
step 3 finds a strict superset — and it runs only when step 2 came back empty,
so those extra hits are observable, not shadowed:

  dirPrefix 'ubModels'  step 2 []  step 3 ['src/SubModels/Widget.cs']
  dirPrefix 'rc/Models' step 2 []  step 3 src/Models/* AND vendor/mysrc/Models/*

So the predicate is kept byte-for-byte and made fast instead. It depends only
on the file's directory (the needle ends with `/`, so every occurrence lies
wholly inside `D + '/'`), which reduces to the `package-dir-index` formula
minus the anchoring leading slash. `PackageDirIndex` itself cannot be reused
for the same reason — its matcher is anchored.

The index is memoized on the `normalizedFileList` array identity and built
lazily at the point step 3 is first reached, so BCL usings — which `continue`
out at the root-namespace gate — never pay for it. Candidates come from an
exact last-segment bucket when `dirPrefix` contains a slash, a last-segment
key sweep when it does not, and `singleSegmentDirs` when it is empty.
Positions rather than paths, merged and sorted when several directories match,
so file-list order survives.

  App.Missing @ {App, src}  1103.0 -> 7.6 us   (145x, and flat in file count:
                                                7.3 @10k, 7.6 @50k, 8.4 @200k)
  App.Missing @ {App, ''}    626.7 -> 108.5 us
  App @ {App, ''}           1077.9 -> 2.0 us   (539x)
  App.Ns8 @ {App, src}         0.6 -> 0.6 us   (step-2 hit, untouched)

`relative === ''` is preserved exactly, including the no-`projectDir` case
where the needle is a bare `/` and the answer is "every `.cs` whose directory
has no slash of its own" — `getFilesInDir('', '.cs')` cannot answer that over
repo-relative paths, so it has its own arm.

13 of 14 mutations are caught, including M1, the naive skip-when-indexed
cleanup, at 9 arms. The survivor drops the empty-prefix fast path and is a
true equivalence. M9 initially survived and exposed a real corpus gap — no
non-`.cs` file lived inside a directory — now covered.

The remaining non-constant term is the slash-free sweep, O(distinct last
segments): 456 us at 200k files on a unique-name layout, but 7.9 us on a
`SrcN/Models` layout, which is how C# repos are actually laid out. Closing the
unique-name case needs a character-suffix map over segments — the
O(files x depth) memory shape `package-dir-index.ts` cites #2649 to avoid — so
it is documented in the code as a design change rather than tuned here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* test(scope-resolution): assert index reuse for every registered language (#2909)

Index reuse was asserted by nine hand-written per-language files, so the
guarantee existed exactly for the languages someone remembered — and #2908 is
the proof that is not good enough: Java and COBOL were registered, quadratic
and unguarded until this branch. `resolveImportTarget` is a required member of
`ScopeResolver` with one signature and 16 registrations, so "calling it N times
against a stable `allFilePaths` must not traverse the set N times" is a
property of the CONTRACT.

`import-target-index-reuse.contract.test.ts` drives every entry of
`SCOPE_RESOLVERS`, modelled on `construction-syntax-wiring.test.ts` — the
established shape here for a property plus a justified inventory. Measured
counts, all memoized:

  c 1  cobol 1  cpp 1  csharp 2  dart 1  go 1  java 2  javascript 2
  kotlin 1  php 1  python 1  ruby 1  rust 0  swift 1  typescript 2  vue 2

**`KNOWN_UNINDEXED` is empty.** The audit that produced it also cleared C, C++,
Rust, Swift, TypeScript, Vue and JavaScript by hand — Rust's memo lives in
`qualified-call.ts::moduleIndexFor`, C's and Swift's loops are inside their
WeakMap builders. The empty map stays as a mechanism: a 17th language cannot
opt out silently, and the inventory arm fails when a registered resolver has no
fixture.

Two things the assertion had to get right:
  - it is `scans(200) === scans(2)`, not `scans === 1`. Per-language counts
    legitimately differ (C# and Java build two indexes), and comparing two
    counts needs no per-language expected value.
  - Rust legitimately scans ZERO times — it answers every leg with
    `allFilePaths.has(candidate)` probes — so the floor is a per-language
    `minimumScans`, 1 for fifteen languages and 0 for Rust with the reason on
    the interface. Paired with a `hitTarget` that must resolve non-null, so the
    property cannot pass vacuously on a resolver that stopped answering.
Miss targets are distinct per import, which defeats the TS/JS/Vue per-target
`resolveCache`.

Also unifies the instrument. Kotlin and Python counted index BUILDS from
production; the other seven count traversals of a `CountingSet`. The build
counter is strictly weaker — a scan added BESIDE a reused index moves no build
count, which is exactly the mutation `baselines.json` `_blind_spot` records as
invisible to every timing arm — and it costs two production modules that ship
in the bundle purely for tests, holding module-global state every test must
`reset()`. Both guards migrate to `CountingSet`, and
`languages/{kotlin,python}/index-stats.ts` plus both call sites are gone, for
-59 lines of shipped source.

(Mechanical note: the two `index-stats.ts` file deletions appear in the #2901
commit rather than this one. They were staged with `git rm` while a concurrent
commit swept the index. The final tree is correct; only that attribution is
off, and rewriting a sibling commit to move them was not worth the risk.)

Coverage went up in the swap: Kotlin's old "rebuilds when the file set is a
different object" arm (3 sets, 3 builds) would have PASSED under a defensive
adapter copy. Its replacement fails, as do all six arms across the two files.

Verified by mutation: `new Set(allFilePaths)` inserted into the kotlin, python
and go adapters fails exactly those three and no others —
`python: 200 imports cost 201 traversals, 2 cost 3`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* test(import-target): gate the four newly-indexed resolvers, retighten heap

The bench covered go/csharp/dart/ruby/kotlin. The four resolvers indexed on
this branch shipped unmeasured, and #2903's memory win was not locked in.

**php, java and cobol join the shared corpus**, each with the two load-bearing
properties the header requires: imports scale with file count, and most imports
MISS so the full cascade runs (resolve rates php 36.0%, java 34.4%,
cobol 36.0%). Java's miss families were measured rather than assumed, since it
has no in-repo-namespace gate (#2910): `java.*` 1041 imports and
`com.google.*` 1006, both resolving 0. COBOL's collide layout repeats a
bookname across BOTH extension tiers, so it reaches the copybook-over-source
tie-break rather than only the basename map.

**`csharp_csproj` is a sixth LANGS entry**, not a new arm dimension — an entry
needs five small additions and inherits all five arms and all seven gates,
where a context axis would have to be threaded through `buildRepo`,
`resolveAll`, `identityPass`, the report shape and every gate. `buildFiles`
aliases it to `csharp`, so the two share one corpus by construction and cannot
drift. Two configs (`{App, 'src'}`, `{Lib, ''}`) produce all three `dirPrefix`
shapes — slashed, slash-free and empty — in five arms instead of ten:

  App.Ns{d}      30.6%  src/Ns{d}        step 2 hit
  App.Missing{n} 25.5%  src/Missing{n}   step 3, last-segment bucket
  Lib            14.0%  (empty)          step 3, singleSegmentDirs
  Lib.Missing{n} 12.0%  Missing{n}       step 3, KEY SWEEP — the one
                                         non-constant path
  BCL / Ghost    12.4%  —                root-namespace-gate control

**2221 of 3200 imports reach the indexed leg**, only 12.4% `continue` out. What
that arm pins is stated plainly rather than overclaimed: step 3 answers null
for all 2221 here (the hits land at step 2), so it gates that leg's COST and
its null answers; its positive tie-breaks stay pinned by the unit parity test.

**Heap ceilings retightened.** #2903 dropped the measured figures, leaving the
1.5x ceilings at ~1.9x — a straight revert to the old size would have passed:

  csharp 116,000,000 -> 98,000,000 B   (measured 61.76 MiB)
  ruby    87,000,000 -> 69,000,000 B   (measured 43.69 MiB)
  php    new 106,000,000 B             (measured 67.29 MiB)
  java   new 154,000,000 B             (measured 97.32 MiB, the largest in the
                                        file — Maven layout is 18 segments)

php and java are gated because both retained NOTHING across imports at BASE and
now retain the O(files x depth) suffix index — the same argument that gates C#.
cobol is not: two `Map<basename, path>`, O(files) with no depth term, and its
retained delta does not clear measurement noise, so a ceiling would gate
nothing. `csharp_csproj` is not: same corpus, same index, a duplicate number —
its one distinguishing footprint, the lazily-built `dirMap` its `getFilesInDir`
forces back, is measured at +20.8% and recorded as a residual instead, because
gating it would licence eager-dirMap everywhere.

csharp's `depth_ratio` also fell 3.318 -> 2.31 (the no-csproj leg never asks a
directory question, so the deep arm stopped paying an eager dirMap build).
Budget 5 -> 3.5, restoring the file's 1.5x convention — and `_arms_note` says
plainly that 3.5 does NOT lock that win in, because locking it needs ~2.9,
which is 1.25x over a 1.05x spread and the kind of tightening `_triage` warns
buys flake rather than signal.

All five pre-existing languages are byte-identical: 25 cells x 5 fields = 125
values, 0 mismatches. The new arms were proven live by a doctored baseline
(cobol ceiling 0.01, php heap 1000 B, java resolved 999) producing three
correctly-worded failures and exit 1.

Wall-clock 10.9 -> 26.1 s, php and csharp_csproj ~11 s of it — both cascades
end in `suffixResolve`'s ~50-extension probe, and both gate the two largest
wins on this branch, so neither is a candidate to drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(javascript): build the suffix index JS resolution never had

JavaScript's `PassCache` was TypeScript's minus one field: `index`. So JS
called the shared `resolveTsTarget` with `ctx.index === undefined`, and
`import-resolvers/standard.ts` fell through to `suffixResolve`'s linear
`findIndex` — scanning the materialized path list once per extension (~39)
per path part, per import.

  2000 files   6448.9 -> 28.5 us/import   (TypeScript: 25.0)
  8000 files  25972.6 -> 27.4 us/import   (TypeScript: 27.0)

Per-import scaling over 4x the files: 4.12x -> 1.09x.

**Every instrument on this branch was blind to it.** `CountingSet` counts
traversals of the Set; this walked the array the adapter had already
materialized — the blind spot `counting-file-set.ts` documents in its own
header and `baselines.json` records under `_blind_spot`. Under mutation M1,
which drops `index` and reproduces the shipped defect exactly, the sixteen-
language contract test stays GREEN for javascript, because the pass cache is
still reused and `files.scans` reads 2 either way. Two new arms do catch it: a
`suffixResolve` linear-branch counter that runs the legacy adapter first as its
control (135 entries legacy, 0 now), and a mock-free behavioural assertion that
a repo-root module resolves by bare specifier.

Adding an index moves output, exactly as it did for PHP in #2901, so it was
characterized rather than assumed — 211,200 pairs (400 corpora x 3 importers x
176 targets) plus 184 hand cases. **Two classes move and there is no third:**

  A  null -> repo-root file (108)   `require('config')` with root `config.js`.
     The scan tests `endsWith('/' + suffix)`, so a path with no slash has no
     proper suffix and was unreachable through that leg — while `./config`
     from the root already resolved via the exact `Set.has` branch. JS was
     internally inconsistent.
  B  file -> different file (5679)  `import 'app/main'` was resolving to
     `node_modules/dep0/lib/main.js`; the scan skipped the whole-path candidate
     at the 2-segment suffix and fell through to the 1-segment `/main.js`,
     taking the first such file in Set order.
  C  hit -> null                     ZERO, and impossible: proper-suffix keys
     are a subset of the index's keys.

Both moved classes are JS being wrong. **JS-new agrees with TypeScript on all
211,200 pairs and every corpus case, 0 disagreements** — which is the intended
design, since JS delegates to the TS resolver and differed only by this field.

Also swaps the single-slot `let cached: PassCache | null` in JS, TS and Vue for
a module-level `WeakMap`, matching every other language. Two alternating file
sets rebuilt everything on every call: 12.0 -> 1438.2 ms at 4000 files x 400
imports (120x); after, 11.0 -> 15.7 ms. This is LATENT, not live —
`pipeline/run.ts:673` builds one Set per provider pass and the three are
separate providers — but it is why these were the only languages that could not
carry the standard distinct-set guard. They can now: the arm fails on HEAD for
all three (`expected 42 to be 2`) and passes after.

Six mutations caught, including a global `resolveCache` (M5), which needed a
new arm — `expectDistinctFileSetsGetOwnIndex` builds two IDENTICAL corpora, so
a stale answer carried between them is also the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* refactor(ingestion): one per-file-set memo primitive, twenty-one call sites

Every language that indexes its import resolution hand-rolled the same memo:
declare a module-level `WeakMap` keyed on the file-set object, `get`,
`if undefined` build and `set`, return. One concept, written twenty-one times,
and this branch had just added five more.

`import-resolvers/per-file-set.ts` exports it once:

    perFileSet<K extends object, T extends object>(build: (key: K) => T): (key: K) => T

Two decisions, both recorded in the file. `T extends object` rather than
`has`-then-`get`: `WeakMap.get` returning `undefined` cannot distinguish "not
built" from "built as undefined", and the `has` form needs a cast or a non-null
assertion, both banned here — the constraint makes the ambiguous case
unrepresentable instead, and a future caller wanting `string | null` gets a
compile error pointing at the decision. A throwing build stores nothing and
runs again next call, so failures are not memoized and a half-filled index is
never published — inert for these pure builders, and the safer direction.

`K extends object` rather than `ReadonlySet<string>` is what lets C#'s
`readonly string[]`-keyed cache share the helper.

Twenty-one sites migrated across `import-resolvers/` and fifteen languages.
Every existing doc comment was re-homed onto the new call rather than deleted —
several record real invariants (the Set-identity contract, the #1918
pass-through rule, why Rust's memo lives on a different hook).

TypeScript, JavaScript and Vue additionally had byte-identical `PassCache`
interfaces and builders. `import-resolvers/pass-cache.ts` now holds the one
builder, taking a single argument — every difference the three have lives in
the CONSUMER (`tsconfigPaths`, the extension list), not the builder. The
builder is shared, the memo deliberately is not: each adapter keeps its own
`perFileSet`, hence its own index and its own `resolveCache`, because the three
disagree about what a specifier resolves to and one shared cache would hand a
language another language's answers. It buys no runtime reuse and the module
says so — each provider pass builds its own `allFilePaths` Set, so the three
are always different keys.

C and C++'s `augmentedFilePaths` was a two-LEVEL memo, and needed no new
abstraction: the outer memo's value is a function and a function is an object,
so `perFileSet(perFileSet(...))` composes. The two instances stay one per file,
and the reason is now in BOTH doc comments rather than only C++'s — cpp
delegates to `resolveCImportTarget`, whose `suffixIndex` is keyed on the
augmented set, so a shared memo would cross the two languages' indexes.

Two sites are deliberately NOT migrated, each with the reason written at the
declaration so the next sweep does not re-litigate them:
  - `configs/swift.ts` is a two-input memo keyed on one. `targets` is not
    derivable from the key; re-keying on `ctx` would force a banned non-null
    assertion or an unreachable fallback inside a memo builder.
  - `rust/qualified-call.ts` `MODULE_SCOPE_CACHE` is three inputs keyed on one,
    and sits ten lines below a `perFileSet` in the same file — the likeliest
    thing to be "fixed" by mistake.

The other ten remaining `WeakMap`s are different concerns and stay: AST-node
caches, worker-pool runtime state, graph metadata, mutable lazily-filled
accumulators, and the C++ ADL / inline-namespace indexes, which are reassigned
by explicit clear functions and epoch-stamped on read — validity rules beyond
key identity that a closure over a private cache cannot express.

Net −20 lines of code, +22 of the two "why not" notes. The primitive's own doc
is where the cost sits: the Set-identity contract and the two design decisions
are written once instead of being twenty-one implicit facts.

Pure refactor: 1764 unit tests, 42 guard tests, all sixteen contract-test
traversal counts unchanged (c 1, cobol 1, cpp 1, csharp 2, dart 1, go 1,
java 2, javascript 2, kotlin 1, php 1, python 1, ruby 1, rust 0, swift 1,
typescript 2, vue 2), 647 C/C++ tests, and every bench fingerprint unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* test(import-target): gate every registered language, not nine of sixteen

The bench pinned output fingerprints and scaling for 9 of the 16 languages in
`SCOPE_RESOLVERS`. The other seven — c, cpp, javascript, python, rust, swift,
typescript, vue — resolve imports in production with nothing pinning their
output or their cost. JavaScript was the sharpest case: the 25,972 us/import
defect fixed earlier on this branch was gated by unit tests alone.

All 16 are now gated, plus the `csharp_csproj` variant: 17 entries.

**The nine existing languages are byte-identical** — 234 committed values
(9 x 5 arms x 5 fields, plus 9 top-level fingerprints), 0 changed, and no
pre-existing budget touched. Measured both before and after the memo
consolidation in e6f15274e, so it doubles as an independent check that the
refactor preserved behaviour.

Corpora keep both load-bearing rules — most imports MISS, and import count
scales with file count — at resolve rates of 26-36%. C and C++ follow the
`csharp_csproj` precedent: a `LANGS` entry carrying its own context (header
paths through `resolutionConfig`) over an aliased corpus, since cpp delegates
into C's `resolveCImportTarget`. Vue threads `tsconfigPaths` so its alias
branch actually runs; ts/js use bare specifiers only, because relative ones
never reach `suffixResolve`.

Two corrections to my own profiling, both verified rather than assumed:
Swift's `byModule` IS depth-scaled (one bucket entry per interior segment, not
O(files)), and Python's index is depth-free while its RESOLVER is quadratic in
depth — `hasRepoCandidate` and `resolveAbsoluteFromFiles` each rebuild one
ancestor prefix per importer directory component, per import. That is why
python's `depth_budget` is 11 against a 3.5 next-highest; the arm is pinning a
real defect rather than a comfortable number, and it is filed separately.

Rust's collide arm was redesigned rather than budgeted away: it is flat on file
count by construction, so a shared-leaf arm would have asserted nothing. Its
collide corpus varies `::` segment count — the axis its cost actually has — and
the linear 1.8 budget asserts the file-count flatness.

Heap: all 8 measured, 3 gated. javascript (44.07 MiB, retained nothing before
its fix), python (7.27 MiB), c (9.55 MiB). Five skipped with their numbers in
`_arms_note` rather than silently: rust 16 B (no index on this hook), swift
reads 3x SMALLER on a 4x corpus so it is below its own noise floor, typescript
288 B on 46 MB, vue +5.4%, cpp 0.04% from c.

Every gate type was proven able to fail: one run with 10 doctored values fired
10 correctly-worded failures across all 8 new languages, covering per-scale
fingerprint, shape/resolved, shape/distinct_outcomes on a non-small arm, depth,
collide scaling, absolute small ms, absolute collide ms, top-level fingerprint
and heap bytes. That proof found two wrong messages, now fixed: the heap
failure claimed a `buildSuffixIndex` cause that is false for python and c, and
the fingerprint failure pointed at a parity harness covering none of the eight.

Wall clock 26 -> 46 s. The ts/js/vue family is 14.6 s of the 18.8 s added,
because `suffixResolve` probes ~39 extensions per path part on a miss — the
real resolver, not something the bench can tune. Per language the bench got
cheaper (2.7 s vs 3.0 s). If it must shrink, `_arms_note` and the CI comment
record the one cut that removes duplicate work rather than coverage — drop
collide for typescript and vue only, -3.9 s, since all three share
`resolveTsTarget` and javascript keeps the arm covering their common axis.
Explicitly NOT `REPS`: it is 15 because `depth_ratio` flaked 1-in-20 at 5, and
lowering it would re-open that for all 17 languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(import-resolvers): stop building half of every suffix index

Applies the findings of a four-lane quality review over this branch.

**Half of `buildSuffixIndex` was dead weight for most of its consumers.**
Commit b6ee577e0 on this branch made the THIRD map (`dirMap`) lazy for exactly
this reason and left the two larger ones eager. Tracing every reader: Java and
no-csproj C# call `get` and never `getInsensitive`; PHP calls `getInsensitive`
and never `get`. Measured dead weight at 32k paths: Java 49.98 MiB of a 100.82
MiB index, PHP 34.49 of 69.85.

All three maps are now built on first use, and `lowerMap` is DERIVED from
`exactMap`'s insertion order rather than re-traversed — measured 330 ms against
389 ms today, so it is cheaper even for the two-map consumers. `pass-cache.ts`
hands the builder an already-lowercased list, so for TypeScript, JavaScript and
Vue the derivation is the identity and `getInsensitive` aliases the one map.

  java            80.26 -> 25.61 MiB retained   (-68%)
  csharp no-csproj 57.15 -> 21.52               (-62%)
  javascript       44.07 -> 22.65               (-49%)
  php              60.86 -> 32.09               (-47%)
  build @32k      562.1 -> 119.6 ms  (get-only), 329.5 ms (both)

The derivation is proven, not asserted: keys, values AND insertion order
byte-equal over 968,418 entries across case-colliding, Unicode-adversarial and
pathological corpora, plus 400 seeded-fuzz rounds. Order matters because it is
what makes `getInsensitive` return the first match in file order.

PHP additionally defers `filesByRawDirectory` (statically unreachable unless a
composer.json parses) and `firstProperSuffixMatch` (0 entries and 35.6 ms on
the bench corpus) to the branches that read them.

One suggested micro-optimisation was REJECTED with a counterexample rather than
taken: hoisting `suffixResolve`'s lowercase out of the extension loop assumes
`(s + ext).toLowerCase() === s.toLowerCase() + ext`, which is false for a
segment ending in Greek capital sigma — `("ΑΣ" + ".ts").toLowerCase()` is
`"ασ.ts"`, not `"ας.ts"`, because Final_Sigma is context-sensitive and `.` is
case-ignorable. A file named `ΑΣ.ts` would have stopped resolving. 16
mismatches in 2,171,190 checks, for 8.7%.

**The heap arms had become ceilings over nothing.** `retainedIndexBytes` read
only `index.all.length`, so once the maps went lazy it built none of them and
reported ~0 B — passing every ceiling. All heap arms now route through
`retainedPassBytes`, resolving a real missing import through the real resolver,
so the maps measured are the maps production forces. Two further measurement
defects surfaced while fixing it: PHP reaches the index through a second memo,
so the ephemeron chain needs four GC cycles and was reporting 249,208 B for a
9.3 MB index; and `bytes_large` carried an ~11% rope-flattening bias that made
every ratio read 0.85-0.96 for structures that are linear (now 0.998-1.017).

A `heap_floor_fraction` arm was added — a ceiling can only say "not too big" —
and proven by simulating the exact regression: `16 B at 32000 files < floor
17325000 B — this arm has almost certainly stopped MEASURING`.
`csharp_csproj` is now gated too: its old exclusion as "a duplicate of csharp"
held at +20.8% and is false at 2.47x.

**Three silent-coverage holes in the bench.** `LANGS` was a hand-written
literal claiming to mirror `SCOPE_RESOLVERS` while never importing it — the
seam that let JavaScript ship ungated; it is now derived, with an inventory arm
reconciling both directions. Four per-language budget lookups compared against
a possibly-`undefined` value, so deleting a key deleted the gate. Five
dispatchers ended in bare fallthroughs meaning "ruby" and "csharp", so a
mistyped language would have been benchmarked as Ruby's corpus under C#'s
resolver, forever green.

REPS is now chosen per language (15 below 5 ms, else `clamp(ceil(150/ms),7,15)`)
rather than globally by the noisiest cell: timing phase 39.8 -> 28.7 s, with the
six reduced-N languages showing peak-to-peak 1.008-1.071, no worse than the
eleven that kept 15. Worst headroom across all 85 cells is 0.71 of budget.

`depth_budget` for csharp 3.5 -> 2.2 and java 3.4 -> 2.2: their ratios fell to
1.438/1.402 because the lazy maps stop the deep arm paying for a map it never
reads. The file's own note said 3.5 did not lock that win in; 2.2 does.

Also fixes a raw NUL byte that made `suffix-index-lazy-dir-map.test.ts` BINARY
to git — all 395 lines were invisible to diff, blame and grep. The repo
documents this exact hazard in `route-extractors/dispatch-guard.ts`. That file
now also carries the guard the refactor lacked: eight arms pinning one-map-per
consumer and zero-extra-pass derivation, each proven against four mutations,
including a fused-eager rebuild that moves no total and is caught solely by the
at-construction count.

All 17 bench fingerprints and all 85 per-scale tuples unchanged. 1772 unit
tests, 12 adapter guards, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

* perf(python): memoize the importer's ancestor chain per directory (#2913)

Python's file index was always depth-free; the resolver was not.
`hasRepoCandidate` and `resolveAbsoluteFromFiles` each rebuilt one ancestor
prefix per directory component of the importer on EVERY import, and the
index's own `dirPrefixes` build inserted one entry per component per file.
So an import from `a/b/c/d/e/f/mod.py` did ~6x the prefix work of one from
`a/mod.py` regardless of corpus size — `depth_ratio` 7.239 where the next
worst language sat at 3.446.

The prefixes are a pure function of the importer's DIRECTORY, so they are
memoized per directory inside `getPythonFileIndex` (`ancestorsByDir`), which
is itself already per-file-set. Three smaller cuts came out of profiling the
same delta: the leading segment is rejected up front against a set of nested
directory names, the module and package buckets are consulted before the
walk instead of inside it, and the `dirPrefixes` build stops at the first
ancestor already stored.

Measured over 6 serial runs: depth_ratio 1.748-1.872 against 7.239, and at a
fixed 400 files the per-import cost at 18 directory components drops 6.761 ->
1.065 us. All five python fingerprints are byte-identical, so this is a
hoist; the budget retightening lands in the following commit, because
`_arms_note` is a single JSON line that also carries the heap-gate rewrite.

Also memoizes `pythonFileExportsName`'s `parsedFiles.find`, which was
O(files) for every import whose package probe resolved — the same shape
#2901 removed, keyed on `parsedFiles` rather than on `allFilePaths`.

The new gate is a count, not a timing: `ancestorsByDir.size` after N imports
from D directories must equal D, paired with a reference-identity assertion
so a memo that rebuilds AND re-stores still fails. `CountingSet` cannot see
this defect — the chain derives from the `fromFile` string and a rebuilt
prefix traverses the file set zero extra times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* fix(import-target): close the eleven findings from the #2911 review

Seven P2s and four P3s. Every one is a gate that could not fail or a
comment that had become false; no shipped behaviour defect was found, and
all 85 per-language fingerprints are unchanged.

GATES THAT COULD NOT FAIL

- The C# namespace-dir memo was keyed on a materialized array, so a
  one-character `[...normalized]` copy at the adapter boundary minted a
  fresh WeakMap key per import while traversing the file set zero extra
  times: 67 tests stayed green and only a timing ratio caught it.
  `resolveCSharpImportInternal` now takes the Set and derives both arrays
  from `getWorkspaceFileIndex`, so there is ONE key shape and ONE
  instrument. Copying the Set now turns three arms red. Established first
  that `configs/csharp.ts` is test-only (`buildImportTargetWorkspace` has
  no production caller) and that both derivations are byte-identical —
  otherwise the rekey would have been a behaviour change, not a hoist.

- The contract test called `resolveImportTarget` with four arguments where
  `pipeline/run.ts:682` passes five, so everything behind `context` was
  ungated for all 16 languages: defeating PHP's `filesByDirectory` memo
  cost 197.0 -> 9,976.2 us/import (50.6x) with 248/248 tests green.
  `CountingSet` provably cannot see it — the builder iterates the
  `parsedFiles` array and touches the Set zero times — so the new gate
  counts own-index reads on `parsedFiles` through a Proxy. Only PHP and
  Python have a context leg; the other fourteen carry the floor anyway.

- Three heap budgets were read with no presence check. `ceiling * undefined`
  is NaN and `bytes < NaN` is false, so deleting `heap_floor_fraction`
  disabled the floor for all eight arms; deleting `heap_ratio_budget` did
  the same; and iterating the baseline's keys dropped a language whose
  ceiling key was deleted out of the gate entirely. All three now fail
  closed with a message naming the broken comparison.

- `HEAP_PROBE_TARGET` decided what each heap arm measured and was compared
  to nothing: repointing csharp_csproj at a non-matching namespace dropped
  it 73.70 -> 59.92 MB with `--check` still exiting 0. The four corpus
  fields are now asserted through the loop the timing scales already use,
  and the floor derives from a recorded reading rather than from a ceiling
  that is itself 1.5x the measurement.

- About 35 of the 86 PHP parity arms were structurally unable to fail:
  both sides called the same production helper, so deleting the `..` guard
  left them green. Every hand case now pins an absolute literal as well as
  the differential. Eight of those literals pin a bug or a documented
  limitation and say so rather than blessing the value.

- The registry inventory arm was weighed and KEPT, against the review's
  suggestion, on a structural number rather than a timing: the benchmarks
  job runs 9m23s against a 12m58s critical path, so its seconds buy no
  merge latency, and moving the arm to vitest would put the registry load
  ON that path while weakening what it reconciles. The "7.3 s" and
  "~46 -> ~42 s" figures it was justified with are corrected, including
  stating that only report mode got faster.

- python's `depth_budget` drops 11 -> 2.6 now that #2913 is in. 1.39x the
  measured maximum rather than the file's usual 1.5x, deliberately: at 2.8
  a revert of the nested-name rejection (2.734) would pass. The two parts
  of that fix this arm cannot gate are named, with the count-based arms
  that do gate them.

COMMENTS THAT HAD BECOME FALSE

- `pass-cache.ts` said it deduplicated "three byte-identical copies".
  JavaScript's had five fields and never called `buildSuffixIndex` — that
  missing field IS this PR's headline defect.
- The per-language census said nine where it is twelve, three of them
  added by this PR. Replaced in seven places with the mechanism that
  enforces it, which cannot go stale.
- `getFilesInDir` handed out the index's live bucket. Now `readonly
  string[]`, so mutation is a compile error; `.slice()` was rejected
  because `configs/python.ts` reads only `.length` and a per-import copy
  would reintroduce the term this PR removes.
- #2910 is the Java in-repo-namespace gap, not the JavaScript index defect.
  13 references corrected, the one correct Java use left in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* perf(python,bench): flatten the bare-import walk, measure the context leg

Two follow-ups the #2911 review surfaced but left open.

BARE IMPORTS (`import os`) still walked every ancestor of the importer.
#2913 fixed the dotted tier; this tier lives in `import-resolvers/python.ts`
and no bench arm can reach it, because every python arm here spells its
imports with a dot and returns at the `pathLike.includes('/')` guard.

It also ran TWICE per `from x import y`: `resolvePythonImportTarget` probed
the package with `targetIncludesImportedName: true`, and on null — the
expensive case, having already walked to the workspace root — fell through
to a byte-identical call. Established that the two cannot differ before
collapsing them: the flag's only effect is to skip
`pythonImportedSubmoduleTarget`, so the recursion re-runs the outer frame's
entire tail on the same three references, and reaching the fallthrough means
that tail already returned null.

The walk itself is now a memoized chain plus an O(1) proof of absence
against the index's basename buckets. Its chain is NOT the one #2913
memoized and the difference is semantic, not accidental — no
`filter(Boolean)`, self excluded, workspace root included — so under an
absolute-path workspace the unfiltered chain probes `/abs/a/` where a
filtered one would probe `abs/a/`, a prefix of nothing. Two negative arms
pin that in both directions. The shared index moved to
`import-resolvers/python-file-index.ts` rather than being reached across a
cycle, which also collapsed a standalone memo into the one per-file-set.

12 / 24 / 72 Set probes at depth 1 / 4 / 16 become a flat 2. At 18 path
components, 11.615 -> 0.740 us/import (15.7x) and the depth curve is gone:
7.843 -> 0.925. Gated by probe COUNT, not timing.

THE BENCH CALLED `resolveImportTarget` WITH THREE ARGUMENTS where
`pipeline/run.ts:682` passes five, so no timing arm entered the `context`
leg for any language. Arity checked against the registry rather than the
comment: php and python declare five, every other hook three or four.
`parsedFiles` is built first and `allFilePaths` derived from it, matching
`run.ts`; fresh per pass, because the memos behind that leg key on the
array identity and `fastest()` takes a min.

Python's `parsedFiles` was structurally unreadable, not merely unread: the
arm passed a `namespace` spelling, which makes `pythonImportedSubmoduleTarget`
return null before the context is consulted. The import KIND had to change
too.

No fingerprint moved anywhere — on this corpus PHP's leg returns the same
file the cascade already did — which is exactly why the new `context` arm
asserts with-context against without-context instead. Defeating PHP's
`filesByDirectory` memo now costs 1003.7 ms against a 148 ms budget; before
this the bench could not see it at all.

Re-recorded on a quiet box, maxima over 5 serial runs: php small 27.762 ->
34.023 and heap 37.6 -> 49.6 MB (`filesByDirectory` is now retained for the
pass), python small 1.76 -> 4.358. `depth_budget.python` moves 2.6 -> 2.2,
because the added work is depth-FLAT: absolute cost doubled while the ratio
FELL to 1.563, so the old budget had gone slack. Both lock-in figures were
re-measured under the new call shape rather than carried over — reverting
the ancestor memo scores 2.524, reverting the nested-name rejection 2.553,
so each fails at 2.2 with 13% to spare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(import-target): make the key-shape rule a type, drop three censuses

Cleanup pass over the #2911 review-fix commits. No behaviour change: all 85
per-language fingerprints, every `resolved` and every `distinct_outcomes` are
byte-identical, and the targeted suite is 1851/1851.

MEASURED — `byBasename` was 71% empty array slots

`byBasename` holds roughly one bucket per file, and building each with `[]`
followed by `push` makes V8 grow the backing store to its 16-slot minimum, so
every single-file bucket retained 15 empty pointer slots. Constructing the
one-element bucket directly is byte-identical in contents and 5.50 -> 1.60 MiB
at 32000 `.py` paths. The bench arm reads 10543848 -> 6360936 B (-39.7%);
`heap_reading_bytes.python` and its ceiling are re-recorded. The same edit
shares one `{ raw, norm }` between both maps instead of allocating a second
literal for every `__init__.py`.

THE RULE THAT COST A TIMING RATIO TO FIND IS NOW A COMPILE ERROR

`perFileSet`'s key is narrowed from `object` to
`ReadonlySet<string> | readonly ParsedFile[]`. Reintroducing the #2911 defect
shape — a memo keyed on an array materialized from the file set — now fails
with TS2345 instead of silently minting a fresh `WeakMap` key per import while
traversing the Set zero extra times, which every scan-counting guard reads as
green at its correct value.

That also retires the header's hand-maintained roster of `ParsedFile[]`-keyed
call sites, which listed three — this PR added a fourth in `395c707d4` and did
not update it. A census inside a comment warning that censuses go stale, stale
inside one commit. The header now names shapes; the compiler names sites.

Two more claims that had drifted from their code:

- `per-file-set.ts` asserted "No index derived from the file set is keyed on an
  ARRAY materialized from it". `configs/swift.ts` is, deliberately, with its
  reasons written down. Two files in one directory disagreeing is worse than
  either; the rule now states what the type rejects and names the exception.
- `SuffixIndex.getFilesInDir`'s doc explained that it returns the index's own
  bucket by reference. True of `buildSuffixIndex`; the other implementation of
  that interface, in `languages/php/import-target.ts`, returns a filtered copy.
  The interface now carries only the caller-facing contract (`readonly`, do not
  mutate) and the sharing rationale moved onto the implementation it describes.
- The contract test still described Python as having "NO memo on this key".
  `parsedFileByPath` landed in `395c707d4`; the floor of 1 is now its single
  build rather than a per-import scan.

DEDUP

`importerDirOf` replaces four copies of `replace / lastIndexOf / slice` — two in
production, where one was a memo KEY and the other a memo's query argument, so
the two per-directory memos in one index agreed only by inspection. The tests
keep their own verbatim derivation on purpose: importing production's would
make the key lookup agree by construction and hide a regression.

`buildParsedFiles` maps through `probeFile` instead of repeating its 7-field
literal 900 lines away; `requireNumericBudget` and `expectNoOrphanKeys` replace
three and three copies, with every per-arm `why` kept per-arm. The two Python
memo guards collapse onto shared arms in `test/helpers/counting-file-set.ts` —
1847 tests before and after, and both still go red under mutation.

SKIPPED, with reasons: dropping `normSet` for bucket scans (trades O(1) probes
on the hot path for ~1.6 MB against a 6.4 MB reading); measuring heap for all
17 languages (+9 s and a design decision, not a cleanup); `readonly` on the
five sibling resolvers' array parameters and the `getDirMap` slice/join rewrite
(both correct, both outside this diff).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* perf(import-target): rewrite the dirMap build, gate heap for every language

The three items the /simplify pass deferred, plus what measuring them found.

`getDirMap` BUILD — 226.9 ms -> 173.1 ms at 32 000 paths

It built every key with `dirParts.slice(j).join('/')`: one parts array, one
slice array and one joined string per file per directory component, in the map
its own doc calls "by far the most expensive" of the three. Now a
`lastIndexOf` walk slicing substrings out of the original string — the same
rewrite `getExactMap` already records at 357.4 -> 264.5 ms.

The key set is identical, not merely equivalent: 272 956 keys over a 32 000
path corpus carrying absolute paths, leading/interior/trailing doubled
separators, Windows separators, extensionless files, dotfiles, dotted
directories and colons, run both slash-normalized and raw. Zero differences in
keys, in key INSERTION ORDER, in bucket contents, in bucket ORDER, or across
767 732 probes through the real index. Bucket order matters because `php.ts`
reads `[0]`.

READONLY on the per-pass shared arrays

`WorkspaceFileIndex.normalized`/`.all` and the `normalizedFileList`/
`allFileList` parameters of jvm, php, ruby, go and standard are now
`readonly string[]`. This PR already made that argument for one bucket
accessor; these are the two biggest arrays held for a whole pass, and the
blast radius of an in-place sort is larger. Types only — no cast, no copy —
and it let two pre-existing `as string[]` casts in
`languages/typescript/import-target.ts` be deleted rather than added to.

HEAP IS NOW MEASURED FOR ALL SEVENTEEN LANGUAGES, AND THE PROSE WAS WRONG

Nine were excluded on measurements taken once and never re-checked, with the
re-entry condition stated in a comment and watched by nothing. Measuring them:

- go, dart and kotlin had NO stated reason at all — the header said "six of
  seventeen" against a list of eight. kotlin retains 45.85 MiB, the
  second-largest reading in this file, larger than ruby's and java's;
- swift and cobol were recorded as below-noise (0.29 MB, 0 B). They read
  3.29 MB and 2.21 MB and grow the right way. The arm changed under them —
  #2903's real-import probe, then corpus flattening — and nobody re-took it;
- the header quoted javascript at two different values four paragraphs apart.

Only rust's exclusion survived: 16 B at both scales, identical over five runs.

Six of the nine are now FULLY budgeted rather than merely bounded — ceiling,
floor and ratio — because each grows linearly (0.996-1.004 against a 1.25
budget). cobol, swift and rust keep an upper bound and no floor, deliberately:
a floor over a reading at or below its own noise gates the noise. Proven live:
restating kotlin's reading so its floor clears the real measurement fails with
"this arm has almost certainly stopped MEASURING rather than started saving" —
the failure that once left four arms at 0 B under passing ceilings.

Cost: +1.37 s in the heap phase, measured per language rather than asserted.

`normSet` was NOT removed, and the reason is now in the code. It is derivable
from the two buckets, but `byBasename` is keyed on BASENAME: on a 9 000-file
service tree `utils.py` and `models.py` hold 1 000 entries each, so `import
utils` would scan every `utils.py` in the workspace per import — the exact
defect class #2901/#2902/#2908 removed. ~1.6 MB against a 6.4 MB reading buys
both probes staying O(1).

All 85 per-language fingerprints unchanged; 1854 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* test(php): drop the impossible undefined comparison from the parity copy

CodeQL (js/comparison-between-incompatible-types, alert 945) flags the
`ctx === undefined` arm of the legacy adapter copy: `WorkspaceIndex` is an
object type at that position, so the comparison can never be true.

Optional chaining expresses the same guard without the type-level clash —
an undefined index still fails the `typeof` test and returns null — so the
copy remains behaviourally verbatim against the shipped adapter, which is
the only property this harness relies on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:22:51 +01:00
azizur100389
4576adfc46
fix(java): emit Record interface heritage (#2916)
* fix(java): emit Record interface heritage

Synthesize inheritance references for Java record implements clauses so scope resolution emits canonical heritage and interface-dispatch edges.

* test(java): cover Record heritage review gaps

Document deferred enum and implicit-accessor behavior, make assertions order-independent, and add Record heritage to the capture benchmark.
2026-08-10 13:35:16 +01:00
azizur100389
49c5b7d81f
fix(scope-resolution): fan out C# Record interface calls (#2904)
Some checks failed
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
* fix(scope-resolution): fan out C# Record interface calls

Use the shared class-like predicate so canonical C# Record implementors participate in interface dispatch, and pin the missing call edge with a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scope-resolution): preserve partial Record dispatch

Keep every scope definition that shares a graph node so interface fan-out is independent of partial declaration order, and strengthen C# dispatch controls.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-09 18:39:47 +01:00
Carter LaSalle
81100e2c74
fix(python): resolve calls through __init__.py re-exports (#2864)
* fix(python): resolve calls through `__init__.py` re-exports

A call to a name imported from a package never resolved when the package's
`__init__.py` re-exported it rather than defining it:

    pkg/impl.py       def target_fn(x): ...
    pkg/__init__.py   from pkg.impl import target_fn
    caller.py         from pkg import target_fn
                      def calls_it(): return target_fn(21)   # no CALLS edge

`caller.py` gets no CALLS edge. Both IMPORTS hops are recorded, and all four
functions are extracted as nodes — only the call binding is missing. Because
`__init__.py` re-exports are how Python packages declare a public surface, this
misses a large fraction of real call edges, and the failure is silent: the
defining file looks like dead code with zero callers.

The re-export closure that should carry this already exists and is fully general
(`buildReexportClosures` — SCC over the re-export subgraph, bounded fixpoint for
cycles, transitive `via` chains). Python just never fed it: the subgraph admits
only `kind: 'reexport'` and `kind: 'wildcard'`, and Python emits neither for
`from m import x`.

Python has no dedicated re-export form. A module-level `from pkg.impl import X`
binds X locally AND publishes it as `pkg.X`, so it is both a named import and a
re-export. Emitting `kind: 'reexport'` would be wrong — that form drops the local
binding, which Python's does create. Instead add an optional `reexportsName` flag
to the `named`/`alias` variants, alongside the existing provider-specific
`importedSymbolKind` / `targetIncludesImportedName` flags, and admit flagged
imports into the closure subgraph. Languages with an explicit form keep emitting
`kind: 'reexport'` and leave the flag unset, so nothing changes for them — a
negative-control test asserts a plain named import still does not resolve.

Verified on a fixture covering the three shapes (direct, top-level-via-re-export,
function-local-via-re-export): 1 of 3 CALLS edges resolved before, 3 of 3 after.

On a 12.4k-file Python/Go/TypeScript repository: edges 294,416 -> 301,443
(+7,027) and execution flows 300 -> 813. A previously "100% orphaned" module
(`shared/db/event_writer.py`) now correctly reports its caller.

5 new finalize tests (single hop, 3-hop chain, alias keying, cycle termination,
and the negative control) plus 6 updated Python fixture shapes.
`npx tsc --noEmit` clean in both packages; full unit suite shows no regression
against baseline (remaining failures are pre-existing load-sensitive flakes in
analyzer-identity / evidence-provenance-helper / skip-git-cli / hooks, each
verified passing in isolation).

* fix(python): set reexportsName only for module-level imports

`interpretPythonImport` flagged every `from m import x` as republishing the
name, but only a module-level statement does. A `from m import X` inside a
`def` or `class` body binds locally and puts nothing in the module namespace,
so flagging it fabricates a re-export of a name no importer can reach:

    # pkg/__init__.py
    def loader():
        from pkg.impl import InternalHelper
    # caller.py
    from pkg import InternalHelper      # CPython: ImportError

resolved to `def:pkg.impl.InternalHelper`. Worse, with declaration-order
first-wins in the closure, a scope-blind entry could claim a name ahead of the
real module-level import and give a WRONG def for legal, running code.

`interpretImport` receives a `CaptureMatch`, which is `{name, range, text}`
with no syntax node, so the scope is not recoverable there — and it is not
recoverable downstream either: `pass3CollectImports` applies no scope filter
and `ImportEdgeDraft.fromScope` is hardcoded to the module scope. The decision
therefore moves up to `import-decomposer.ts`, which still holds the live
`import_from_statement` node, and rides down as an `@import.publishes` marker.
Computed once per statement, not once per imported name, with the existing
`findAncestorBeforeBoundary` helper.

Only `function_definition` and `class_definition` suppress publication.
`if` / `try` / `for` / `with` do NOT — Python has no block scope — so the
predicate is an ancestor walk for those two node types and nothing else.
Verified against CPython 3.11 in both directions; both are now pinned by
tests, including the counterpart control that a branch-nested import still
republishes.

Also corrects the docblock in `scope-extractor.ts` that sent this change the
wrong way. It claims pass 3 attaches imports "not to any `Scope` — finalize
reconstructs the owning scope via `provider.importOwningScope` during Phase
2". Finalize does no such thing: `importOwningScope` is declared on
`LanguageProvider` and implemented by a dozen providers, and
`grep -rnE "\.importOwningScope\b" gitnexus/src/` returns exactly one hit —
that doc comment. Nothing invokes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* fix(shared): stop guessing ambiguous and namespace re-exports; bound the via chain

Four changes to the re-export closure, all reachable only now that Python
feeds it.

1. AMBIGUOUS NAMES ARE DROPPED, NOT GUESSED. `populateFileClosure` documented
   "declaration order first-wins for duplicates of the same exported name",
   which is sound only where a duplicate export is illegal — two
   `export { X } from …` is a TypeScript compile error, so the rule never
   fires. Python has no such guarantee:

       from .v1 import Client   # legacy, left behind
       from .v2 import Client   # the actual public Client

   CPython binds v2 (verified on 3.11); first-wins attributed every
   `from pkg import Client` in the repo to the DEAD implementation, and
   `impact("Client")` pointed at the wrong file. Last-wins is not the fix
   either: for the equally common `try:`/`except ImportError:` and
   `if sys.version_info` pairs exactly one branch runs, and which one is not
   decidable here. Both directions are wrong on real code, so the entry is
   dropped — the importer stays unresolved, which is exactly the pre-#2864
   answer, and the file-level IMPORTS edge is untouched.

   `collectAmbiguousReexports` runs as a PRE-PASS over data phase 0 froze,
   so the poisoned set is constant across the fixpoint. That matters: a set
   that grew mid-fixpoint would need retraction to propagate to files that
   already inherited the name, would make `myClosure.size > before` an
   unsound progress signal, and would invalidate the `|SCC| + 1` cap. As a
   pre-pass the closure map stays monotone and every existing termination
   argument survives unchanged. Only two flagged drafts resolving to two
   DIFFERENT in-workspace files count; duplicates of one target are
   harmless, and unresolvable targets never entered the closure.

   Checked in both loops. Named re-exports take precedence over wildcards,
   so suppressing only the named loop would hand the name to a later
   `import *` and reinstate an arbitrary winner through the back door.

2. NAMESPACE-RECLASSIFIED DRAFTS ARE EXCLUDED. The admission guards tested
   `draft.source.kind` while `tryFinalize` tests the post-reclassification
   `draft.base.kind`. Python's `from . import logger` is emitted as `named`,
   reclassified to `namespace` by `isNamespaceImport`, and was still
   admitted — republishing whatever def shared the module's simple name. For
   a `logger.py` holding a module-level `logger = logging.getLogger(...)`,
   importers of `from pkg import logger` bound to that Variable instead of
   the module. Reproduced end to end. Both predicates now take the draft and
   test `base.kind`; this is a no-op for TS/Rust, whose only
   `isNamespaceImport` implementation is Python's.

3. `transitiveVia` IS CAPPED AT 32. Each hop copies the inherited path, so
   an unbounded chain is Theta(depth^2) in time AND retained memory, and
   Theta(|SCC|^2) for a cycle whose chain tracks it. `MAX_REEXPORT_DEPTH =
   100` covered this until fc919ad6 removed it — correct for the shallow
   TypeScript barrels that were then the only input, and invisible until the
   input class changed. Measured at depth 400: 67 ms / 145 MB uncapped vs
   25 ms / 40 MB capped. 32 against a real-world worst case of ~6 for
   `__init__.py` chains. Safe because `ImportEdge.transitiveVia` has no
   production reader — it is diagnostic provenance, emitted and typed but
   dropped by graph emission.

4. `localDefs` ARE INDEXED BY SIMPLE NAME. `findExportByName` linearly
   scanned a target's defs on every call, and the phase-3 fixpoint rescans
   the same target once per iteration. Memoized on the array identity, which
   `FinalizeFile` documents as static input. Worth 12-14% where lookups
   repeat and neutral elsewhere.

The 46-line algorithm docblock was also ORPHANED by the helpers inserted
between it and `buildReexportClosures` — AST-verified, that function had zero
jsdoc blocks, so the cross-reference elsewhere in the file landed on an
undocumented function. Helpers move below it (declarations hoist), and its
step 1, precedence and complexity sections are rewritten: they still claimed
regular imports do not contribute to the export surface, and justified the
via-copy cost by TypeScript barrels being shallow.

The `reexportsName` contract consolidates onto `ParsedImport`, where its
"`kind: 'reexport'` would drop the local binding" rationale is corrected —
`materializeBindings` creates a module-scope binding for every linked edge,
re-export included. The real reasons are that `origin` flips, changing
evidence weight and priority, and that it misreports Python's syntax.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* test(shared): add a re-export closure scaling guard to CI

No bench covered `buildReexportClosures` at all. Until #2864 its input was
TypeScript barrel files — a handful of shallow edges — and it admitted only
`reexport` and `wildcard` drafts. It now admits every module-level Python
`from m import x`, measured ~20x more edges on the CPython stdlib and cyclic
SCCs where there were none. The pass went from "rarely runs" to "runs over
the whole named import graph" with nothing watching it.

The regression this guards has already happened once: fc919ad6 removed
`MAX_REEXPORT_DEPTH`, which was correct for shallow barrels and stayed
invisible for as long as the input stayed shallow.

The depth arm is an EXACT structural assertion — build a chain far past the
cap, assert the longest emitted `transitiveVia` is exactly `MAX_VIA_LENGTH`.
It started as a `depth_ratio` timing arm and that was a bad gate: sampled
five times capped it scored 2.71-3.52 and three times uncapped 5.87-7.65, so
the ranges nearly touch and one uncapped run came in UNDER budget. A gate
that passes a third of the time on a broken build is worse than none, because
it gets read as evidence. The structural form fails 3/3 with 401 vs 32.

`width_ms` stays a timing arm with a deliberately loose budget, because a
structural check cannot see a constant factor: restoring a per-lookup linear
scan of `localDefs` leaves every array length untouched while making every
real analyze slower.

Both arms drive `finalize` through INDEXED hooks. Reusing the unit tests'
`defaultHooks` is the trap — its `resolveImportTarget` does `files.some(...)`
per import, which is O(imports x files) in the FIXTURE and swamps the pass so
completely that removing the cap measures as no change at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* fix(cache): bump SCHEMA_BUMP 53 -> 60 for ParsedImport.reexportsName

`reexportsName` is a new field on `ParsedImport`, and `parsedfile-store.ts`
serializes the whole `ParsedFile` generically — so it is part of the cached
shape even though it is not a capture, which is the easy-to-miss variant of
the rule `parse-cache.ts` states as a MUST. (The `@import.publishes` marker
added alongside it moves the capture output too, so this qualifies twice; the
python captures golden confirms the drift.)

Without the bump, a warm `parsedfile-cache` replays pre-fix `ParsedImport`s
carrying no flag, `isNamedReexport`'s strict `=== true` takes the old path,
and the entire fix is a SILENT NO-OP on incremental analyze while every
cold-run test passes. It lands hardest on `__init__.py` — the rarest-changing,
highest-cache-hit files in a Python repo, i.e. exactly the target. A published
npm release invalidates via `GITNEXUS_PKG_VERSION`; dev trees, main-HEAD
installs and CI with a restored cache dir do not.

60, not 54, because the value has to clear every in-flight claim rather than
just origin/main: main is at 53 while open PR #2899 claims 54 and #2891 claims
59. Five exact clashes are recorded in the ledger, and the pin test cannot
detect a tie — both sides assert the same number and both pass. RE-CHECK
against origin/main immediately before merging.

Also documents the divergence between `pythonFileExportsName` and the
re-export closure. That predicate answers "does this package expose X?" from
`localDefs` alone, so with `pkg/__init__.py: from .impl import log`,
`pkg/impl.py: def log` and a same-named `pkg/log.py`, `from pkg import log`
still targets the submodule and the closure is never consulted — for exactly
the case it was built for.

Deliberately NOT fixed by reusing the flag, which is the obvious three-line
change and is WRONG: `reexportsName` is also set for `from . import log`,
where CPython binds `pkg.log` to the MODULE, not a name (verified on 3.11
against the `from .impl import log` form, which binds the function). Returning
true there would kill the correct namespace edge. Separating the two needs the
re-export's own resolved target — i.e. re-entering `resolvePythonImportTarget`
from a different `fromFile` — and that classification is the subject of open
issue #2882, so it belongs with that fix. Not a regression: both halves behave
exactly as they did before #2864.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* test(python): re-baseline the scope-capture fingerprint for @import.publishes

CI's `bench/python-scope/measure.mjs --check` failed on capture fingerprint
drift. Intentional: the module-level marker added for `reexportsName` is a new
synthetic capture, and that guard hashes `tag|text|range` over every
`emitPythonScopeCaptures` output.

Attributed before re-baselining rather than after. Reverting ONLY the
`@import.publishes` emission — nothing else — restores the previous hash
a0da3e7c exactly, so the whole drift is that one marker. `capture_groups_fp`
is 3246 either way and `scaling_ratio` stays ~1.0, so no capture group
appeared or vanished and the pass is still linear.

The other nine bench guards were run rather than assumed: scope-capture,
callable-value-flow, finalize-reexport, cpp-qualified-ns,
kotlin-import-target, receiver-resolution, scope-emission, import-target and
cfg all pass. The benchmarks job runs under `-e`, so this failure masked
whatever followed it — worth checking the rest before pushing a one-line
baseline change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

---------

Co-authored-by: Carter LaSalle <carterlasalle@gmail.com>
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>
2026-08-09 12:21:06 +01:00
DuduPhudu
fa31a7d824
fix: close the nine follow-up review findings from #2856 (routes, receiver typing, truncation honesty) (#2899)
* fix(typescript): a type parameter shadows a declared type of the same name (W2-8)

First item of wave 2, promised to the reviewer on #2856.

`export function unwrap<Result>(value: Result): Result` names the PARAMETER, not
the `interface Result` beside it — tsc resolves both annotations to the
parameter. The type-reference capture that makes a contract answerable ("what
breaks if I remove this field?") had no notion of a parameter binding, so every
annotation mentioning `Result` inside `unwrap` minted a `USES` edge into the
interface, at the same confidence as a real consumer and indistinguishable from
one. Measured on the new fixture: `unwrap` produced TWO false edges while the
genuine consumer produced one.

Blast radius is every generic whose parameter name collides with a declared
type, and the colliding names are ordinary choices for both: `Result`, `Key`,
`Value`, `Item`, `Node`, `Options`, `Config`, `Props`, `State`, `Response`.

TWO HALVES, and the first is why upstream's fix could not reach this. #2833
introduced `bindsTypeParameter` for the CALL-receiver path, where a workspace
`class T` was answering for `<T>`. Reusing it here changed nothing at first, and
the reason is its own documented contract: `@declaration.type-parameters` was
captured for class/interface declarations ONLY, so a generic FUNCTION recorded
no parameter list and the predicate correctly returned false — absence is not
evidence. The data was missing, not the logic. So:

  - TYPESCRIPT_SCOPE_QUERY now captures type parameters on `function_declaration`,
    `generator_function_declaration` and `type_alias_declaration`;
  - the graph bridge consults `bindsTypeParameter` before emitting `USES`.

Both are load-bearing — removing either one fails the fixture.

The fixture carries two controls, because the obvious wrong fix is to stop
emitting: a genuine consumer of the interface must still link, and a generic
whose parameter does NOT collide must still link its real reference. Both are
asserted, and the "genuine consumer" case is asserted FIRST so the absences
below it cannot pass vacuously.

SCHEMA_BUMP 53 -> 54: parse-time capture change. A warm cache replays defs with
no parameter list, so the guard reads nothing and the feature is inert while
looking implemented.

Capture fingerprint re-baselined with justification. NO NEW CAPTURE NAME —
diffing the capture-name sets against the wave-1 branch returns empty; the tag
existed and now fires on more declarations. capture_groups_fp 2338 -> 2371,
fixture_count 151 -> 152, scaling 1.06 < 1.5, and JavaScript's fingerprint does
not move at all, which is the check that this is the TS declaration rules rather
than something broader.

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

* fix(analyze): close the four false-success paths in the graph-write-collapse guard (W2-6)

Second wave-2 item, promised on #2856. All four were reported; all four
reproduced by reading the code they name.

(a) A SAME-COMMIT RE-RUN REPORTED SUCCESS FOREVER. Every other meta-driven
    trigger — schema fingerprint, PDG mode, runner identity, CJK segmentation,
    embedding dims — has a block that forces a rebuild before the
    `alreadyUpToDate` fast path. `graphWriteCollapsed` had none; `grep -rn` found
    writes and no reads. So the one state meaning "most of your edges are gone"
    was the one state that repaired itself only if the user happened to pass
    `--force`. Now forces a full rebuild, and forcing is right rather than merely
    re-running: the persisted graph disagrees with what the pipeline produced, so
    an incremental pass over unchanged files would write nothing and re-stamp the
    same broken index as fresh.

(b) AN INCREMENTAL RE-RUN ERASED THE STAMP. `saveMeta` is a full atomic
    overwrite, and the field was spread in only when the CURRENT run had a
    verdict. `undefined` meant two different things at that site — "full run, no
    collapse" (a positive all-clear) and "incremental write, not comparable" (no
    opinion) — so the second case silently dropped `graph-write-collapsed` from
    meta.json while the edges were still missing. Now three-way: stamp on
    detection, CLEAR on a healthy full run, CARRY FORWARD when there is no
    verdict. That is the shape `branch: branchLabel ?? existingMeta?.branch` two
    lines away had all along.

(c) THE SERVER PATH NEVER CONSUMED IT. `analyze-worker-ipc.ts` projects the field
    "so a server-side caller sees the same degraded outcome the CLI does" — but
    nothing read it, so the comment described an intention and every collapsed
    run reported `complete` to the UI and to every API consumer. Now reports
    `failed` with the counts and the remedy, matching the CLI, which prints
    `Repository indexed INCOMPLETELY` and exits non-zero. A consumer that reads
    "complete" will query the index and get confident wrong answers.

(d) --pdg ROWS MASKED TOTAL STRUCTURAL LOSS. `expected` counts the in-memory
    graph plus the streamed STRUCTURAL manifest; the streamed PDG layers never
    enter `graph.relationshipCount`. But `persisted` was `stats.edges`, a count
    of EVERY `CodeRelation` row, and PDG writes into that same table. With 1,000
    structural edges expected and 4,000 PDG rows persisted, losing every
    structural edge still read `persisted = 4000`, cleared the ratio, and stayed
    silent — on exactly the large repos `--pdg` is used for.

    Worth recording that the OBVIOUS fix does not work. Padding `expected` with
    the PDG rows makes the two universes match but leaves the ratio judging a
    minority population: 4,000 of 5,000 still clears 0.5. I wrote that first, and
    the test I wrote to prove it failed. Only comparing structural against
    structural asks the question the check exists to ask, so `getLbugStats` gains
    a `structuralEdges` count excluding `PDG_EDGE_TYPES`. `TAINT_PATH` is
    deliberately NOT in that set — it is a whole-program Function→Function edge
    persisted by the normal emit, so it is structural and stays counted on both
    sides.

    `index-freshness-graph-collapse.test.ts` had pinned the masking as correct
    (`detectGraphWriteCollapse(1000, 4000)` → undefined, "PDG layers write into
    the same table, so persisted > expected is normal"). True about the table,
    and it licensed the hole. Replaced with the case that matters and a note on
    why the fix is at the caller.

The new `structuralEdges` assertion in `lbug-core-adapter` is there because the
failure mode is silent: the query sits in a try/catch that yields `undefined`,
and `undefined` makes the collapse check decline to compare — so a typo in the
Cypher would throw nothing, fail nothing, and switch the guard off. Verified
against a real LadybugDB and mutation-checked by breaking the query.

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

* fix(processes): make process selection insertion-order invariant (W2-5)

Third wave-2 item. Reproduced before fixing: two equal three-step flows with
`maxProcesses: 1` select `handleAlpha`; inserting the identical nodes and CALLS
edges in reverse select `handleBeta`. Same repository, same commit, a different
persisted graph — so a filesystem that enumerates differently, or an incremental
run that reorders assembly, silently changes what the tool reports.

Four sorts ranked by score or length alone and returned 0 on a tie.
`Array.prototype.sort` is stable, so a 0 preserves INPUT order, which traces
back to `graph.iterNodes()`. Under `maxProcesses` capping that decided which
`Process` and `STEP_IN_PROCESS` nodes were persisted at all. Each now falls
through to a totally-ordered, content-derived key — node id for entry points,
the joined path for traces.

WHAT IS ACTUALLY VERIFIED, stated precisely because "four fixes" would overclaim:

  - the ENTRY-POINT sort is individually mutation-verified;
  - the two DEDUP sorts are collectively mutation-verified;
  - the TRACE-RANK tiebreak is NOT individually observable, and the source says
    so. The dedup sorts already impose a total order on the list that reaches
    it, so removing it alone fails nothing. Kept as defence in depth: it cannot
    misbehave — it only makes an already-deterministic order explicit — and it
    is what stops a change to dedup ordering from silently re-opening this.

Finding that out took two fixtures. The first (three chains, three entry points)
is separated by the entry-point sort before trace ranking is reached, so it never
exercises the trace comparator at all; the second gives ONE entry point two
equal-length branches to different terminals, which is the only shape where the
trace comparator decides. Both are kept — they gate different sites.

The invariance tests assert the INVARIANT rather than any single sort, so they
cover all four sites and any future one without needing to know where they are.
Three assertions: same selection under a cap, identical set uncapped, and
identical ORDER — the last because order is what the cap consumes, so a set-only
assertion would pass while the defect persisted.

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

* fix(impact): UNKNOWN dominates a mixed candidate set, instead of reporting the known floor (W2-4)

Fourth wave-2 item. The all-UNKNOWN branch here was reasoned about carefully and
is correct — its comment even names the two ways a set can be all-UNKNOWN. The
MIXED case fell straight through it.

`RISK_ORDER` is `['LOW','MEDIUM','HIGH','CRITICAL']` and has no `UNKNOWN` entry,
so `indexOf('UNKNOWN')` is -1 and an UNKNOWN candidate can never win the reduce.
An ambiguous name with one caller-less candidate (UNKNOWN, per the round-1 fix)
beside one single-caller candidate (LOW) reported `maxRisk: 'LOW'` — a confident
floor over a set containing an interpretation nobody measured. That is the same
false-safe the all-UNKNOWN branch exists to prevent, one case over, and it
surfaced in the UI as "Max blast radius N (LOW risk)".

`maxRisk` answers "how bad could this be?", and an unresolved candidate could be
CRITICAL — so any UNKNOWN in the set makes the aggregate UNKNOWN. Narrowing it
that way would normally cost information, so the measured part travels alongside
as `knownMaxRisk`, present only when the two differ: absent on a fully-resolved
set, where it would duplicate `maxRisk`, and absent on a fully-unknown one, where
there is no measured part. A reader gets "at least LOW among what resolved, and
one interpretation could not be walked at all", which is strictly more than
either value alone. The human-readable message says the same thing.

The seed gained a mixed pair because the existing one could not reach this: both
its twins are caller-less, so it only ever exercises the all-UNKNOWN branch —
which is precisely why the gap survived a round of review. Three assertions,
both halves mutation-verified.

`eval-server.ts` needs no change: it renders `result.maxRisk ?? 'UNKNOWN'`, so it
now shows UNKNOWN where it previously showed the floor.

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

* fix(routes): track ternary polarity in dispatch guards, so a selected verb cannot be inverted (W2-9)

`if ((req.method === 'GET' ? false : true) && pathname === '/api/i')` emitted
`GET /api/i` — the one method that branch guarantees the request does NOT have.
A ternary SELECTS between its arms, so a verb inside one is not reached merely
because the whole condition is truthy, but `findVerbInSubtree` descended into
both arms and returned the first verb it saw. Same inversion `!` produced before
d4dcba8c, one level up.

Handled by folding the ternary where an arm is a boolean literal, which is what
collapses the selection into a conjunction:

    c ? A : false  ==  c && A     both hold, so search both
    c ? false : B  ==  !c && B    c must NOT hold, so search it at flipped parity
    c ? true : B   ==  c || B     a disjunction guarantees neither operand
    c ? A : true   ==  !c || A    likewise

Two non-literal arms leave the verb chosen by an unknown condition, so the
ternary guarantees nothing. Refusing every ternary would also have fixed the
reported bug, but three of the four shapes measured were ALREADY correct and
would have silently lost their verb; they are pinned now.

A second defect in the same walk, found while reproducing: the `!` rule was
keyed on PRESENCE, returning null at the first negation it saw, while
`isNegatedContext` two functions above states the rule is PARITY and says so
outright — `!!x` is `x`. So `!!(req.method === 'GET')` dropped a verb the source
states plainly. The existing double-negation test covered the PATH position,
where the parity walk already ran, and so never saw it. The verb walk now tracks
parity too, and the two agree.

Verb-less, not route-less: the path comparison is untouched evidence that the
branch serves that path, so an inverted verb becomes a missing verb rather than
a missing route.

SCHEMA_BUMP 54 -> 55. Routes are emitted at parse time and replayed verbatim
from a warm cache, so without the bump an already-indexed repo keeps serving the
inverted verb and the fix looks inert. Free against origin/main (48).

Every rule mutation-checked: removing the ternary dispatch, either literal-arm
rule, the negated-ternary guard, or the parity walk each fails exactly the tests
that claim it. One assertion I wrote survived all five mutations and was removed
rather than kept.

Not a recall win on crypto-trading-bot, which contains neither shape — this is
precision insurance for dispatchers that do.

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

* feat(routes): report every method a dispatch guard serves, not just the first (R3-8 part 1)

`if ((req.method === 'GET' || req.method === 'POST') && bundlesMatch)` is two
routes. The verb walk returned the FIRST verb it found, so `route_map` presented
a two-method route as GET-only and `impact` on the POST path found nothing.
Taken verbatim from the reporting repo's researchRunRoutes.js.

`governingVerb` -> `governingVerbs`, returning a list; `findVerbInSubtree` and
`verbFromTernary` likewise. A guard with several verbs emits one route per verb
via the new `pushPerVerb` — they share a path and a handler but not a method,
and `(method, url)` is the key every downstream consumer dedups and looks up on.

A disjunction yields ALL its verbs or NONE, which also fixes an over-attribution
the first-match rule had:

    req.method === 'GET' || req.method === 'POST'   ->  GET, POST
    req.method === 'GET' || isAdmin                 ->  no verb

The second is reached for ANY method when `isAdmin` holds. Reporting `GET` — as
first-match did — describes a route open to everything as single-method, which
is the direction this module treats as more expensive than saying nothing.
Negated, `!(A || B)` is `!A && !B`, so it excludes verbs rather than offering
them and yields none.

Generic descent deliberately stays FIRST-match rather than unioning across
children: an arbitrary node says nothing about how its children combine, and two
verbs found under one are far more likely unrelated than alternatives. `||` is
the one construct that genuinely means "either of these".

Pinned against regression: the pre-existing rule that distributes ONE verb
across an OR of PATHS must not start multiplying methods, and switch arms
inherit the full method set.

SCHEMA_BUMP 55 -> 56. Routes are parse-time output replayed verbatim from a warm
cache. Free against origin/main (48).

Four mutations, each failing exactly the tests that claim it: removing the
disjunction dispatch, dropping the all-operands rule, allowing a disjunction at
odd parity, and emitting only the first verb.

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

* feat(routes): read `.match()` dispatch, and the capturing wildcard it needs (R3-8 part 2)

`RE.test(pathname)` and `pathname.match(RE)` are the same test with the operands
swapped. Only `.test` was read, which is why 28 of the reporting repo's 75 routes
still named the shared route table as their handler rather than the module that
serves them: those modules dispatch with `.match`.

THE CAPTURING WILDCARD, which is the part that made the rest inert.
`regexToRoutePath` accepted `[^/]+` and refused `([^/]+)` — `(` fell through to
the metacharacter bail. So the non-capturing form translated and the capturing
form produced nothing, and every existing test passed because every existing
test used the non-capturing form. The tests were written against the
implementation rather than against the corpus, and the reporting repo contains
no non-capturing path wildcard at all: a dispatcher captures the segment because
it needs the id. This alone also repairs the already-shipped `.test` rule.
A capture around anything that is NOT one segment still bails — `(.+)` spans
slashes — and the alternation is balanced, so `([^/]+` unclosed is not a match.

`.match` differs from `.test` in one way that matters: its result is USED, so it
is almost always BOUND, and the verb then lives in a later `if`:

    const runMatch = pathname.match(/^\/api\/research-runs\/([^/]+)$/)
    if (req.method === 'GET' && runMatch) { … }

Reading the verb off the CALL would report every one of those verb-less. So a
bound match records `name -> path` and the route is emitted where the binding is
TESTED, once per test site — one binding tested for GET and for PUT is two
routes. A reference counts only in a truthiness position (`&&`/`||` operand, or
a whole `if` condition), which is what separates `if (m && …)` from `m[1]`: a
read of the captured segment says nothing about dispatch and would otherwise
mint a duplicate route per use of the id. A binding never tested still emits one
verb-less route — the code did compute an anchored match against the path.

Regexes named by a same-file const resolve too (`pathname.match(POSITION_REPLAY_RE)`),
with the same ambiguity refusal the string-constant map uses: bound twice to
different patterns means dropped, because a half-right regex is a wrong route.

SCHEMA_BUMP 56 -> 57. Free against origin/main (48).

Nine mutations, each failing exactly the tests that claim it. TWO of my own
tests initially survived their mutation and were rewritten, not kept:
- the non-path-receiver case had no path token anywhere in the fixture, so
  PATH_TOKEN_HINT skipped the file and the assertion was satisfied by a file
  that was never examined;
- the negation case used `!m`, which never reaches the negation check at all —
  a `unary_expression` parent is not a truthiness position to begin with. The
  shape that exercises it is `!(req.method === 'GET' && m)`.
A declaration-site skip written alongside them proved unreachable for the same
reason and was removed rather than left to imply a hazard.

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

* feat(processes): report what the detection ceilings dropped, instead of logging it at debug (W2-3)

`processProcesses` has five ceilings - the entry-point trace quota, the
per-entry trace budget, `maxTraceDepth`, `maxBranching` and `maxProcesses` -
and every one of them fired silently. The result came back looking whole and no
consumer could tell it was a sample. The code's own comment already said so:

    // A silently truncating cap reads as "this is everything", which is the
    // same class of confident-empty answer this work is about.

and then only called `logger.debug`. A log nobody has enabled is not a
disclosure.

`stats.truncation` is additive, so every existing consumer of `totalProcesses` /
`crossCommunityCount` / `avgStepCount` / `entryPointsFound` is unchanged. It
carries one boolean to branch on plus a counter per ceiling, kept SEPARATE
rather than summed because they mean different things: unexplored entry points
mean whole flows are missing, while a depth-capped trace means a flow is present
but shorter than it really is.

`processesDropped` counts against the DEDUPED population, not the raw trace
list - the gap between those two is deduplication doing its job, and counting it
as truncation would report a permanent non-zero on every healthy repo.

`truncated` is DERIVED from the counters rather than set at each site, so a
ceiling added later only has to increment its own counter to be reported.

Surfaced at `warn` and NOT gated on `isDev`: "823 flows" printed without it
reads as the complete set, which is the confident-empty failure wearing its
other face - a confident-COMPLETE one. The debug line stays for the per-entry
detail it carries.

Seven mutations, each failing exactly the tests that claim it, including BOTH
directions of the flag: hardcoding `truncated` false fails the four positive
cases, and hardcoding it true fails the nothing-was-truncated case, which is
asserted first precisely so the positives cannot pass vacuously. The
`walksCutByBudget` fixture gives every node exactly `maxBranching` callees so it
asserts its own counter and not a neighbour's.

Also fixes a defect this work exposed: 10b0c7a1 (W2-5) embedded a RAW NUL BYTE
in `trace.join(...)` instead of the backslash-u escape the rest of the repo
uses. It behaves identically at runtime, but `file` reports the source as
`data`, and grep, git diff and code search treat it as binary - several greps
against this file silently returned nothing while I was reading it. main was
clean here; two other files carry the same raw byte from before this branch and
are left alone.

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

* feat(scope-resolution): resolve members through a MEMBER-CALL producer's return shape (W2-1)

    const svc = new SignalService()
    const r = svc.make()
    return r.secretFlag        // <- no edge

`return-shape-members` types `r` to the producer that made it, but a member call
binds the spelling `svc.make`, and slicing that to its last segment leaves
`make` — a METHOD, never a callable binding in scope. The producer lookup failed
and the pass declined.

The limit shipped documented as needing inter-procedural receiver typing. It does
not. Measured on a fixture, the pipeline had already done the hard part:

  - `readMake -> Method:...SignalService.make#0` already resolves as an ordinary
    CALLS edge, so the receiver is already typed; and
  - `Property:...SignalService.make.secretFlag@N:C` already exists, because R3-4
    anchors a returned literal's keys to the METHOD that returns them, not only
    to free functions.

Both halves were present and unjoined — the same shape as R3-5 itself.

ADDITIVE, not a reroute. The new branch sits inside `if (producerFile ===
undefined)`, so it can only fire where the callable lookup already declined;
every reference that resolved before resolves identically, by construction
rather than by test.

Nothing new is inferred. The receiver is typed by the SAME predicate that typed
`r`, and it must itself resolve to a class — a receiver that cannot be typed
still declines, so `make.<member>` is never matched by name across the graph.
That fabrication is what the existing guards exist to stop and they all carry
over unchanged: the owner must resolve, its file must match the candidate's, and
`ownFilePaths` keeps the polyglot class registry from walking a JS read into a
Java field.

The owner segment is TWO parts for a method (`SignalService.make`) and one for a
free function (`makeSignal`), which is exactly how R3-4 qualifies each. That is
what separates two methods of one class returning the same key name from each
other AND from a free function of that name — the fixture gives `secretFlag`
three owners so a wrong resolution is detectable rather than a coin flip that
happens to look right.

Four mutations, each failing exactly the two tests that claim it: removing the
fallback, using the method alone as the owner segment, taking the producer file
from the reading file instead of the owner class, and dropping the
receiver-type requirement. 3,408 resolver tests pass, including
`polyglot-property-isolation`, which is the one this could plausibly break.

No SCHEMA_BUMP: this is a resolution pass over ParsedFiles, not parse-time
output, so a warm cache replays the same input and produces the new edges.

Measured on crypto-trading-bot: ZERO new edges, byte-identical at 62,158. Its
170 `const x = new Y()` bindings are overwhelmingly built-ins (Map, Set,
Promise, S3Client) rather than workspace classes whose methods return object
literals — it is a module-style JS codebase. Correctness fix for class-shaped
code, not a recall win on this corpus, and it should not be presented as one.

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

* feat(scope-resolution): type a bare parameter from what its callers pass (W2-2)

    function readSpike(spike) { return spike.wickRatio }

had nothing to type `spike` from, so the read fell through to the 0.5 name tier.
That is the standing limit of R3-5 and, measured, by far the largest: 11,012 of
13,672 property edges on the reporting repo (81%) rest on that name guess.

The two facts needed were already extracted, for a different consumer. For JS
and TS among others, `callable-flow-captures` synthesizes:

    formal    owner=readSpike  binding=spike  parameter-index=0
    argument  source=s  parameter-index=0  direct-callee-name=readSpike

Joining them on (callee, parameterIndex) says which cell reaches which
parameter, and the argument's own binding is typed by the same
`findReceiverTypeBinding` a directly-bound receiver already uses. So the
parameter inherits the producer and `spike.wickRatio` resolves as evidence
rather than inference.

No new capture, no parse-time change, NO SCHEMA_BUMP. And deliberately not a
change to the callable-value-flow solver that owns these sites: that pass is
guarded by a fingerprint CORRECTNESS gate plus a timing budget, so this reads
the same facts and computes its own map.

AMBIGUITY DECLINES. A parameter whose callers pass different producers resolves
to nothing. Picking one would fabricate at the 0.9 PRECISE tier, which no
`minConfidence` floor can filter out — the same reason `buildConstantMap` drops
an ambiguous constant instead of taking the first.

Keyed by the formal's (scope, name), not by a definition id. The first attempt
used a def and measured `paramDef=NONE`: a parameter is not reachable through
`findValueBindingInScope` (its predicate is `isOwnableValueLabel`, which lists
Const/Variable/Property/Static because it exists for OWNERSHIP registration, and
a parameter is owned by nothing) and it is not a `local` binding either. The
formal site already states the scope its parameter binds in, which is enough.

Formals carry their DECLARING FILE in the key, so two same-named functions in
different files cannot answer for each other — dropping it makes both go
ambiguous and both readers silently lose their edge.

COVERAGE, counted rather than assumed. The synthesis skips an argument that is
itself a call result (an explicit `continue` in `callable-flow-captures`), so
`f(makeSignal())` emits no argument site and only the bound spelling
`const s = makeSignal(); f(s)` is served. That looked fatal until measured: in
the reporting repo, bare-identifier arguments outnumber call-result arguments
2,563 to 50 — 51:1. Extending the shared, benched capture synthesis for the 2%
case is not worth its risk.

Four mutations, each failing exactly the tests that claim it: keeping the first
producer instead of declining on conflict, dropping the read-site lookup,
matching a formal at index 0 regardless of the argument's index, and dropping
the declaring file from the formal key. Two of those could not be caught by the
first fixture at all — it had a single parameter and a single consumer file — so
the fixture gained a two-parameter callee and a same-named twin in a second file
before they were meaningful. The test helper also had to start filtering by
source FILE, or two different `readSpike` symbols merged into one count.

Measured on crypto-trading-bot: 36 reads left the 0.5 name-guess tier. 26 became
precise 0.9 edges (return-shape reads 1,130 -> 1,156, which is the whole delta),
and 10 became honest absences — the receiver was typed, the producer's shape was
known, and the member is NOT on it, so the site is claimed as disproved rather
than left for the name fallback to invent an answer for.

That is ~0.3% of the 11,012, and it should be reported as such. The 81% figure
is the size of the PROBLEM, not of this fix: the shape requires a bound
argument, a producer that returns an object literal, and a parameter read as a
receiver, and that intersection is narrow. The remaining name-tier reads are
mostly receivers no workspace producer types at all.

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

* fix(processes,ci): anchor trace subsumption, cover the sink wiring, stop one bench guard hiding the rest (#2894, #2896, #2895)

Three follow-ups reported against #2856 after it merged. Each was reproduced
before it was fixed.

#2894 — trace subsumption matched mid-identifier.

`deduplicateTraces` decided whether one trace is a sub-path of another with an
UNANCHORED `String.includes`, so a match could begin in the middle of a node id:

    'X->AA->B'.includes('A->B')   ->   true

and `A -> B` was discarded as redundant against a chain `A` is not a step of at
all. Reproduced directly against the function before fixing.

Padding both keys with the separator makes `includes` match whole steps only.
Reported as measured-inert and that holds — the collision needs one node id to
be a strict suffix of another at a `->` boundary, which real ids
(`Function:<path>:<name>`) do not produce. Fixed anyway because the predicate
did not mean what the surrounding code says it means, in a function whose entire
job is deciding what to delete, and nothing pinned it.

`deduplicateTraces` is exported for the test, matching how `traceFromEntryPoint`
and `buildSinkFunctionSet` are already reached. The tests use bare ids because
the shape cannot be built from realistic ones — which is exactly why nothing
caught it. Alongside the regression case, two tests pin that GENUINE subsumption
still happens, prefix and suffix, so the fix cannot degenerate into "subsume
nothing" and pass the first test trivially. Mutation-checked: reverting the
padding fails the mid-identifier test and only that one.

The encoding assumes `->` never appears IN a node id; a C++ `operator->` would
defeat the join regardless of padding. Out of scope, but the assumption is now
written down where the join happens.

#2896 — the sink wiring was only ever exercised through its fail-open catch.

`processesPhase` reads `allFetchCalls` / `allORMQueries` off the parse output
inside a try/catch that falls open to "no sinks", and every phase-level test
omitted `parse` — so all of them took the CATCH branch and the success path had
no coverage. `getPhaseOutput` is a raw `as T` cast, so a field rename would make
the phase detect zero sinks while every test still passed, because zero sinks is
what they already assert.

The new test asserts the one thing only the success path can produce: a flow
ENDING at the sink while a longer chain continues past it. Its control is the
same graph with no `parse` dep, which must NOT produce that terminal — without
the control the assertion could pass for an unrelated reason. Also asserts
`processesPhase.deps` contains `parse`, so the read and the declaration cannot
diverge, and that a parse output missing those fields still fails open rather
than losing every process.

Mutation-checked, including the exact drift scenario reported: renaming
`allFetchCalls` at the read site, dropping `parse` from `deps`, and passing no
sinks to `processProcesses` each fail exactly the test that claims them.

#2895 — a failing bench guard aborted the job and masked every later guard.

Every step in the benchmarks job was fail-fast, so the first failing `--check`
aborted it and the rest reported `skipped`, which reads identically to "nothing
to do". Audited over 13 runs on #2856: the job succeeded zero times and the last
two guards executed zero times for the life of the PR, while two reviews read
the checks summary and saw nothing wrong. Both guards did in fact pass — that
was luck, not verification.

`if: ${{ !cancelled() }}` on all ten steps after the first, so one stale
baseline reports one red step instead of hiding nine. `!cancelled()` rather than
`always()` so an explicit cancel still stops the job instead of running seven
minutes of benchmarks nobody is waiting for.

The two steps easiest to miss are covered: `Receiver-resolution drop guards`,
whose `run:` sits twenty lines below its `name:` behind a long comment, and the
final `Cross-language pipeline benchmarks` step, which is not a `--check` and so
falls outside any grep for one — and is one of the two that never ran.

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

* fix(parse): capture a fetch call site even when its URL is not a literal (#2897)

The `fetch` rule required the argument to be a string or template literal:

    arguments: (arguments
      [(string (string_fragment) @route.url)
       (template_string) @route.template_url])

so `fetch(url)` with a variable matched nothing at all. Measured across this
repository's own TypeScript sources: **44 of 47 fetch calls pass a variable**, so
94% produced no site.

That is what makes R3-6 look inert. The sink set is built entirely from
`allFetchCalls` / `allORMQueries`, so a function performing an outward call
through a computed URL was never a sink, no flow could terminate there, and the
sink-first ranking rule never changed an ordering. The feature was fine; the
signal underneath it was almost always empty.

The URL alternation is now OPTIONAL, so one match covers both shapes. The R3-6
sink set needs only WHERE the program reaches outward, not where to.

Route linking is untouched, by construction rather than by hope:
`processNextjsFetchRoutes` normalizes the URL first and skips anything that
yields nothing, so a URL-less entry cannot mint a FETCHES edge. Verified on this
repo — FETCHES went 8 -> 9 across the change, i.e. the widening added sink sites
without inventing route edges, which was the one real risk here.

Tested in BOTH JavaScript and TypeScript, since the rule is duplicated in each
query block and fixing one would have left the other blind:

  - a variable argument is captured, with no URL   <- the regression case
  - a computed argument (`fetch(buildUrl(), {...})`) likewise
  - a literal URL is still captured WITH its URL   <- route linking depends on it
  - a template URL likewise
  - exactly ONE site per call — an optional alternation must not make a literal
    match twice, which would double-count the site and could mint two edges
  - `prefetch('/x')` is still not a fetch

Mutation-checked: restoring the mandatory alternation fails six of the twelve,
three in each language.

SCHEMA_BUMP 57 -> 58. Parse-time capture output is replayed verbatim from a warm
cache, so without the bump an already-indexed repo keeps its empty sink set and
the fix looks inert — which is the failure this constant exists to prevent, and
would have reproduced the very symptom being fixed.

Not addressed here, and worth stating: this widens `fetch` only. The reporter's
broader point stands — anything keyed on FETCHES / QUERIES is only as good as
the extraction underneath it, and the ORM side has not been measured. A guard
that fails when a corpus known to contain outward calls yields zero sites is the
right follow-up; this change makes such a guard meaningful rather than
tautological.

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

* test(bench): re-baseline receiver-resolution for the two fixtures this PR adds

`receiver-resolution --check` failed on:

    countArm.totalDropsAllKinds: 140 -> 148
    countArm.bySiteKind.write:    11 ->  19

Investigated before touching the baseline, because a guard that exists to catch
unexplained movement should not be silenced by an unverified story.

WHAT IT IS: the count arm runs the real pipeline over a corpus that includes
`test/fixtures/lang-resolution/`, and this PR adds two fixtures there —
`member-call-producer` (W2-1) and `parameter-producer` (W2-2). Each returns an
object literal with two keys, and a producer writing its own returned key is a
write site the receiver recorder logs. Four each, eight total.

Attributed by dumping the individual drops rather than reading the aggregate:

    member-call-producer/src/producer.js   secretFlag, wickRatio  (2 lines) = 4
    parameter-producer/src/producer.js     source, wickRatio      (2 lines) = 4

The eleven drops already in the baseline are all `javascript-object-properties`
fixtures of exactly the same shape, so the new ones are not a new KIND of drop —
they are more of one the baseline already records. This is the first case the
guard's own failure message names: "a fixture was added".

WHAT IT IS NOT: `callDrops` — THE gate number, and `call`-only by deliberate
design because reads and writes "would inflate it" — is unchanged at 102. `read`
drops unchanged at 27. The SHAPE ARM shows no drift at all: no receiver spelling
moved between RESOLVES / VISIBLE-GAP / INVISIBLE-GAP, so no resolution
regressed.

HOW IT WAS ISOLATED, since the first attempt was misleading and the record is
worth having: reverting `return-shape-members.ts` alone did NOT reproduce it and
pointed away from W2-1/W2-2. Only a commit-level bisect was trustworthy —
`origin/main` OK, W2-8 OK, W2-3 OK, then W2-1 +4 and W2-2 +4, which matches the
fixture count exactly. A file-level revert leaves the fixtures in the tree, and
the fixtures are the cause.

The update is two numbers. Nothing else in the baseline moves.

Worth noting where this failure became visible at all: under the fail-fast
benchmarks job it would have aborted the run and shown the five guards after it
as `skipped`. It is legible here because #2895 — fixed in this same PR — now
lets every later guard run.

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

* fix(analyze): stop `--pdg` runs reporting a healthy index as INCOMPLETE

Every `gitnexus analyze --pdg` reported a graph-write collapse and exited 1 on an
index where every row had persisted. Reported by a user hitting it on a real
repo; introduced by this PR's own W2-6(d).

    Repository indexed INCOMPLETELY
    the pipeline produced 200,501 relationships but only 64,764 are readable

The index was complete: 200,190 rows present, 109,905 PDG and the rest
structural, all queryable.

WHAT WENT WRONG. W2-6(d) made the persisted side count STRUCTURAL rows only —
correct, and the reason is in its own comment: PDG writes into the same table, so
counting everything let PDG surplus mask real structural loss. But the expected
side kept using `graphEmitManifest.totalRows`, and that is a BUFFER-POOL SIZE
HINT which counts every streamed row. PDG streams through that same sink, so the
check compared a structural-plus-PDG expectation against a structural
measurement. On any repo with a PDG layer that is a guaranteed false collapse.

It compounds rather than merely misreporting: the run stamps
`graph-write-collapsed`, and W2-6(a)'s rebuild trigger — added alongside it —
forces a full re-analyze next run, which collapses again. A permanent rebuild
loop, on an index that was never damaged, at ~100s a cycle.

MEASURED RATHER THAN ASSUMED, because the first attempt was wrong. I first
subtracted PDG edges RESIDENT in `graph.relationshipCount`, rebuilt, re-ran the
failing command and got byte-identical numbers. Instrumenting the three terms
showed why:

    relationshipCount=20,825  graphManifestTotalRows=179,676
    pdgEmitManifest=absent    residentPdgInGraph=0

PDG is not resident in the graph AND has no separate manifest — it streams
through the ordinary `GraphEmitSink`. The reverted attempt is not in this diff.

THE FIX. A pair key cannot separate them: it is `From|To` NODE LABELS, and a CFG
edge shares `Function|Function` with CALLS. Only the write path sees
`relationship.type`, so the sink now counts a `structuralRows` subtotal there and
publishes it on the manifest. `totalRows` is unchanged — it still sizes the
buffer pool, which is what it was for.

WHY THIS SHIPPED UNCAUGHT, and what changed about that. The wiring test kept a
LOCAL MIRROR of the expected-count expression "because the production expression
is inline in a 3000-line function". A mirror cannot catch a term the original got
wrong. That expression is now an exported
`computeExpectedStructuralRelationships` which production calls and the test
imports.

It also takes the MANIFEST rather than a pre-selected number, deliberately: the
defect was choosing the wrong FIELD, and a numeric parameter leaves that choice
at a call site no unit test can reach. Verified — with the helper taking a
number, reverting to `totalRows` failed nothing; taking the manifest, the same
revert fails four tests.

Verified end to end on the reported command: `analyze --force --embeddings 0
--pdg` now exits 0 with "indexed successfully", 86,963 nodes / 200,217 edges, and
the run clears the stale collapse stamp.

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

* fix(routes): scope match bindings, and intersect ternary conjunctions

Two ways the dispatch-guard walk minted a route that does not exist — the one
thing this module's header says is worse than missing one.

MATCH BINDINGS WERE KEYED BY BARE NAME, FILE-WIDE. `collectFromMatchBindings`
walked from `tree.rootNode` and resolved `matchBindings.get(node.text)` at every
identifier in a truthiness position, so a same-named binding in ANOTHER function
answered for it. The poison check only fired on a second REGEX match with a
different URL; a non-match binding never entered `collectFromRegexDispatch`, so
nothing refused it. Reproduced:

    function handleReplay(req, res) {
      const m = pathname.match(/^\/api\/live\/positions\/([^/]+)\/replay$/);
      if (req.method === 'GET' && m) { … }
    }
    function handleSettings(req, res) {
      const m = req.headers['x-mode'];        // unrelated value, same name
      if (req.method === 'DELETE' && m) { … }
    }

    GET    /api/live/positions/{param1}/replay  handler=handleReplay    correct
    DELETE /api/live/positions/{param1}/replay  handler=handleSettings  FABRICATED

Wrong in method, handler and line. `m`, `match`, `result` are the ordinary names
here. Two ways the truth was then lost: the fabricated route is VERBED, so
`reconcileDispatchGuardRoutes` kept it and dropped the true verb-less one — the
#2856 `/api/report` shape, through the channel this series added — and `tested`
was name-keyed too, so the tail loop suppressed the real binding's own honest
verb-less emit before reconciliation ever ran.

`matchBindings` and `tested` are now keyed on (enclosing function, name).
`enclosingFunction` is extracted from the walk `enclosingHandlerName` already
did, so there is one function-boundary mechanism, not two. A second declarator
for a key refuses it, and an assignment refuses the name in its own scope and
every enclosing one. `buildRegexConstantMap` refuses a name rebound to anything
that is not a regex literal, closing `let RE = /…/; RE = buildDynamic(req)` and
the `new RegExp(prefix + '/x')` twin.

A use resolves only within its own function. Resolving outward would need a
complete declaration model — params, imports, catch bindings — and a miss there
fabricates exactly the route this fixes. Declining costs the verb, not the path.

THE TERNARY TOOK FIRST-MATCH WHERE THE ALGEBRA IS INTERSECTION. The docblock
proves `c ? A : false ≡ c && A` and says "both hold, so search both", but
`firstNonEmpty` returned one operand's set unintersected:

    (req.method === 'GET' || req.method === 'POST')
      ? (req.method === 'POST' || req.method === 'PUT')
      : false                                    emitted GET and POST
                                                 only POST is reachable
    req.method === 'GET' ? req.method === 'POST' : false
                                                 emitted GET, unsatisfiable

`intersectVerbs` replaces it for both conjunction shapes. An empty side still
yields to the other — "names no method" is not "admits none", which is what the
`isAdmin && POST` fallthrough is for — but two non-empty sides intersect, and an
empty intersection is an unsatisfiable guard that yields no verb.

Both changes strictly REMOVE routes, so SCHEMA_BUMP 58 -> 59: routes are
parse-time output replayed verbatim from a warm cache, and without the bump an
indexed repo keeps serving the fabricated verbed route while the fix looks
implemented.

10 tests added, 9 of which fail without the change. All 86 existing assertions
pass unchanged; none was weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(scope-resolution): bind a type parameter only inside the scope it opened

W2-8 captured `@declaration.type-parameters` on EVERY `type_alias_declaration`,
but an alias becomes a SCOPE only when its value is an `object_type`
(`typescript/query.ts:149`). For a union, array, conditional, mapped, tuple or
function alias there is no scope, so the def — now carrying `typeParameters` —
attached to the innermost enclosing scope, which is the MODULE. And
`typeParameterNamesInScope` folds each scope's set from its PARENT'S, so the
name landed in every scope in the file. The `USES` guard then deleted every edge
whose target had that simple name:

    export interface Result { ok: boolean }
    export type Maybe<Result> = Result | null      // one ordinary line
    export function readResult(r: Result) { … }    // its USES edge is DELETED

Silent data loss, in the edge class whose whole purpose is answering "what
breaks if I remove this field?". Measured: adding two scope-less generic aliases
emptied the fixture of USES edges entirely.

The existing fixture could not see it — it wrote `type Box<Result> = { held: Result }`,
the ONE alias form that opens a scope.

`typeParameterNamesInScope` now reads a def's `typeParameters` only when that
declaration OPENED the scope owning it: `scope.kind !== 'Module'` and the def-id
position equals the scope range start, via the canonical `definitionIdPosition`
rather than slicing the id. That is the same alignment test `pickCallerCallableDef`
uses to tell a closure from a nested function, and it is language-neutral — it
also covers `function f() { type W<Result> = Result[] }`, which a module-scope-only
stopgap would miss.

Every language populating the capture was audited (ts, java, csharp, kotlin,
rust, cpp): all anchor it on a declaration that IS a scope node, including C++
where the capture rides `template_declaration` but the anchor is the inner
`class_specifier`. Go uses a separate sidecar. The TypeScript non-object alias
was the only mismatch in the codebase. `query.ts` is untouched.

THE GUARD ALSO SAT AT THE WRONG LAYER, which forced three defects at once. It
keyed on `edgeType === 'USES'` — and `mapReferenceKindToEdgeType` maps THREE
kinds there, `type-reference`, `value-ref` (#2437) and `macro` (#1934) — and,
because `Reference` carries no spelled name, substituted the resolved def's name
via `simpleNameOfDefId`. So `import { Result as ApiResult }` inside
`function unwrap<Result>()` deleted a REAL edge, while a namespace-qualified
target (`Host.Result`) kept a FALSE one, and a positional `@row:col` suffix broke
the last-colon parse outright.

Moved to `lookupForSite`'s `case 'type-reference'` in `resolve-references.ts`,
which has the spelled `site.name` and the reference kind in hand. One line closes
all three, deletes `simpleNameOfDefId` — a byte-identical duplicate of
`simpleNameOfGraphId` — and removes the only `graph-bridge/` -> `scope/` import
in that directory.

Honest scope: all three sub-defects are real in the code but none is observable
end-to-end today (`value-ref` never reaches this path; TypeScript emits no
cross-file USES for a type annotation at all — a separate pre-existing gap). Those
arms are labelled forward guards in the test rather than claimed as repros.

Fixture grows 1 file -> 4; 3 of 9 assertions fail without the change. The
scope-capture TypeScript fingerprint moves for FIXTURE-CORPUS GROWTH ONLY, with
per-file accounting that sums to the delta and JavaScript unchanged as the
control — see the `_rebaselined_` key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(scope-resolution): refuse an ambiguous formal, stop the walk at the nearest binding

Two ways W2-2 typed a parameter from the wrong caller, both at the PRECISE 0.9
tier — above every `minConfidence` floor, so nothing downstream can filter them.

`formals` WAS LAST-WRITE-WINS. The key is (filePath, ownerName, parameterIndex),
`ownerName` is a bare identifier, and `emitFormalFacts` emits one site per
parameter of EVERY function collected, nested functions and class methods
included. A plain `.set` let two same-named callables in one file collide — a
free `parse` and a nested `parse`, a free `apply` and `Runner.apply` — so the
last one visited won, fabricating an edge on the loser and leaving the genuine
consumer untyped. The file's own comment covers only the cross-FILE axis.

The correct shape was thirty lines below, in the `producers` map, which does
`producers.delete(cell); conflicted.add(cell)`. `formals` now refuses the same
way: a key claimed by two DIFFERENT parameters is deleted and recorded, so a
third same-named formal cannot re-claim it. Re-stating the same cell is not a
disagreement, so a benign duplicate capture cannot poison a real key.

THE SCOPE WALK CLIMBED PAST A NEARER BINDING. The docblock claimed it stops at
the first scope carrying the name, but it consulted only `parameterProducers` —
a shadowing `const`, a catch binding or an arrow parameter is not in that map, so
the walk went straight past it to the enclosing formal:

    function readSpike(spike) { … items.map((spike) => spike.wickRatio) … }

typed the ARRAY ELEMENT from the outer parameter. `parameterProducerFor` now
stops at the first scope that binds the name AT ALL — reading the scope's own
tables, the same channels and the same reasoning as the sibling
`isNamespaceNameShadowed` — and then stops at a Function boundary. That boundary
is what covers the anonymous arrow: `collectFunctions` drops a callable it cannot
name, so an anonymous arrow emits no formal site and its scope looks empty while
in fact rebinding the name. The cost — a closure genuinely reading an enclosing
parameter now declines — is documented as the deliberate trade.

No cycle guard, deliberately and with the reason stated: both constructions of
`indexes.scopeTree` validate through `buildScopeTree`, which enforces strict
parent-contains-child ranges, so a cycle needs a scope strictly containing
itself. A per-site Set on every read/write site in the repo would defend against
a state the builder rejects.

5 fixtures, 5 assertions; 4 fail without the change and the control passes both
ways. Still uncovered and not faked: a `for (const x of …)` binder shadow — the
binder lives in the loop header, so JS emits no scope to stop at and no Function
boundary intervenes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(analyze): measure one population in every config, split the stamp on the verdict

`1b41c9df6` fixed the collapse check for the STREAMED configuration by giving
the sink a `structuralRows` subtotal. It does not cover the other one.

`resolveStreamGraphEmit` and `resolveStreamPdgEmit` both open with a
`force === true` gate, so a run without `--force` streams nothing: there is no
manifest, `structuralRows ?? 0` contributes 0, and
`scope-resolution/pipeline/run.ts:1222` (`input.pdgEmitSink ?? graph`) writes PDG
into the ordinary in-memory graph, where `relationshipCount` counts it. And
`isIncremental` requires an existing meta, so a FIRST run is a full write and the
check runs. A first-time `gitnexus analyze --pdg` on a fresh repo therefore
compared structural+PDG against structural and exited non-zero with
"Repository indexed INCOMPLETELY" on a healthy index.

MEASURED, not assumed — `runScopeResolution({ pdg: true })` with no sink:

    pdgEmitSink        = absent (non-force shape)
    relationshipCount  = 1
    residentPdgInGraph = 1
    byType             = [["CFG",1]]

The prior `residentPdgInGraph=0` was taken on a `--force` run, where
`input.graph` IS the sink; it never spoke to this case. `graph-collapse-wiring.test.ts`
had pinned the gap, asserting a PDG-inclusive in-memory count was a valid
structural expectation.

`countStructuralRelationships(graph)` filters `PDG_EDGE_TYPES` over
`forEachRelationshipFields` — the same predicate the sink uses for
`structuralRows` and the adapter for `structuralEdges` — so all three terms
measure one population in every configuration. Declining whenever
`pdg && !streaming` was rejected: that is the DEFAULT PDG shape, so the guard
would be off for every non-force run including the only full write most users
ever do. An unscannable graph (mocked pipelines) yields NaN, the same fact the
old `undefined + rows` produced and one `detectGraphWriteCollapse` already
documents as expected input.

THE THREE-WAY STAMP WAS A TWO-WAY. The comment enumerated collapse -> stamp,
healthy -> clear, no verdict -> carry forward, but the code split on the WRITE
MODE. `graphWriteCollapsed` is undefined for two different reasons, and one of
them is "the structural query threw" — so on a full run where the count could
not be READ, the code took "healthy, clear it" and erased a stamp recording real
edge loss. Run 3 then printed "Already up to date" forever: the exact failure the
comment says it fixed, reachable through the new code's own `catch {}`.

`detectGraphWriteCollapse` now returns `'collapsed' | 'healthy' | 'unmeasurable'`
with a reason, and `selectPersistedCollapseStamp` is a pure exported function
production calls. Two boundaries worth naming: `expected === 0` is unmeasurable
(its own docstring calls it "could not report a total"), but the small-repo
exemption and a cleared ratio are HEALTHY — both counts were taken. Making the
exemption a non-verdict would leave a stamp unclearable on any repo that shrank
below 100 edges, relocating the wedge rather than fixing it.

`getLbugStats` now reports `structuralEdgesError` and warns, and `run-analyze`
falls back to `stats.edges` only when the run had no PDG layer, where the two are
equal by construction. With `--pdg` on there is no substitute, so the absence
becomes an explicit unmeasurable verdict — which preserves the stamp.

13 tests added; 8 fail without the change. The integration suite now seeds a CFG
row and asserts `edges` moves while `structuralEdges` does not — the exclusion
filter was previously unexercised, its own comment conceding "structural == total
here".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(server): check the collapse before publishing the index

W2-6 marked a collapsed run's job `failed`, but the check ran INSIDE
`.then(() => backend.init())` — after the publish. `LocalBackend.init()` is the
publish step: it refreshes the registry and atomically swaps the in-memory repo
map every MCP tool and HTTP route resolves through, and its `validate` pass
prunes only entries whose metadata is provably gone, so it can publish but never
quarantine. The known-incomplete database was therefore live and queryable before
the job was ever marked failed — the job status was a label on a published index,
not a gate. The pre-existing comment two lines above says so outright: "the repo
is actually queryable when the client receives the SSE complete event."

`backend-client.ts` routes `failed` to `onError` and never calls `onComplete`, so
the UI showed an error toast while every query against that repo answered from
the incomplete graph — precisely the confident-wrong-answers failure this guard
exists to prevent.

The collapse branch now returns before publishing; the healthy path publishes via
a nested `backend.init()` so the trailing `.catch` still converts init failures
into the same message. `closeDbHandle()` runs on both paths — it is eviction, not
publication, and the worker rewrote the DB files regardless of outcome, so
skipping it would leave a stale pre-rewrite handle.

Honest limit, stated in the error string rather than overclaimed: this keeps a
FIRST-TIME analyze unpublished, which is the UI's main flow. On re-analysis of an
already-published repo the existing map entry survives and points at the same
storagePath. A real quarantine needs an un-register hook on `LocalBackend`, which
does not exist today — follow-up.

`'partial'` was considered and rejected on evidence: it is not a status. It is an
embedding-specific detail object in the `updateJob` allowlist; the status union
excludes it. Adding it would make `isTerminalJobStatus` false, so `sse-progress`
never writes a terminal frame and never calls `res.end()` — the stream hangs
open — while `backend-client` falls through to `onMessage` and `api.ts` spins the
full hold-queue timeout. `failed` at least terminates.

The failure branch also now sets `repoName`, which only the success path did.

First tests this file has ever had: 4, of which 2 fail without the change. They
assert the ORDERING, not just the final status, and build the worker message by
calling the production `projectAnalyzeResultForIpc` so a field rename breaks the
test instead of silently disabling the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(processes): count the entry-point cap, and make the disclosure proportionate

W2-3 added a truncation disclosure and then missed the largest ceiling it was
written to report. `findEntryPoints` ends `.slice(0, 200)` and
`entryPointsUnexplored` counted against the POST-slice list, so candidates
201..N were invisible — while the derivation docblock claimed "a new ceiling
added later cannot be forgotten here". An existing one was. On this repo's own
corpus the new counter reads 780 of 980 candidates never ranked in.

`entryPointCandidatesDropped` reports the pre-slice count, folded into
`truncated`, with `ENTRY_POINT_CANDIDATE_LIMIT` extracted and `findEntryPoints`
taking the same optional out-parameter `traceFromEntryPoint` already uses. Its
return contract is unchanged.

THE WARN FIRED ON EVERY RUN. At the shipped defaults — only `maxProcesses` is
overridden — `calleesDropped` fires for any function with 5+ callees and
`tracesDepthCapped` for any chain deeper than 10, so an ungated `logger.warn`
was constant background noise, and a warning that always fires is one nobody
reads. The split is the module's own, from the `ProcessTruncationStats` docblock:
"unexplored entry points mean whole flows are missing, while a depth-capped trace
means a flow is present but shorter than it really is."

So `warn` iff whole flows are absent — candidates dropped, entry points never
traced, or flows dropped at `maxProcesses` — and `debug` for a run truncated only
in depth or breadth. `stats.truncation` still carries all six counters; the
machine-readable channel is unchanged, only the log level moves.
`entryPointCandidatesDropped` stays in the loud set deliberately: it is the only
ceiling that GROWS with repo size, while the other two can only fire while
`maxProcesses` is small enough to bind, so gating on those alone would go silent
on exactly the large repos where 200-of-several-thousand is the thinnest sample.
The message leads with the ratio so the line carries a fact, not an alarm.

THREE COMPARATORS ALLOCATED PER COMPARISON, in the function whose own comment
explains the hoist that removed this shape (`deep_chain` 1233 -> 102 ms).
Measured here: +99 ms once per analyze at 80k functions — small, because `n` is
capped at 200 entry points x a 12-trace budget = 2,400 traces regardless of repo
size. Worth fixing anyway: 23,851 comparisons cost 70,524 joins.

One shared `sortByDepthThenPath` (Schwartzian, key built once per trace) now
serves all three sites, and `rankedByInterest` additionally hoists the `isSink`
test that ran twice per comparison. It also settles a separator inconsistency:
`deduplicateByEndpoints` joined on a SPACE while `traceOrderKey` used NUL, and
node ids embed file paths, so two different traces could produce the same key and
the tiebreak fell back to the insertion order it exists to remove — the same
hazard this series' own `->`-padding fix addresses. Order identity is pinned by a
seeded 200-trace corpus asserting the new sort equals the old one exactly.

11 tests added, 9 failing without the change, including two end-to-end
insertion-order arms. The W2-5 determinism block is unregressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(docs): restore the agent guidance, and put it in the generator that deleted it

Commit `9e602aef0` — whose message is entirely about the fetch capture — also
regenerated the machine-managed `<!-- gitnexus:start -->` block from a local
non-`--pdg` index, deleting from both AGENTS.md and CLAUDE.md:

  - the whole `MUST treat risk: UNKNOWN as unresolved, not as low` bullet
  - the `pdg_query({mode:"controls"/"flows"})` bullet
  - the `mode: "pdg"` text on the impact bullet
  - `…never read UNKNOWN as an all-clear…` from Never Do

and regressing the stats 248612/565510/918 -> 42853/135955/758. All four document
SHIPPED features: `pdg_query` at `mcp/tools.ts:675`, dispatched at
`local-backend.ts:2233`; `mode: "pdg"` at `tools.ts:448`; `riskNote` at eight
sites.

It matters more than a docs nit because the SAME series makes `UNKNOWN` dominate
a mixed candidate set (`local-backend.ts:6058`) — correct, and it makes UNKNOWN
far more common. The surviving rule only warns on HIGH/CRITICAL, so a set
measuring CRITICAL now reports UNKNOWN and that rule no longer fires, while the
rule that covered the gap was deleted in the same commit range, from all three
files agents actually read.

ROOT CAUSE, which is why restoring the files alone would not have held.
`cli/ai-context.ts` is the template. The `pdg_query` and `mode: "pdg"` text IS in
it, correctly `hasPdg`-gated — a non-PDG analyze SHOULD drop those. The
`risk: UNKNOWN` rules were never in the template at all: they had been hand-added
INSIDE the machine-managed region, so every `gitnexus analyze` on any repo
silently deleted them. This was the second occurrence; #2856's `8f8261021` was
the first. Both lines are now generated unconditionally — they describe impact's
risk semantics, which are not PDG-dependent — so regeneration restores them
instead of removing them.

AGENTS.md and CLAUDE.md are byte-identical to origin/main again, and the fixed
template reproduces that block exactly for `hasPdg: true` plus the real stats.
`.claude/skills/gitnexus-guide/SKILL.md` regains the "Inline staleness signal"
section for a live feature (`local-backend.ts:921`, `:1017-1024`, `:1995`); the
npm mirror's lack of it is pre-existing drift and is left alone, so the new sync
guard is scoped to the canonical and plugin copies.

Guards added, both demonstrated failing against the unrestored files: the managed
block must contain the UNKNOWN policy and its Always-Do/Never-Do bullet counts
must not fall below a floor, and `generateGitNexusContent` must render both lines
for `hasPdg` true AND false while keeping `pdg_query` gated. The existing
fragment lists could never have caught this — they assert presence, and this was
a deletion.

One deliberate loosening, called out rather than buried: the restored text pushes
`ai-context.test.ts`'s block-size ratio past 0.55, so it moves to 0.65. That test
argues against exactly this nudge-the-number pattern. The defence is that the
wording is origin/main's own and the 0.55 budget was calibrated against a block
already missing it; trimming shipped guidance to fit a budget would be the wrong
direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-09 11:44:52 +01:00
Gergő Magyar
78ecce1b92
perf(import-target): index the workspace once per run for go/csharp/dart/ruby (#2898)
* perf(import-target): index the workspace once per run for go/csharp/dart/ruby

Four import-target resolvers answered their lookups with a full
`allFilePaths` scan per import, making resolution O(imports x files):

- go (#2877): `findRootPackageFiles` / `findAllFilesInPkgDir`, the latter
  once per path segment on the GOPATH fallback. Most Go imports are
  external, so the whole cascade ran to completion before returning null.
- csharp (#2878): the no-csproj leg took the raw Set past the memoized
  index the csproj leg was already using - up to eight passes for a
  four-segment `using`.
- dart (#2879): one scan per candidate path, and for an external package
  both candidates miss, so both always ran to completion.
- ruby (#2880): a complete `buildSuffixIndex` rebuilt and discarded per
  `require` - every require paid to index every file in the repo.

Each now reads an index memoized on the `allFilePaths` Set identity, the
shape `getPythonFileIndex` (#1918) and csharp's own `getWorkspaceFileIndex`
(#1881) already used. Two shared modules back them:

- `workspace-file-index.ts`: normalized list + `SuffixIndex` + a
  normalized->raw map, for csharp and ruby.
- `package-dir-index.ts`: "which files live directly inside a directory
  ending with <path>", for go and csharp. Candidates are bucketed by the
  directory's last segment rather than by indexing every directory suffix,
  which would cost O(files x depth) entries at kernel scale (#2649).

Behaviour is unchanged, including the tie-breaks that are expressed only
through Set-iteration order and `indexOf` positions: the go root leg stays
sorted and its package leg stays unsorted, the first-occurrence rule that
excludes a directory nested inside a same-named directory is preserved,
csharp's whole-path match still beats an earlier suffix match, and dart
still tries `lib/<rel>` fully before bare `<rel>` and matches raw paths.

Verified two ways. `import-target-index-parity.test.ts` keeps verbatim
copies of the pre-change implementations and diffs against them over a
deterministic corpus plus hand-built layouts for each tie-break; six
mutations of the new code were confirmed to fail it. Separately, the bench
corpus produces byte-identical fingerprints against the pre-change
resolvers at both 400 and 1600 files.

`bench/import-target/measure.mjs` gates both arms in CI: per-language
output fingerprints, a scaling budget (measured 0.98-1.12 here, 3.32-4.10
against the pre-change scans), and the corpus shape, so the corpus cannot
be shrunk below the size the scaling arm needs and still print PASS.

Closes #2877
Closes #2878
Closes #2879
Closes #2880

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

* perf(import-target): cover kotlin, and add depth + absolute-cost arms

#2872 landed the same index hoist for Kotlin while this branch was open.
Fold it into the shared measures so all five resolvers are gated on one
corpus, and adopt the two arms that PR's review proved a scaling ratio
alone cannot carry.

- `bench/import-target/measure.mjs` gains a kotlin arm: a Gradle-shaped
  corpus with per-module source roots over one package namespace, `.kt`
  and `.kts` stems, a nested same-name package directory, and a share of
  wildcard `.*` imports so the package fan-out tier — the only tier whose
  output is order-bearing — is inside the fingerprint.

- `depth_ratio`: deep arm at a FIXED file count with ~6x the path
  components. `scaling_ratio` divides the file count out, so it is
  scale-invariant and structurally cannot see a cost that grows with path
  depth instead, and `buildSuffixIndex` (C#, Ruby) and Kotlin's
  `suffixByStem` each emit one entry per component. Measured: go 0.98,
  dart 0.88 (depth-free indexes), ruby 1.48, kotlin 2.20, csharp 3.45 —
  which is why the budget is per language. One global budget would have
  to sit at 5.0 and would let Dart go 0.88 -> 4.9 unnoticed.

- `small_ms_ceiling`: an absolute bound at 4x the measured arm, because a
  constant-factor regression that grows both scale arms equally passes
  every ratio.

- The deep arm must resolve exactly what the small arm resolves. Padding
  was supposed to change depth and nothing else; a deep arm that stopped
  resolving would be timing the null path.

The five fingerprints are unchanged by this commit - verified against the
previous baseline before rewriting it, so adding the kotlin arm and the
deep scale did not perturb the four languages' output.

Kotlin joins the Set-iteration counter in
`import-target-index-parity.test.ts` too. Its own guard
(`kotlin-import-index-reuse.test.ts`) counts index BUILDS, which a scan
added beside a reused index does not move.

That counter is also the only DETERMINISTIC guard against a reintroduced
scan, and this commit documents why rather than pretending otherwise: a
full workspace scan on 1-in-32 imports was measured to pass every timing
arm here (dart, 1.458 scaling against a 1.8 budget, 1.736 ms against a
4 ms ceiling) while the counter reads 14 instead of 1. Tightening the
ceilings toward the noise floor to chase that case would only buy flaky
CI.

`bench/kotlin-import-target/` stays: it fingerprints both file-set
iteration orders and probes the four-tier cascade shape by shape, neither
of which this corpus does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

* perf(import-target): merge matching package dirs in one pass

`filesDirectlyInPkgDir` re-spread its accumulator once per matching
directory, costing O(files x dirs^2) copies per import. On a Go monorepo
where many services carry the same package directory (`svcN/internal/pkg`,
which Go's GOPATH cascade queries by two-segment tail) that made the index
SLOWER than the scan it replaced: 13.4x at 1600 matching directories.

Append into one array and sort once. Measured against a verbatim copy of
the pre-change scan, output byte-identical at every k:

  k=200   1400 files   old 0.126 ms   was 0.169 ms   now 0.042 ms
  k=800   5600 files   old 0.457 ms   was 3.002 ms   now 0.185 ms
  k=1600 11200 files   old 0.960 ms   was 12.890 ms  now 0.232 ms

The index now beats the scan by 2.5-4.1x on this shape instead of losing
to it by up to 13x.

Also drop the min-`ord` comparison in `firstFileDirectlyInPkgDir`: the
build loop appends a directory to its last-segment bucket the moment it
accepts that directory's first file, so bucket order already IS ascending
first-file-`ord` order and the first hit is the minimum. Differentially
verified at 0 divergences. The invariant, and the build-loop edits that
would silently break it, are now recorded at the early return.

Type the index containers as deeply readonly so Go's deliberate
`[...rootFiles].sort()` copy is compile-enforced rather than
comment-enforced, and correct the header's claim that a polyglot repo
"never pays" -- only the stored index is per-language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rLy781K8E3rRHFGEe1khJ

* test(import-target): guard index reuse at the adapter boundary

`workspace-file-index.ts` documented the hazard as "a defensive
`new Set(allFilePaths)` in an ADAPTER" -- the bug #1918 shipped -- and
named the unit parity test as the guard. It is not: that test imports the
resolvers directly, while production reaches them through
`<lang>ScopeResolver.resolveImportTarget`. Inserting the copy at
`go/scope-resolver.ts:31`, `csharp:35`, `dart:189` and `ruby:268` left the
parity test 28/28 green and `measure.mjs --check` PASS in all four cases.
Kotlin and Python already had adapter-level guards; go/csharp/dart/ruby
had none.

Add `test/integration/<lang>-import-index-reuse.test.ts` for the four,
mirroring the Kotlin/Python precedent: resolve through the scope resolver,
assert the file set is traversed once (twice for C#, which builds two
indexes), and pair every count with a result assertion so a count of 1
cannot be the count of an adapter that resolves nothing. Each was proven
to fail under the copy it exists to catch:

  go     expected 600 to be 1     dart   expected 600 to be 1
  ruby   expected 400 to be 1     csharp expected 600 to be 2

`CountingSet` moves to `test/helpers/counting-file-set.ts` and now counts
`forEach`, `values`, `keys` and `entries` as well as `[Symbol.iterator]`.
It missed a rescan spelled `allFilePaths.forEach(...)` entirely; with the
overrides that mutation reads 14 instead of 1.

Four fixtures that pinned the guard next door, each now shown to kill its
mutation:
- the Dart "matched RAW" case used a forward-slash target, so the basename
  bucket missed before the raw comparison was reached and it asserted
  `null === null`. A positive twin carrying the backslash in the TARGET
  catches both half-mutations.
- no C# or Ruby target addressed the corpus's `win\dir\thing` file, so
  deleting the backslash normalization in `workspace-file-index.ts` passed
  both gates. Now 4 failures.
- `normToRaw`'s first-wins rule had no normalization twin in any corpus.
- the Go nested-package fixture was decided by the `endsWith` half and
  never reached the first-occurrence branch its title names; addressing
  the directory as a single segment makes it reach it.

The parity test's own docstring no longer claims the scan count is a
complete census -- it names the three materialized arrays it cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rLy781K8E3rRHFGEe1khJ

* test(import-target): assert every scale, add collide and retained-heap arms

Three holes in the gate this PR ships as its own proof.

1. `--check` computed three fingerprints per language, stored all three,
   and compared one. `DEEP_PAD = 16 -> 0` deleted the entire depth arm and
   still printed PASS, because depth padding is count-neutral by design so
   no asserted number moved. Assert `fingerprint` per scale, and assert
   `deep.fingerprint !== small.fingerprint` so the padding's EFFECT is
   pinned, not just its output.

2. The corpus minted per-index directory names (`src/pkg${d}`,
   `src/Ns${d}`, `lib/feature${d}`), so max last-segment bucket and max
   matching dirs were both 1 -- and bucket cardinality is the only
   non-constant term the index has. The `dirCount > 1` merge branch had
   never executed in any arm. Add a `collide` arm on shared-leaf layouts
   with identical files/imports/resolved counts; it reaches 9,269
   multi-directory merges per run, up to 34 directories at once. Go and
   C#/Dart legitimately score above the linear budget there and get their
   own; Ruby and Kotlin stay at 1.8 because their keyed maps are
   collision-immune and that immunity is the assertion.

3. No arm measured memory, while the C# no-csproj leg newly retains an
   O(files x depth) suffix index. Add a retained-heap arm on the
   `bench/cfg` pattern, including its loud failure when `--expose-gc` is
   missing rather than a silent skip. Measured at 32k files:
   csharp 73.62 MiB, ruby 55.26 MiB. Ceiling is 1.5x, NOT the 4x the
   timing arms use -- the measurement is byte-stable to 0.00085% across
   processes, so 4x would be throwing away the gate. `_arms_note` records
   why, so nobody harmonises it back.

`depth_ratio`, added by this PR, flaked ~1-in-20: go peaked at 1.748 and
dart at 2.043 against a 1.6 budget, both ratios of two sub-3 ms minima.
Fixed at the estimator, not the threshold -- REPS 5 -> 15, matching
`bench/cfg`, `schema-pairs` and `callable-value-flow` (5 was the lowest in
the repo; the sibling `kotlin-import-target` uses 7, which was not enough
here). 22/22 PASS, every arm now at 70-78% of its budget with a <=1.26x
swing. No budget was widened; the distributions are recorded in
`_arms_note` so the headroom is visibly earned.

Three copies of the same overclaim corrected: the parity test NARROWS the
1-in-32 blind spot, it does not close it -- it watches the Set while the
resolvers hold materialized arrays. `_floor` no longer claims its ratios
"match" the issues' (different corpora, both quadratic).

The step moves to the END of the benchmarks job and runs with
`--expose-gc`. A failing step aborts every step after it (#2895), so the
newest, least-proven gate must not sit ahead of eight established ones.

All five output fingerprints are byte-identical to before this session --
the proof that every change here was behaviour-preserving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rLy781K8E3rRHFGEe1khJ

* docs(import-target): point the reuse contract at the guard that guards it

`workspace-file-index.ts` told callers the unit parity test guards the
adapter-copy hazard. It does not -- it never crosses the adapter. Name
both layers and say which catches what: the per-language
`test/integration/<lang>-import-index-reuse.test.ts` files at the adapter
boundary, the parity test for a rescan reintroduced inside a resolver.

The C# namespace-dir index comment named `findDirectChild`, which this PR
deleted; it feeds `firstFileDirectlyInPkgDir` now.

Drop `GoResolveContext`, dead since the legacy call-resolution DAG was
removed in #942 -- zero importers, and `gitnexus`'s package.json declares
no `main`, `exports` or `types`, so it is not a published surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rLy781K8E3rRHFGEe1khJ

* refactor(import-target): quality pass over the review-round changes

Four cleanup lanes (reuse / simplification / efficiency / altitude) over the
previous four commits. No behaviour change anywhere: all 25 bench cells
(5 languages x 5 arms) are byte-identical on files, imports, resolved,
distinct_outcomes and fingerprint, re-verified after each individual edit.

**Restores a fast path the last commit lost.** Fixing the O(k^2) accumulator
made the SINGLE-directory case — the overwhelmingly common one — copy the
bucket where the original aliased it: measured 1.11x slower at 4 files/dir
rising to 1.72x at 128. Holding the first bucket by reference and promoting to
an accumulator only when a second directory appears is 0.65-0.97x of the
previous code at dirCount=1 and parity at dirCount=64. 176-case differential,
0 divergences.

**`sortedRootFiles` accessor.** `rootFiles` was the only index container read
directly from outside the module. `readonly` is erased at runtime and
`Array.isArray` widens it back, so the copy rule now lives with the code that
owns the invariant instead of at the call site. No `Object.freeze`: V8's
PACKED_FROZEN_ELEMENTS read cost lands on the hot `matchingDirs` path.

**One shared arm for the four reuse guards.** The distinct-file-set test was
copy-pasted four ways, 33-38 identical lines each, and this repo's own helpers
(`mini-repo.ts`, `scope-model.ts`) document extracting at the SECOND verbatim
consumer. `expectDistinctFileSetsGetOwnIndex` takes what actually varies; its
`expected` type excludes `null` so the pairing rule cannot be reinstated as a
hole. The per-language first and third arms stay duplicated on purpose —
corpora and payload shapes genuinely differ. Re-proven: all four still fail
under an adapter-inserted `new Set(allFilePaths)`.

**Bench.** `dirsFor` shared by the two functions that must agree on directory
fan-out (they mint and address the same files). `SCALES` derived from the arm
table, so a future arm cannot be measured, printed and silently never asserted.
Five timing checks with one shape collapsed to a table — the trailing sentence
had already drifted into four wordings. `uniqueTarget`/`collideTarget` as flat
functions, mirroring the `uniqueDir`/`collideDir` split rather than nesting a
second axis four ternaries deep. One `identityPass` replaces two untimed full
resolution passes per cell: -371 ms median.

**CI step moved back where it belongs.** It was parked last "until #2895
lands", but that reasoning was backwards twice over: the flake that motivated
it was fixed at the estimator in the previous commit, and #2895's own audit
measured the last slot as executing zero times in 13 runs. It sits with the
other resolver-index guards; #2899 carries the `if: !cancelled()` that fixes
step masking for every step at once.

Filed rather than fixed here: #2908 (java and cobol still scan the workspace
per import, same shape as #2877-#2880, neither memoized), #2909 (make index
reuse a contract test over SCOPE_RESOLVERS on one instrument).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Co58j4au9JLf8dmJpwdF9B

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:59:37 +01:00
glier
c6b24162d9
perf(kotlin): index import resolution instead of scanning per import (#2872)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* perf(kotlin): index import resolution instead of scanning per import

`resolveKotlinImportTarget` walked the entire workspace on every import.
Its four tiers — exact/suffix, directory child, package fan-out and
progressive prefix strip — each ran `for (const raw of allFilePaths)` with a
`replace(/\\/g, '/')` and several string scans per entry, and they are tried
in cascade, so one unresolved import cost two to four full passes.

Across a repository with tens of thousands of Kotlin files that is
O(imports x files): on the order of 10^10 string operations on a single
thread. It does not look like a hot loop from the outside - analyze sits at
exactly 1.00 core with a completely flat heap and emits nothing for hours,
because every allocation is a short-lived string and nothing accumulates to
hint at progress. Small repositories hide it entirely: at a few hundred files
each pass is free.

Three maps, built once per `allFilePaths` Set and memoized on its identity,
make each tier O(1): stem -> path for the exact tier, every component-suffix
of the stem for the suffix tier, and directory -> direct children for both the
fan-out and the first-child fallback. Cost becomes O(files) once plus O(1) per
import. This mirrors the existing Python index (`getPythonFileIndex`), down to
the WeakMap keying and the build counter.

Semantics are unchanged, including the parts the scans expressed only through
iteration order:

  - an exact match anywhere beats a suffix match found earlier, because the
    scan returned on the first exact hit but merely remembered the first
    suffix hit;
  - "first match" stays first in set-iteration order, so both stem maps keep
    the earliest path inserted for a key;
  - a directory-name match still honours the scan's `startsWith`-then-`indexOf`
    rule, which only ever considered the FIRST occurrence of `/dir/`. A path
    like `data/src/main/kotlin/com/example/data/Repo.kt` is therefore still
    NOT a child of `data`. That is arguably wrong, but fixing it here would
    silently move edges in every Kotlin repository; it belongs in its own
    change with its own fixtures.

That claim is gated, not asserted. `bench/kotlin-import-target` fingerprints
every `fromFile | targetRaw -> result` triple over an exhaustive branch matrix
plus a deterministic fuzz, each file set resolved in BOTH iteration orders
because that is the only place the tie-breaks above are expressed. The
committed baseline is the value the PRE-INDEX implementation produces: both
implementations print
5ad605c179081505705ff7698a09dbdbdc4831080af6d9fdec5499cc6bce28ee over the same
20074 cases, 11612 of them non-null, and anyone can re-run it by pointing the
harness's module specifier at the old file.

Its second arm is the scaling ratio, `(t_large/t_small)/(1600/400)` over a
synthetic Kotlin monorepo whose imports are ~40% unresolvable — only a miss
drives all four tiers, which is where the scan was worst. The index measures
0.99 (8.0 ms / 31.7 ms); the implementation it replaces measures 3.737
(2207.8 ms / 33003.5 ms) on that same corpus, so the budget of 1.6 separates
them by a wide margin. Take the absolute times as an order of magnitude only
(~276x, ~1041x): the floor arm was run once cold because best-of-seven against
a quadratic implementation costs minutes, while the index arm is the usual
best-of-seven. The ratios are the comparable pair. Both arms run in the
existing always-on `benchmarks (GITNEXUS_BENCH)` job, next to the C++ guard
from #2788 and the Python one from #1918.

Two unit-level guards sit alongside it: a parity test pinning the curated
cases, and an integration test asserting the index is built once across many
imports — the adapter must pass the Set through, since a defensive copy would
hand a fresh WeakMap key per call and restore the old behaviour (the same trap
Python hit in PR #1918).

Two other providers have the same defect and are left alone here, having no
repository at hand to verify a change against:

  - `go/import-target.ts`: `findRootPackageFiles` and `findAllFilesInPkgDir`
    scan unmemoized, and the GOPATH fallback calls the latter once per path
    segment but the last, so a single import can trigger several full passes;
  - `dart/import-target.ts`: the `package:` branch scans once per candidate
    path — `lib/<rel>` and bare `<rel>` — and `resolveRelative` scans again in
    its suffix fallback, also unmemoized.

`csharp/import-target.ts` is a partial case worth noting: it already builds a
memoized `getWorkspaceFileIndex`, but that is reached only when a `.csproj` is
found; the no-csproj path hands the raw Set to `resolveDirectMatch` and
`resolveByProgressiveStripping`, which scan past it.

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

* test(kotlin): close the blind axes in the import-resolution gate

Review of #2872 found the weak part was the gate, not the resolver: four
plausible follow-up mutations passed `--check` with a byte-identical
fingerprint, `cases` AND `non_null`. Each is now caught, and each was
re-checked against the mutation it exists to stop.

  - The hashed record carried `order | fromFile | targetRaw | result` but not
    the FILE SET, so a corpus edit that swapped the workspace under a case
    while leaving its result string alone was invisible. Leaving the resolver
    untouched and editing only the corpus, two documented load-bearing cases
    could be gutted — the "exact beats an earlier suffix" case losing its
    competing file, the repeated-directory negative case losing its file
    entirely — with the gate green. The file set is now part of the record, and
    that same edit now moves the fingerprint.
  - The corpus capped path depth at 8 components and packages at 16 files,
    which are precisely the two axes the loops this change added run on. It now
    carries 11- and 13-component paths, queries against suffix keys deeper than
    seven segments, a 40-file package, and a fuzz that spans both. Verified:
    capping suffix-key depth at 7, skipping the `dirChildren` suffix loop above
    depth 8, and capping a bucket at 17 entries each now move the fingerprint,
    where all three previously passed.
  - `non_null` was reported but never asserted; it is asserted beside `cases`.
    That closes only the "resolves nothing at all" hole — it stayed 11612 under
    all three code mutations above and under the corpus edit — so it is a
    companion to the two fixes above, not a substitute for either.
  - A ratio cannot see a constant factor, and a file-count ratio cannot see a
    depth cost. `--check` now also asserts a DEPTH ratio (file count fixed,
    paths 24 components against 8) and an absolute ceiling on the small arm: a
    full workspace scan reintroduced on 1-in-32 imports scores 1.490, inside
    the scaling budget, while running 2.8x slower.

The baseline is re-derived, not adjusted: the pre-index implementation and the
index both print
ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c over the new
20106-case corpus, 13256 of them non-null.

Both test suites were shown to be non-load-bearing and now are:

  - the parity test's repeated-directory case put `data` at the LEADING
    segment, so the `startsWith` guard fired and the `indexOf` rule its own
    comment describes was never reached — a resolver with that check relaxed to
    `>= 0` passed all 18 cases. A mid-path case now pins it, and a backslash
    fan-out case pins `norm.lastIndexOf` against `raw.lastIndexOf`, which was
    also bench-only. Both mutations now fail the unit suite.
  - the index-reuse test discarded all 200 return values, so a build count of 1
    was equally true of an adapter that had stopped resolving anything. It now
    asserts results, and its docstring premise is corrected: every one of its
    imports hit the tier-1 suffix lookup and none reached the fan-out it
    claimed to exercise. Half now genuinely do. The `undefined as never` casts
    and the `?.` are gone — both trailing parameters are optional and the
    member is required.

Resolver changes, all output-identical against the differential above:

  - `dirChildren` buckets are frozen once built. `findKotlinPackageFiles` hands
    a bucket straight out of the index, and the `readonly string[]` return type
    does not survive the caller: the finalize pass normalizes with
    `Array.isArray(t) ? t : [t]`, and `isArray`'s `arg is any[]` predicate
    widens the true branch, so `tsc --strict` accepts a `.sort()` there. A
    downstream sort would permanently reorder the cached bucket and flip the
    first-child tier for every later import in the run.
  - `stripped` is computed only after tier 1 misses, with `lastIndexOf`/`slice`
    instead of `split`/`slice`/`join`. Measured -20% small arm, -21% large arm.
  - `KOTLIN_EXTENSIONS` now comes from the existing `import-resolvers/jvm.ts`
    export instead of a fourth inlined copy.
  - A note on why the shared `buildSuffixIndex` is not reused, with the four
    probes that diverge, and the measured basename-bucket comparison — the one
    place this was less documented than the Python precedent it follows, and
    the question the Go/Dart/C# follow-ups will each face.

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>
2026-08-08 09:31:39 +00:00