Commit graph

101 commits

Author SHA1 Message Date
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
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
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
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
azizur100389
87dc6c4d00
fix(go): gate imports by module path (#2984) 2026-08-18 14:31:40 +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
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
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
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
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
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
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
DuduPhudu
223ac7010d
feat: close reported graph blind spots in reference resolution, analyze and storage (#2856)
* fix(mcp): report UNKNOWN risk when an upstream impact walk finds no callers

`risk: LOW` asserts "safe to change" — a claim ABOUT callers. An upstream
walk that resolved none has nothing to base it on: the symbol may be
genuinely unused, or reached only through a reference class the index does
not record (a property access on a plain object, a bare-identifier read of a
module-scope const). Seeding LOW from an empty result is the false-safe
signal `anyKnownRisk` already refuses to emit on the ambiguous-candidate
path, and that #2687 removed by making an undetermined impactedCount `null`
rather than `0`.

Zero-caller upstream results now report risk UNKNOWN with a riskNote saying
absence of edges is not evidence of disuse. Downstream is untouched: an
empty downstream walk reports resolved callees, not safety.

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

* feat(javascript): emit ACCESSES for bare-identifier reads of module-scope consts

A constant read only as a bare identifier — `Math.max(LIMIT, n)`, a default
parameter value, `return LIMIT` — minted no reference site at all, because
JS captured only `@reference.read.member`, which requires a receiver a bare
identifier does not have. So "who uses this constant?", the question behind
every dead-code trim and constants refactor, answered with a confident zero
in both directions.

The rest of the machinery was already in place: `FIELD_KINDS` accepts
`Const`, the scope query already declares it via `@declaration.const`, and
`read` maps to ACCESSES for any resolved target. This adds the missing
capture in VALUE POSITIONS ONLY (call arguments, default-parameter values,
return statements) — a blanket `(identifier)` rule would mint a site for
every token in the file, which is unaffordable at repo scale and would keep
alive the block-local symbols `pruneLocalSymbols` exists to drop.

Cross-file readers are NOT yet covered: the site exists and a call through
the same import statement resolves, but a value-kind def does not link
across the import edge. Recorded as a todo with the investigation.

PARSE_CACHE_VERSION bumped 44 -> 45: this is parse-time capture emission, so
a warm cache replays the pre-change capture set and the new edges never
appear — observed directly, a full `analyze --force` produced a
byte-identical graph until the cache was cleared by hand.

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

* test(javascript): pin A1/A5 plain-object property acceptance criteria

Fixture plus todo specs for the four shapes plain-object property access has
to answer: object-literal keys indexed as Property nodes, a read through the
holding variable, a property WRITE, and a read through an untyped param.

Records the investigation so the work is resumable: the parse-query pattern
scoped to literals bound to a variable matches correctly (verified against
the raw JAVASCRIPT_QUERIES), but no Property node reaches the graph and
local-symbol-pruner is not the cause — it drops only Const/Variable/Static.
The remaining gate is in the parse worker's node-creation path.

No production code — specs only, so the suite stays green.

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

* feat(javascript): index object-literal keys of a named object as Property nodes

Idiomatic JS models configuration as an object literal, not a class, but
Property definition nodes existed only for DECLARED CLASS FIELDS. A config
field therefore had no symbol at all: `context({name: 'exitMinAtrMult'})`
answered "not found" for a field read and written throughout a live code
path, and ACCESSES had no target to point at.

Both halves are added for keys of a literal BOUND TO A VARIABLE — the parse
query mints the graph node, the scope query mints the def the resolver can
aim at. Unbound literals are deliberately excluded: an inline call argument
or a JSX prop bag is call-site data, not a named surface other code
references, so a node per key there would add volume without adding an
answerable question.

This lands the definition-node half only. The ACCESSES edges still require
receiver resolution — typing the const that holds the literal to the
literal's scope for the precise case, and name-based matching at reduced
confidence for the untyped-param (option bag) case. Both are recorded as
todos with the mechanism each needs.

Also records a trap that cost a wrong conclusion: under vitest the parse
worker runs the BUILT dist code (parse-impl resolves parse-worker.js, absent
under src/, and falls back to dist), so parse-query changes are invisible to
tests until `npm run build`.

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

* test(cache): move the SCHEMA_BUMP pin to 45

The pin is the guard that makes two branches claiming one cache-schema
number fail loudly instead of silently serving each other's entries, so a
bump is only half-done until the pin moves with it. The bump itself landed
with the JavaScript bare-identifier captures; this is the other half.

Caught by the guard working exactly as designed — the suite failed with
"expected 45 to be 44" rather than letting a mismatched pair through.

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

* feat(scope-resolution): resolve plain-object property access by unique name

Idiomatic JS reads configuration off an object whose receiver cannot be
typed — an options bag passed as a parameter, a destructured handle, an
imported literal. No precise pass resolves those, so a field read and
written across a live code path produced no ACCESSES edge at all and "who
reads this setting?" answered a confident zero.

A last-resort pass runs after every precise pass and sees only what they
left behind. For each still-unresolved read/write site it asks whether
exactly ONE Property in the workspace carries that name. If so the read
almost certainly means it. If two or more do, nothing is emitted and the
site is COUNTED as ambiguous — a guess between them would be a coin flip,
and a wrong edge in the pre-edit safety gate is worse than a missing one.

Uniqueness is the right gate because it recovers exactly the names worth
recovering: distinctive domain fields (exitMinAtrMult, bookNotionalUsdt)
are unique in a repo and resolve, while generic keys (id, name, data) are
not and are skipped — which is where name matching would over-connect.

Bounded four ways:
- Confidence 0.5, the global tier, with the inference named in the reason,
  so a consumer can filter inferences without losing scope-resolved edges.
- Never second-guesses a precise result: sites already resolved are
  excluded, because first-write-wins stops a duplicate but NOT a second
  edge to a different target.
- Honors `fieldFallbackOnMethodLookup`. A statically-typed language opts
  out of name matching precisely because it over-connects; inferring an
  ACCESSES edge by name is the same claim and must obey the same opt-out.
- Requires an explicit receiver — a bare identifier is not a property
  access, and matching one by name would link a local to an unrelated key.

Indexes graph nodes rather than scope defs because an object-literal key
mints a Property NODE but no scope-resolution DEF: `localDefs` and
`scope.bindings` are both empty for exactly the population this serves.

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

* feat(analyze): record a collapsed graph write instead of reporting fresh

The dangerous half of a broken refresh: metadata IS written, so the index
reads as fresh, hooks re-arm, and every tool answers from a graph missing
most of its edges — indistinguishable from a codebase that genuinely has no
such relationships. Reported in the field as edges collapsing 23009 -> 2170
and as a CodeRelation table that never materialized.

`analyze` now compares the relationship count the pipeline PRODUCED against
what the DB hands back after the write. Both numbers are already in scope at
the same point, so the shortfall is provable rather than inferred — no
comparison against the previous index, which cannot distinguish a failed
write from a repo that legitimately shrank. A missing relation table needs
no special case: it reads back as a persisted count of zero.

On a collapse the run records `graphWriteCollapsed` in metadata, which
`getIndexIncompleteReasons` turns into `graph-write-collapsed` so status and
the MCP resources report the index INCOMPLETE rather than fresh.

A ratio, not equality: some relationship types do not round-trip one-for-one
and `--pdg` writes MORE rows into the same table, so demanding equality
would fire on healthy runs. Only a collapse is a defect. Fail-safe when the
expected count is unavailable — an implementation that offloads
relationships out of memory may not be able to report a total, and a false
"your index is broken" is worse than a missed one.

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

* fix(ingestion): qualify object-literal Property ids by their owning object

Two config objects in one file that share a key name generated the same
`Property:<file>:<key>` id and COLLAPSED INTO ONE node, so two distinct
settings became a single symbol. Worse, the merged name then looked
workspace-unique to name inference, which happily resolved reads of it to a
node representing both — a wrong edge in the pre-edit safety gate, which is
precisely what the unique-name pass is bounded to avoid.

`objectLiteralOwnerInfo` already existed for exactly this ("so two
constructors in one file that both define `bar` stay distinct nodes") but
was gated to `Method`. `Property` now opts in.

`findObjectLiteralBindingInfo` returns `ownerName` only when asked. Its
`Method` ids must stay byte-identical — qualifying them would rewrite every
object-literal method id in every indexed repo — while object-literal KEYS,
indexed only since A1/A5, have no such history to preserve.

Found by a test written for the ambiguity path rather than by review: the
suite reported one node where two were expected, and an edge where none
should exist. Both are now pinned, along with the detection boundaries of
the B2 collapse check, which was previously an untestable inline expression
and is now a pure function.

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

* feat(typescript): index type aliases and shape members as symbols

A TS frontend models its API contracts as `type X = { … }` and `interface`,
so a field on one is exactly what "who breaks if I remove this?" is asked
about. Three gaps made that unanswerable, all in the TypeScript queries:

1. No `type_alias_declaration` -> `@definition.type`, so an alias minted NO
   NODE AT ALL and a context() lookup on an exported contract type answered
   "Symbol not found". TypeScript was the ONLY language missing this — Rust
   (type_item), Kotlin (type_alias), Swift (typealias_declaration) and Dart
   all emit it. The alias was declared for scope resolution but never became
   a graph symbol.
2. No `property_signature` in the parse query, so INTERFACE members minted no
   Property nodes either — the upstream report's "class/interface index fine"
   holds only for the type, not its fields.
3. No `property_signature` in the scope query, so even with nodes present the
   resolver had no member declaration to aim at. Its sibling
   `method_signature` -> `@declaration.method` already existed; only
   properties were missing.

Interface bodies and object-type aliases both spell members as
property_signature, so one pattern per query covers both shapes.

Lands the SYMBOLS, not yet the ACCESSES edges: the shape is already a
class-like scope and now has member declarations, but no edge forms — the
remaining link is owner/type-binding, recorded as todos with the diagnosis.
Note TypeScript sets fieldFallbackOnMethodLookup:false, so unlike JavaScript
there is deliberately no name-based fallback here; the precise path is the
only route by design.

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

* test(golden): accept interface members in the mini-repo snapshot

Drift is entirely the new TypeScript shape-member indexing: the fixture's
three interfaces (ValidationResult 2, DbRecord 3, LogEntry 3) contribute
exactly 8 Property nodes, each with exactly one HAS_PROPERTY owner edge.

Verified before regenerating rather than after: every pre-existing count is
untouched (CALLS 9, IMPORTS 12, DEFINES 16, HAS_METHOD 1, MEMBER_OF 12,
STEP_IN_PROCESS 12), so nothing was rewired — the digest moved only because
8 edges were added. The fixture's inline `return { valid: false, … }`
literals correctly produced nothing, confirming the object-literal rule
stays scoped to variable-bound literals.

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

* fix(analyze): never report a collapse from a non-numeric count

The B2 check reported healthy runs as total graph-write collapses. A
non-numeric `expected` (a graph implementation reporting no total, a
lightweight pipeline result) does not skip the guards — it INVERTS them:
`undefined < 100` is false, so the small-repo exemption never fires, and
`0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio check
"passes" as well. Both bounds silently evaporate and every such run is
flagged.

That is precisely the failure this check was written to catch, reproduced
inside the check itself: an unmeasurable quantity treated as a measured
zero. Both sides are now validated as finite numbers before any comparison.

`persisted` is also passed as UNKNOWN rather than zero when the DB was not
demonstrably readable: `getLbugStats` flattens "no connection", "query
threw" and "empty table" all into `edges: 0`, so `stats.nodes > 0` is used
as independent evidence the read happened at all.

Caught by the existing run-analyze suites, not by the new unit tests — those
exercised the pure function with well-formed numbers and were blind to the
integration's actual inputs. Both cases are now pinned.

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

* feat(typescript): make object-type aliases own their members

A TS object-type alias declares the same `property_signature` members as the
interface beside it and answers the same question, but was not a member
owner: its fields were minted with bare ids and no owner edge, so two
aliases in one file sharing a field name collapsed onto one node, while the
identical interface resolved normally.

`type_alias_declaration` joins CLASS_CONTAINER_TYPES (and
CONTAINER_TYPE_TO_LABEL, as that set's invariant requires — a container
missing there gets orphaned member edges or a wrong owner label). Aliases
with no object type (`type Id = string`) declare no members, so they own
nothing and are unaffected.

This also lands the INTERFACE field -> consumer edges, verified on the
mini-repo fixture rather than only on a purpose-built one: `saveToDb` now
links to `ValidationResult.value`, and `formatLogEntry` to `LogEntry.level`
and `LogEntry.message` — three real contract-field reads that previously had
no graph path at all. Golden updated: +3 ACCESSES, no node changes.

The ALIAS field -> consumer edge is still not linked and is recorded as a
todo with the exact blocker: resolving a receiver typed as the alias needs
the NAME to resolve to a class-like def, and `isClassLike` is
Class|Interface|Struct|Record|Enum|Trait. That predicate is read from ~12
sites including MRO and heritage, and every language mints TypeAlias, so
widening it would enrol aliases in linearizations where they do not belong.
Widening only the scope index was tried and reverted — the type-name walkers
gate on it independently, so it fixed nothing and left dead code. That needs
a deliberate "shape-like" concept, not more call-site widening.

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

* docs(test): record the traced diagnosis for the unlinked alias field edge

Traced to the end rather than left as "needs investigation", so the next
attempt starts from facts:

  1. Graph side is COMPLETE and symmetric with the interface —
     Property:...:LiveModeConfig.bookSlots is owner-qualified and carries
     HAS_PROPERTY.
  2. Resolution DOES reach resolveClassBindingForName('LiveModeConfig')
     (instrumented) and misses.
  3. It misses because the module scope binds LiveModeIface:Interface,
     renderAlias, renderIface — and not LiveModeConfig. The alias has no
     binding on the receiver's scope chain at all.
  4. The TS scope query tags aliases @declaration.type, but normalizeNodeLabel
     accepts only typealias / type_alias and has no "type" case, so it returns
     undefined. Kotlin and Dart use @declaration.type_alias; TypeScript is
     alone on the dead tag.
  5. Retagging is NECESSARY BUT NOT SUFFICIENT — tried, and the binding still
     does not appear, so a second gate exists in how a declaration anchored on
     a node that is ALSO a @scope.class anchor is attached: the alias appears
     to bind inside its own scope rather than hoisting to Module, where
     interface_declaration evidently does hoist.

An isShapeLike predicate (the nominal-vs-structural split: shapes declare
members, nominal types participate in MRO) plus a mirrored
findShapeBindingInScope were built and REVERTED along with the retag. With no
binding on the chain they never fire, and shipping inert widening is worse
than shipping none — the same standard applied to the earlier scope-index
attempt. The design is recorded here; it is worth doing once step 5 is fixed,
and it also unblocks Rust's parked union_item, which the MEMBER_OWNER_NODE_TYPES
comment documents as the same gap in another language.

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

* feat(scope-resolution): resolve cross-file value references, skip block-locals

Two halves of the same question, "who uses this constant?".

CROSS-FILE. `resolveReferenceSites` runs against the registries and, as its
own comment says, "imports live in finalized bindings the registries can't
see" — which is why free CALLS need `emitFreeCallFallback`. Reads had no
counterpart, so `import { LIMIT }` followed by a bare use resolved to nothing
while a CALL through the very same import statement resolved fine. This adds
the read/write counterpart, reusing `findValueBindingInScope` (which walks the
FINALIZED chain) rather than inventing a lookup. Confidence 0.9: the import
names the def, so this is precise resolution, not inference.

BLOCK-LOCALS. Bare-identifier capture also matches a read of a block-local
`const`, and an edge to one keeps alive exactly the inert locals
`pruneLocalSymbols` exists to drop — a pruned node becomes a retained node
plus an edge, in every function of every indexed repo. Emission now takes the
set of value defs bound at MODULE scope and drops ACCESSES to
Const/Variable/Static outside it. The cross-file pass carries the same
guarantee structurally: a def in another file cannot be a block-local of this
one, so it skips same-file hits entirely.

The block-local leak was already shipped in the intra-file A2 commit and was
found only because a test was written for the guard rather than the feature —
the same way the object-literal id collision surfaced.

Verified on the full resolver matrix: 3172 tests, golden unchanged.

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

* fix(lbug): diagnose a vanished staging CSV instead of surfacing a Binder error

A forced rebuild could fail with "COPY failed for File: Binder exception: No
file found that matches the pattern .gitnexus/csv/file.csv" and then an ENOENT
on .gitnexus/csv/rel_Folder_File.csv — two engine-level messages that name
neither a cause nor a remedy, which is where several field reports end.

Only tables with rows > 0 enter the COPY manifest (csv-generator.ts), so an
absent file was WRITTEN during this run and removed since. Both COPY loops now
preflight and say exactly that, with the row count, both causes the reports
point at (a second `gitnexus analyze` on the same repo — they share
.gitnexus/csv — or an external cleanup of .gitnexus/), and the action to take.

Scope note, deliberately narrow: this does not attempt to fix WAL corruption
or checkpoint rotation. Those already have detection and recovery hints
(isWalCorruptionError, WAL_RECOVERY_SUGGESTION, the configurable
wal-checkpoint-threshold), and the ~6000 lines added to lbug/ + storage/ since
v1.6.9 — index-lock.ts most of all, which serializes writers and plausibly
closes the concurrent-run class outright — postdate every report in the
window. Guessing at unreproducible durability faults would be speculation;
making the one failure with NO handling legible is not.

An existing overlap test induced this exact scenario (a manifest entry
pointing at a missing csv) and asserted on the engine's wording. Its intent —
that a node-COPY failure is rethrown at the FK barrier rather than swallowed —
is unchanged and still asserted; only the message it matches moved.

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

* feat(scope-resolution): split shape-like from class-like, linking alias fields

Completes A4: a field on a TypeScript object-type alias now links to the code
that reads it, the last unanswerable half of "who breaks if I remove this?"
for a TS frontend that models contracts as `type X = { … }`.

`isClassLike` answered two questions that only coincide for classes:
  1. does this declare MEMBERS I can look up?   — a SHAPE (structural)
  2. does this participate in inheritance / MRO? — a NOMINAL TYPE
An object-type alias is (1) and emphatically not (2) — it has no supertypes
and no place in a linearization. Widening `isClassLike` to buy (1) would have
enrolled every language's aliases (Rust type_item, Kotlin/Swift/Dart
typealias, C typedef) into MRO and heritage, so the two questions now get two
predicates. Call sites split by which they ask, and their names already said
which: `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase`
keep `isClassLike`; receiver typing and member OWNERSHIP take `isShapeLike`.

Three parts, each necessary and none sufficient alone:
- `findShapeBindingInScope`, mirroring `findValueBindingInScope`'s established
  relationship to `findClassBindingInScope` (same walker, different accepted
  def-type), consulted only AFTER the class lookup misses so a class of the
  same name always wins.
- `populateClassOwnedMembers` uses it, so alias members get an `ownerId` and
  are registered under the alias. Without this the receiver resolved to the
  alias and then found no members under it.
- The TS scope query tags aliases `@declaration.type_alias`, not
  `@declaration.type`: `normalizeNodeLabel` accepts typealias / type_alias and
  has no "type" case, so the old tag mapped to NO label and TypeScript aliases
  produced no scope-resolution def at all. Kotlin and Dart already spelled it
  this way; TypeScript alone was on the dead tag.

An earlier attempt concluded a further "scope-attachment gate" existed. That
was wrong and is worth recording: scope extraction runs in the parse WORKER,
which loads built `dist`, so the retag was never executed. Rebuilt, the alias
hoists to Module scope exactly as the interface does. Same trap as the parse
query — `src` edits to anything the worker runs are invisible until
`npm run build`.

Typedef and Union stay out of `isShapeLike` deliberately: they belong
conceptually (the union_item note on MEMBER_OWNER_NODE_TYPES records the same
gap) but neither is wired as a member container, so including them would widen
a predicate nothing exercises.

Verified on the full resolver matrix: 3173 tests, golden unchanged.

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

* test(typescript): pin the type-alias capture to a tag that maps to a label

The capture test asserted `@declaration.type`, the tag that
`normalizeNodeLabel` does not recognize (it accepts typealias / type_alias and
has no "type" case). So the test passed for as long as the tag was broken: it
checked only that the capture FIRED, never that it resolved to anything, while
TypeScript aliases produced no scope-resolution def at all.

Updated to the working tag and given a second assertion that the derived kind
string is one the label mapper accepts — the property that actually matters,
and the one whose absence let a dead tag sit pinned.

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

* fix(lbug): declare TypeAlias member pairs so analyze does not abort

Making object-type aliases member owners emits HAS_PROPERTY from a `TypeAlias`,
and the relation schema declared no such pair. The emit therefore threw
`UndeclaredRelationPairError` and the ENTIRE analyze died on any repo
containing `type X = { ... }` — a hard stop, not a dropped edge. Found by
running the analyzer over a real 16k-node TypeScript repo, not by a test.

`Method` is declared alongside `Property`: a member written
`type Handler = { onClick(): void }` is a method_signature and would fail in
exactly the same way.

Why every existing test missed it: the resolver suites build an in-memory
graph via `runPipelineFromRepo` and never write to LadybugDB, so the schema
constraint was never exercised. `structural-pair-coverage.test.ts` is the one
suite that does run the emitters against the declared pairs — and its own
docstring names the gap: coverage is bounded by NON_BRIDGE_CORPUS, "a new
structural emitter should land with an entry here". This adds that entry,
pinning TypeAlias|Property and Interface|Property as sentinels.

Verified the guard is not vacuous: removing the pair again makes the suite
fail with undeclaredPairs: ["TypeAlias|Property"].

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

* fix(processes): trace depth-first so multi-hop flows are detected

D1 ("query ranks frontend components above the backend module that owns the
concept") and D2 ("processes is dominated by trivial mechanical chains") are
the same defect, and neither is about ranking or selection.

The walk stops after a fixed NUMBER of traces, so traversal order decides which
traces those are. Breadth-first reaches every shallow terminal before any deep
one, so the quota filled with the shortest paths in the graph and the walk
stopped — `maxTraceDepth: 10` was never approached. Measured on a real repo
before the fix: of 300 processes NONE exceeded 7 steps and 90% were 3-4. A
multi-hop business flow (signal → order → exit) therefore had no process that
could represent it, and `query` could only rank the mechanical pairs that did
exist. Step 4 of the caller already sorts by length and dedupes by endpoint —
it was always asking for the deepest traces this walk could give it.

Depth-first descends to a terminal first, so the same quota is spent on paths
worth keeping. Cost is unchanged: same budget, same cycle guard, same depth
ceiling — only the order differs.

Measured on the same 16k-node repo, same build and flags, BFS vs DFS (an
earlier comparison was discarded as confounded — it crossed builds and --pdg):

  steps   6-8:  50 → 168   (3.4x)
  totals:      844 → 806

and the reported query moved from `LiveSetupView → Cn` (a React component) to
`ReconcilePositions → IsTpInProfit / WithHeld / ShouldNotify` — server-side
exit management, which is what was asked for.

`traceFromEntryPoint` is exported for the test. Traversal order is unobservable
through `processProcesses`: `findEntryPoints` supplies several starting points,
so a deep chain is traced from inside it whatever the order does. A test at
that level passes under BOTH traversals — the first version of this test did
exactly that and guarded nothing. Driving the walk directly, it fails under
breadth-first with "expected 3 to be greater than 3".

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

* docs(test): correct a stale status note left behind by a later fix

The A1/A5 header still said "edge resolution REMAINING ... neither is
implemented". Both shapes resolve — the typeable receiver precisely, the
untyped one by workspace-unique name — and the tests below assert exactly that,
so the note contradicted the file it sat on.

It was accurate when written and went stale when the work continued past it.
Left as-is it would tell a reviewer that a landed feature is missing.

The TRAP note is kept: the parse worker still runs built dist under vitest, and
that is still the trap it describes.

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

* feat(scope-resolution): index literals behind identity-preserving wrappers

`export const INERT_EXIT_CONTRACT = Object.freeze({ ... })` minted no
`Property` node for any of its keys. The object-literal rule matches
`variable_declarator > value: (object)` as a DIRECT child, and freezing puts a
call expression in between — so the shape whose fields are most worth querying
was the one shape the rule could not see. Freezing a config object is how JS
publishes an immutable contract, which is why this reads as a confident zero
on exactly the fields a reader cares about.

The allowlist is three functions, not "any call". `Object.freeze`, `seal` and
`preventExtensions` RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes
the literal's keys members of the bound name. For `const x = compute({ a: 1 })`
the literal is an argument and `x` holds compute's return value, so attributing
`a` to `x` would be a fabrication.

Two negative controls, because the obvious one is vacuous: a bare-identifier
callee is rejected structurally and would pass with no allowlist at all, so the
assertion that actually pins the predicate uses `Object.entries` — identical
shape, differing only by name. Verified load-bearing by adding `entries` to the
allowlist and watching that test alone fail.

SCHEMA_BUMP 46 -> 47: parse-time emission, so a warm cache replays the pre-fix
capture set. Observed as a false negative first — `analyze --force` returned
the old node set until the on-disk cache was removed by hand.

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

* fix(scope-resolution): narrow multi-candidate property names by scope

Workspace uniqueness was the wrong denominator. Measured on the reporting
repo: `exitMinAtrMult` has 26 `Property` definitions — 16 in one-off
`scripts/`, 7 in the frontend, one in a test, and exactly ONE in the backend
that reads it. Every backend read was refused because of competitors the
reader cannot see. The gate was not too permissive or too strict, it was
scope-blind.

A name with several definitions is now narrowed before being abandoned:
same-file first, then files the reading file directly imports, using the
finalized import graph rather than a path-shape heuristic. Exactly one
survivor at the first non-empty tier resolves; anything else stays refused.
A tier holding several candidates stops the walk instead of falling through —
local evidence that is itself ambiguous still contradicts reaching further out.

Confidence stays 0.5 at every tier. Narrowing changes which candidate is
chosen, not the kind of claim: it is still a name match, and the round-1
contract is that filtering on confidence drops all name inference at once.
The reason string now names the tier that fired.

Ambiguity reporting goes from a count to the actual names (capped), because a
count says a gap exists while the names say which fields are unanswerable.

Measured on that repo, backend readers of `exitMinAtrMult` go 0 -> 24 and
total readers 9 -> 45, including the two call sites in
`oppositeSignalExitManager.js` the report singled out. Both narrowing tests
were mutation-checked by dropping the import evidence and confirming they, and
only they, fail.

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

* feat(scope-resolution): capture destructured parameter keys as property reads

`function exit({ exitMinAtrMult = 0 })` reads that property off whatever the
caller passes, exactly as `cfg.exitMinAtrMult` would. It never appears in a
member_expression, so it had no reference site at all — and this is the shape
the function that IMPLEMENTS a behaviour uses, so the most relevant reader was
the one systematically missing from "who reads this setting?".

Uses a distinct `@reference.read.destructured` anchor rather than
`@reference.read.member`. The latter is filtered emit-side to matches with a
member_expression ancestor, because calls and writes share its shape, and a
destructuring pattern has none — reusing the tag would have been silently
dropped by that filter. The `read.` head already maps to a read kind, so no
mapping change is needed.

Scoped to formal_parameters. A destructuring binding elsewhere
(`const { x } = require('m')`) is frequently an import rather than a field
read, and minting a property read there would attribute module bindings to
unrelated same-named keys.

All three cases (default value, bare shorthand, renamed key) mutation-checked
by removing the patterns and confirming those three tests, and only those,
fail. The renamed case also asserts the edge points at the KEY and that the
local alias mints nothing.

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

* fix(scope-resolution): link type consumers to the type they name

An exported contract type owned its members after round 1 and still answered
`incoming: {}`, so "what breaks if I remove this field?" — the question a
contract type exists to answer — had no edge to walk. Measured on the
reporting repo: all 324 TypeAlias nodes AND every Interface node had DEFINES
as their only incoming edge.

Two independent causes, and the second is why the first was not enough.

TypeScript captured no type references at all — only cpp and csharp did — so
an annotation naming a declared type minted no reference site. Added for
annotations, generic arguments and `as` assertions, anchored to those contexts
rather than a bare `(type_identifier)`, which would also match the name in
`type X = …` and make every declaration a consumer of itself.

That alone fixed interfaces and left aliases still empty. `TypeAlias` was
missing from `LINKABLE_LABELS`, so alias graph nodes were never indexed in
`nodeLookup` and `resolveDefGraphId` could not bridge a def to its node — the
edge was dropped AFTER a successful lookup. `CLASS_KINDS` has always listed
TypeAlias and the ClassRegistry returned the def correctly, which is what made
this read as a resolution failure; instrumenting the lookup showed it
returning the right def all along and moved the search one table over. Exactly
the bug already documented two entries above it for Trait.

Fixes every language that spells an alias this way — TypeScript, Kotlin, Dart
and Rust all emit `@declaration.type_alias`.

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

* feat(scope-resolution): capture record construction as property writes

The read side answered well after the narrowing work while "who SETS this
field?" still missed the code that stamps the value. A record built inline —
`return { exitContract: { exitMinAtrMult: settings.x } }` — is bound to no
variable, so it minted no definition and its keys referenced nothing.

Modelled as WRITE REFERENCES, deliberately not definitions. The round-1 rule
already mints Property nodes for literals bound to a variable; minting more for
anonymous records would add same-named competitors to the very name-narrowing
that makes these fields resolvable — measured at 26 competing definitions for
one field on the reporting repo, which is what made every backend read
unanswerable in the first place. A construction site is a USE of a field, not
another declaration of it.

Two positions only: nested under a key, and returned. Both are records with a
name attached (the key, or the function). An inline call argument
(`doThing({ id: 1 })`) stays excluded for the same reason round 1 excluded it
from definitions — it is call-site data, not a named surface — and is asserted
as such.

The enclosing literal is the receiver and it is anonymous, so these route
through the same narrowing and the same refusal-to-guess as every other
untyped receiver.

Verified on the reporting repo: `entryPlan.js` went from no rows to
`selectExitEnvelope` as a writer of `exitMinAtrMult`. Both captures
mutation-checked.

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

* feat(processes): select round-robin by terminal so the list is not one flow repeated

Ranking was `sort by length` alone, so the top of the list was one behaviour
described many ways: eleven of the top fourteen processes on the reporting
repo were four entry points crossed with three terminals of the SAME
date-window utility cluster. Genuine call chains, but a reader learns one
thing from fourteen entries, and the repo's own domain flows sat below them.

Selection now round-robins across TERMINALS, deepest first. Depth still orders
within a terminal and still leads the list; what changes is that no terminal
takes a second slot until every other has had a first.

Keying on the entry point was tried first and made it worse — many files
declare a `main`, so each was a distinct entry that round-robin then awarded
its own slot, and `Main -> AlignWindowEnd` went from one row to eight. The
repetition was never in where a flow starts.

Measured on that repo: distinct terminals in the top 20 went 3 -> 20, and its
domain flows (`ReconcilePositions -> ...`) moved into the top 4%.

Two things this deliberately does not claim. The reported cause — ranking
rewarding fan-in, promoting chains ending in widely-called helpers — measured
FALSE: those terminals have one caller each (`alignWindowStart` 1,
`validateSymbol` 1). A fan-in discount was implemented against that hypothesis,
measured, and reverted for moving nothing. And a business flow still cannot be
a process in its own right: the walk only emits at a leaf, at max depth, or on
a cycle, so a flow whose meaningful endpoint calls onward survives only as
whatever leaf it bottoms out in. Both are recorded in the code so neither
reads as settled.

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

* test(structural-pairs): pin the type-annotation USES pair

R2-2 emits USES INTO a `TypeAlias`, so the pair is `Function|TypeAlias` — a
different table from the `TypeAlias|Property` entry added in round 1, and one
that entry stays green without. `TypeAlias` is on the eleven-table list this
suite exists for, and an undeclared pair does not degrade: it throws
`UndeclaredRelationPairError` and kills the entire analyze on any repo
containing an annotated type. Every resolver suite still passes, because they
build an in-memory graph and never write to the DB.

That exact failure shipped once in this PR already. Two emitters into the same
label, each with its own way to reach a released build.

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

* fix(scope-resolution): build the module-level set before the out-of-core seal

Review blocker. Under `GITNEXUS_DISK_SCOPE_INDEX=1` the seal replaces every
ParsedFile with a scope-STRIPPED copy, and the block-local filter's set was
built after it — so it walked `scopes: []` for every file, came out empty, and
the filter read that as "no def is module-level" and dropped EVERY
`Const`/`Variable`/`Static` ACCESSES edge in the repo. All languages, all
files, including the module-scope-const edges this PR exists to add. Nothing
threw and nothing logged, on the path the largest repos take: the exact
confident-empty answer the PR is about.

Built above the seal now, from `parsedFiles`, and passed as `undefined` rather
than an empty set when no scope was inspectable — an empty set is a legitimate
answer ("this repo has no module-level value defs") and must not be
indistinguishable from "could not look". Fails open; the block-local exclusion
is still asserted under the seal, since that is correctness rather than
optimization.

Also widens module level past `kind === 'Module'`. A `Namespace` scope (TS
`namespace`, Rust `mod`, C++/C# `namespace`) holds importable values too, and
treating its consts as function-locals dropped their reads. Included only when
the whole chain to the root is Module/Namespace, so a namespace declared inside
a function body stays local — asserted both ways.

That fixture then failed for a third reason: `@reference.read.identifier`
existed ONLY in the JavaScript query, so A2 did not work for TypeScript at all.
Added there, and both languages widened to `variable_declarator value:` and
`binary_expression` operands — the gaps review named between what A2 claimed
and what it matched.

Nothing covered `GITNEXUS_DISK_SCOPE_INDEX`. The new parity test asserts the
seal changes no edge, and was verified against an emulation of the original
bug: same-file readers vanish and only the cross-file reader survives.

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

* fix(typescript): anchor property_signature to declared shapes

Review blocker, and it reproduces end to end. `property_signature` occurs in
EVERY object_type in the TS grammar, not only in an interface body or an
alias's object type, so inline parameter types, inline return types and nested
object types all matched — and the enclosing-container walk hung each one off
the nearest class, interface or alias. Measured against the unanchored rule,
all four appeared as members of shapes that do not have them:

  Property:contracts.ts:Svc.inlineParamOnlyKey
  Property:contracts.ts:Repo.inlineQueryOnlyKey
  Property:contracts.ts:NestedConfig.nestedOnlyKey
  Property:contracts.ts:buildInline.inlineReturnOnlyKey@46:33

When the inline member shares a name with a real one — `run(opts: { retries:
number })` inside a class that declares `retries` — `addNode` is
first-write-wins and the two distinct symbols merge onto one node, so every
context()/impact()/rename() answer about that field describes the merge. The
sibling JS object-literal rule in this same PR is anchored for exactly this
reason; this is the TypeScript half of the same fix.

`(A (B))` matches DIRECT children, so nested object types are excluded by the
same anchor rather than by a second rule.

The first version of these tests was VACUOUS and is recorded here because the
reason generalizes: a collision and a correct exclusion both leave exactly one
node behind, so counting ids cannot distinguish them. Every inline member in
the fixture is now uniquely named, which is the only thing that discriminates —
verified by restoring the unanchored rule and watching exactly those four
assertions fail. A fifth test asserts anchoring costs no real member.

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

* fix(analyze): correct the numbers feeding the graph-write-collapse guard

Review blocker. The predicate itself held under adversarial probing; every
defect was in what it was handed and what happened after it fired.

(a) `expected` was wrong twice. Under `GraphEmitSink` streaming the bulk types
leave the heap at parse time and never enter `relationshipCount`, so the count
understated the real volume by most of it and the ratio passed trivially —
on `force === true` runs, which include crash recovery AND the
`analyze --force` retry this check's own warning tells the operator to run.
Adds the manifest totals, the same correction the buffer-pool hint in this file
already makes for the same reason. Separately, an incremental run persists only
the changed subgraph while both counts are whole-scope: a 10,000-edge index
that lost 200 replacements reads 9,800 and is certified complete. The check is
skipped on that path rather than answered wrongly.

(b) A throwing edge count became a measured zero. `getLbugStats` initialised
its total to 0 and ran the query in a swallowing catch, so WAL/lock contention
during finalize — documented on this exact call — reported a healthy index as a
total collapse. It now returns `number | undefined`, and the caller requires
both a readable node count and a defined edge count.

(c) A total loss was exempted for being small. The min-edges rule tested
`expected` before looking at `persisted` at all, so `expected = 99,
persisted = 0` — every edge gone — stayed fresh and reported success. Total
loss is now decided first. The existing test asserted the defect; it now
asserts a PARTIAL shortfall, which is the case the exemption was written for.

(d) A detected collapse reported success and exited 0. It is different in kind
from the other incomplete reasons: those describe a run that did what it said
and left work for later, this one means most of your edges are gone and every
query answers a confident empty. The CLI now prints INCOMPLETE with the counts
and sets a non-zero exit code, and the flag crosses IPC so the worker cannot
send a clean `complete` either.

Nothing exercised this wiring — only the pure helper. Adds tests for all four,
each written so the pre-fix arithmetic fails it.

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

* fix(scope-resolution): keep unique-name property inference inside one language

The pass indexed `Property` nodes from the whole shared graph. Per-language
gating decides whether it RUNS for a language; it never restricted which nodes
could be TARGETS. So the only carrier of a name could be in another language
entirely, and a read here resolved to it on name uniqueness alone — no owner,
no file, no call path.

Reproduced: a Java class declaring `private int loyaltyPointsBalance` and a JS
`cfg.loyaltyPointsBalance` on an untyped parameter produced an ACCESSES edge
from the JS function to the Java private field. Confidence does not mitigate
it, because `minConfidence` defaults to 0 — the tier is only a filter for
consumers who ask for one.

Candidates are now restricted to files in the language's own `parsedFiles`,
which is a precise restriction rather than a heuristic and needs no new node
property.

Every other fixture in the suite is single-language, so this could not be
caught anywhere by construction. The new fixture is deliberately polyglot and
asserts both halves: no cross-language edge, and a same-language unique name
still resolves.

Known and not addressed here: the index is still O(total graph nodes) and is
rebuilt once per qualifying language, the per-language whole-graph-scan pattern
`phase.ts` hoisted out for `sharedNodeLookup`. Hoisting it belongs with that
machinery rather than in this fix.

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

* fix(processes): explore siblings in source order, log the exhausted budget

`slice(0, maxBranching)` selected the FIRST N callees while `pop()` explored
them LAST-first, so the trace budget went to the last-declared branch. For
`main() { init(); loadConfig(); run(); shutdown(); }` the walk spends itself on
`shutdown` and can drop `init` — the earliest steps of a flow, which is the
opposite of what a process describes. Selecting first-N and exploring last-first
was simply inconsistent; pushing in reverse makes the stack pop in source order.

Measured on the reporting repo, this costs depth: 6-8 step processes go 168 ->
146 of 816. Still roughly three times the pre-PR baseline of 50, and the right
trade — a deep branch is no longer reached by accident of being declared last.

The remaining limit is the BUDGET, not the traversal: with a fixed quota a deep
branch declared after enough shallow ones is not reached at all. That is now
asserted in both directions rather than left implicit, and the walk logs when it
stops with branches unexplored — a silently truncating cap reads as "this is
everything", the same confident-empty answer this work is about, and the repo
already sets that precedent for `dispatchFanoutSkipped`.

Removes the second depth test, which was vacuous: the note twelve lines above
it already said a `processProcesses`-level depth assertion passes under BOTH
traversals, and measured it does — breadth-first yields the same deepest
stepCount of 8, so it passed with the production change reverted. Traversal
order is asserted against `traceFromEntryPoint` directly; what is observable at
the pipeline level is which traces survive selection, which the diversity tests
cover.

Also renames `queue` to `stack` and corrects the BFS references in the module
docstring and the function's own JSDoc, which is what an IDE hover shows.

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

* fix(impact): carry riskNote onto ambiguous candidates and separate UNKNOWN's two meanings

Two problems on the ambiguous fan-out, which builds its own candidate object
rather than returning the single-symbol shape.

The narrowed type had no `riskNote` field and never read one, so a candidate
that resolved and found no callers reported `risk: UNKNOWN` with nothing
attached — losing the entire point of the change on the path where the reader
has the least context, since the name is ambiguous there by definition.

And `UNKNOWN` used to mean exactly one thing on this path: the probe threw. The
zero-caller branch gives it a second meaning, so an all-UNKNOWN fan-out could
no longer be told apart from a broken one. Candidates now carry `probeFailed`,
and the comment asserting the old reading is corrected.

Also aligns `gitnexus-web`, which review flagged as giving a different verdict
for the same symbol. That surface answers in prose rather than an enum, and its
message said the symbol "appears to be unused (not called by anything)" — the
identical false certainty in words. It now carries the same MEANING rather than
the same field. Downstream wording is unchanged: no outgoing dependencies
really is a fact about the symbol itself.

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

* test: replace assertions that cannot fail

Four from review, each satisfied by the defect it was meant to catch.

`new Set(props).size === 2` over two different literal strings can only ever
be 2, so it could not detect the node merge its title promises — that is a
difference in COUNT, now asserted on the raw array.

The ambiguity test asserted only an empty edge set, which is satisfied equally
by "the gate fired" and "the name was never looked up". It now also requires
the ambiguity counter to have moved.

`Interface|Property` was listed as a structural-pair sentinel beside
`TypeAlias|Property`, but both its labels are in the SCOPE_BRIDGE cross-product
so the pair is generated by construction and the sentinel cannot fail. Dropped
rather than left reading as coverage; `TypeAlias|Property` is the load-bearing
one.

`TypeAlias|Method` was declared in the schema with no fixture emitting it — a
declared pair no emitter exercises is indistinguishable from a missing one
until an analyze aborts on a real repo. Adds a method-shaped alias member, and
the suite requires sentinels to actually appear, so it is not vacuous.

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

* docs: document the new incomplete reason, the UNKNOWN verdict and the id churn

Review found the code changes landed without the guidance around them, and an
agent following this repo's own rules would have been told the wrong thing.

`graph-write-collapsed` joined `INDEX_INCOMPLETE_REASONS` with no Sign block
and no recovery section, while the precedent it cites
(`embedding-checkpoint-pending`) has both — so `gitnexus status` would surface a
new string naming silent wrong answers with nothing explaining trigger or
remedy. Added to RUNBOOK and GUARDRAILS, including why this reason alone also
fails the exit code.

`AGENTS.md` said MUST warn on HIGH or CRITICAL and never mentioned UNKNOWN, and
the shipped impact skill's risk table had no UNKNOWN row and still implied
few-callers ⇒ LOW. An agent obeying those rules literally sees `risk: UNKNOWN`
and proceeds, which negates the change the verdict exists to make. Both copies
of both skills updated.

`MIGRATION.md` now records that process ids do not survive this release —
positional ids plus depth-first tracing, source-order siblings and round-robin
selection mean `proc_7_handle` is a different flow afterwards. Bounded honestly:
nothing in-repo joins on a raw process id, so it is index churn, not a broken
consumer.

`ARCHITECTURE.md`'s scope-resolution stage list gains the two new stages.
The guide skill's node list gains `Property` and `TypeAlias` — the two node
types this work most prominently creates.

Also, on the pair-CSV preflight review asked to confirm: the hard abort IS
deliberate, because a fallback recovering zero rows is the confident-empty
failure this work targets. But the transient the message itself names — a second
concurrent analyze sharing `.gitnexus/csv` — is a race, so the check now
re-looks three times over ~150ms before declaring the file gone. Long enough to
ride out a rename, far too short to mask a file that is genuinely missing.

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

* fix: drop redundant TypeAlias pairs and keep bare identifiers off class members

Two regressions the full suite caught after the review fixes, both real.

`schema-pair-coverage` failed with eleven hand-declared pairs that a rule now
generates. Adding `TypeAlias` to `LINKABLE_LABELS` — needed so
`resolveDefGraphId` can bridge an alias def to its node — also makes it a
SCOPE_BRIDGE source and target, so the cross-product produces `File|TypeAlias`,
`TypeAlias|Property` and nine others that round 1 had declared by hand. Removed;
the invariant is that no pair is both generated and hand-declared.

This also changes what the structural-pair sentinel means, and the comment is
corrected rather than left overstating it: `TypeAlias|Property` is no longer
load-bearing because the label is off the generated grid — it is load-bearing
because it now depends on `TypeAlias` being IN `LINKABLE_LABELS`. Remove it and
the pair stops being generated while the hand declaration is gone, which is the
same state that silently breaks alias consumer edges.

`block-scope-shadowing` failed because a bare identifier resolved to a class
`Property`. `class Box { baseUrl = '...'; pick() { const baseUrl = ...; return
baseUrl; } }` linked the block-local read to `Box.baseUrl`, duplicating the
legitimate `this.baseUrl` edge. A bare identifier is not a member access: with
no receiver there is no object whose property it could be, and in JS/TS a field
read needs `this.`. Receiver-less read/write sites no longer accept `Property`
hits; callables stay reachable, so `cb = save` naming a top-level function is
unaffected.

That defect PREDATES this branch's TypeScript captures — JavaScript has emitted
bare-identifier reads since A2 and no class fixture exercised the shadow. The
TS parity added here is what surfaced it.

Golden snapshot regenerated after verifying the drift line by line: exactly
+5 USES from type annotations in the mini-repo, every pre-existing count
unchanged, so nothing was rewired.

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

* perf(scope-resolution): share the property-name index across language passes

Review follow-up. `indexPropertyNodesByName` scanned every node in the graph
and was rebuilt inside each qualifying language pass, reintroducing exactly the
pattern `phase.ts` hoisted out for `sharedNodeLookup` — whose comment records
why it matters: "the previous per-language rebuild burned that CPU+heap N times
and, on a huge repo, a tiny language's full-graph copy overlapped the next
language's — a real contributor to the scope-resolution memory peak."

Built once in `phase.ts` beside `sharedNodeLookup` and `sharedFnNodeIndex`, and
threaded through the same `prebuilt*` seam, so tests and isolated calls still
build their own.

Sharing is only safe because the per-language restriction MOVED rather than
disappeared: the shared index is whole-graph, and candidates are filtered to
the language's own files at lookup time. That also fixes a subtlety the
per-language build had backwards — the cap now applies to the FILTERED set, so
a name carried by forty properties across a polyglot monorepo but only two in
the language being resolved is still answerable, where a global cap would have
refused it.

The tri-state at the lookup boundary is deliberate and the three outcomes are
not interchangeable: no property of this name in this language (nothing to say,
and NOT an ambiguity), too many to choose between (reportable), or a list to
narrow.

Caught mid-change by the polyglot fixture: an intermediate state shared the
index without moving the filter, and the cross-language edge came straight
back. That test earning its keep twice is the reason it exists.

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

* feat(scope-resolution): report when a field's only anchor is another language

Round 3, found OUT-OF-SAMPLE — six field names appearing in no prior report, so
nothing here was tuned against them. All six answered 0 backend ACCESSES while
their definitions sat in `apps/research-dashboard/**`: TypeScript only. The
in-sample set scored 5/5 and the out-of-sample set 0/6, and the gap is entirely
this.

Per-language inference (`3c5eadc7`) is right and stays. What was wrong is that
declining is INVISIBLE: an empty result for a field anchored only in TypeScript
is byte-identical to an empty result for a field nobody reads. One says "look
in the other language or grep"; the other says "delete it". That is the same
confident-empty failure this series exists to remove, one surface over — and
this time the missing fact is about the ANALYZER's reach rather than the code.

Declines are now counted and named, with the languages the anchors actually
live in, kept SEPARATE from ambiguity because the remedies differ: ambiguity
wants better receiver typing, this wants an anchor in the reading language.
Collapsing them would tell a reader the wrong thing to do. A non-zero count
warns at analyze time regardless of dev mode.

The facts are published as `PipelineResult.propertyInference`, which they had
to be for any of this to be testable — and that exposed a second defect. The
round-2 ambiguity assertion, which I told the reviewer of #2856 I had
strengthened, read its stat off a `scopeResolution` field that does not exist
on PipelineResult: the `if (undefined) return` guard swallowed it and the test
passed with the production code deleted. Both that assertion and the new ones
now read the published field, and the guard is an assertion rather than an
escape. Verified by deleting the counter and watching them fail.

Reported by the same round-3 method note that caught it: verifying a fix
against the cases it was written for only proves those cases pass.

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

* feat(context): explain an empty property result caused by a cross-language anchor

The other half of R3-1. The analyze pass now knows which fields it declined to
link because every definition of the name lives in another language; this puts
that fact where it is actually read.

`context()` on such a field previously returned an incoming list byte-identical
to a genuinely unread field. The two demand opposite actions — "look in the
other language, or grep" versus "delete it" — so the difference has to travel
with the answer:

  unresolved: property reads of this name were NOT linked: every definition of
              it is typescript, and name inference does not cross languages.
              An empty or short incoming list here is not evidence the field is
              unused — confirm with a text search, or give it an anchor in the
              reading language.
  anchorLanguages: ['typescript']

Carried through repo meta because the graph cannot answer it: the unlinked
reads mint no edge and no node, so the only record is the pass that declined
them.

Keyed on the NAME, not on the resolved label. Gating on `=== 'Property'` was
tried first and is wrong — the label reads `''` on this path for a plain
Property node, so the gate silently suppressed the entire feature while every
test still passed. Caught by asserting the field is DEFINED rather than
guarding on it, which is the same anti-pattern that made two earlier
assertions vacuous. The meta list only ever contains property names, so
matching the name is itself the type check.

Cached per (index, indexedAt): `ensureInitialized` deliberately avoids a
per-call `loadMeta` because every tool routes through it, so this re-reads
exactly when a re-analyze could have changed the answer and never otherwise.

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

* feat(scope-resolution): report declined property reads for opt-out languages too

Generalizing R3-1 rather than waiting for it to be re-reported in the other
direction. The reported case was a JavaScript read whose only anchor was
TypeScript; the mirror — a TypeScript read anchored only in JavaScript — was
still silent, because a language that sets `fieldFallbackOnMethodLookup: false`
had the whole pass skipped, and skipping emission also skipped REPORTING.

Detection is not inference. Counting what could not be linked asserts nothing
about what it means, so `reportOnly` runs the pass for its facts while emitting
no edge, and the opt-out keeps protecting exactly what it protected before.

Two things this turned up that a single-instance fix would have missed:

The cross-language fixture could NOT prove `reportOnly` is load-bearing — the
per-language candidate filter already blocks those edges, so the assertion
passed with the flag forced off. The case that discriminates is a SAME-language
TypeScript read that name inference could legitimately link and the opt-out
forbids; forcing the flag off there emits `readsTsOnly -> tsOnlyBudget`, which
is the violation.

Getting to that case surfaced a sibling gap, recorded but NOT fixed here: the
object-literal `Property` rule is JavaScript-only, so `const CONFIG = { ... }`
in a `.ts` file mints no node and its keys are invisible. The first draft of
this fixture used exactly that shape and could not discriminate for that reason.
It is the TypeScript half of R2-1a and wants its own change, not a rider on
this one.

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

* feat(typescript): index object-literal keys, as JavaScript already did

The sibling recorded in `0c5a4f64` and deliberately left out of it. Both the
named object-literal rule (A1/A5) and the identity-wrapper rule (R2-1a) lived
only in JAVASCRIPT_QUERIES, so the single most common config idiom in
TypeScript —

    export const tsRuntimeConfig = { tsConfigRetries: 3 };

— minted no node for any key. `context()` answered "Symbol not found" and a
precise read through the holding variable had nothing to resolve to.

TypeScript sets `fieldFallbackOnMethodLookup: false`, so these gain no
name-based inference. What they gain is the PRECISE path, which is the route
TypeScript is meant to use: `tsRuntimeConfig.tsConfigRetries` has a typeable
receiver and now resolves. A read through an untyped receiver stays unresolved
and, since `0c5a4f64`, is reported as such rather than answering an empty set.

Scoped exactly as the JavaScript rules are — bound to a variable, and for the
wrapper only the three functions that return the argument they were given —
with the same `Object.entries` negative control pinning the allowlist.

Found by fixture, not by report: the first draft of the `reportOnly` test used
a TS `const CONFIG = { ... }` as its discriminator and could not discriminate,
because the shape mints nothing. That is the whole argument for sweeping a
class instead of waiting for each instance to be filed.

SCHEMA_BUMP 47 -> 48: parse-time, so a warm cache replays ParsedFiles carrying
none of these matches and the keys stay invisible.

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

* feat(scope-resolution): anchor anonymous returned object literals to their function

The last gap round 3 named, and the dominant shape in idiomatic JS: 437
`return {` sites in a single backend directory of the reporting repo, including
the ~25-field payload of its entire signal pipeline. The literal binds to
nothing, so its keys could not even be named — "who reads wickRatio?" had no
symbol to ask about.

The enclosing FUNCTION is the owner: the literal is that function's return
shape, a contract its callers consume. Keys qualify as `<function>.<key>`, so
two functions returning the same name stay two shapes rather than one merged
symbol, and multiple returns in one function stay distinct by position.

RECONCILING THIS WITH R2-1b, which deliberately modelled returned keys as WRITES
to avoid adding same-named competitors to narrowing. These are definitions, but
narrowing now ranks DECLARED anchors — named literals, class fields, interface
and alias members — strictly above return shapes. A name that already resolved
keeps resolving to what it resolved to before, so the competitor problem R2-1b
was avoiding cannot come back. Mutation-checked: dropping that ranking breaks
five pre-existing R2 resolutions.

That also required an R2-1b assertion to change, and the change is a
strengthening rather than a concession. It asserted `toHaveLength(1)` — no new
definition — as a proxy for "adding definitions must not move an existing
answer". The proxy is now false while the property still holds, so the property
itself is asserted directly.

No `HAS_PROPERTY` edge from the function: that would be a `Function|Property`
relation pair the schema does not declare, and an undeclared pair does not
degrade — it throws and kills the whole analyze. That already shipped once in
this PR.

Two things found by dumping rather than assuming, both fixed here:

SHORTHAND keys were not matched at all. `return { symbol, interval, score }` is
the commonest spelling and the reporting repo's own payload is mostly this form,
but tree-sitter models it as `shorthand_property_identifier`, which `(pair)`
does not match. Caught by dumping the golden fixture and seeing a literal
returning `{ level, message, timestamp: Date.now() }` had indexed only
`timestamp`. Now covered in return position AND in the variable-bound rule,
which had the same gap.

Provenance was flagged by owner-presence, which mislabelled the anonymous case:
a callback's return shape yields no name to qualify by, so it looked like a
DECLARED anchor and would have outranked real declarations. Flagged by position
now — a different question from whether a name could be derived.

SCHEMA_BUMP 48 -> 49. Within one PR the version only has to differ from main's,
but a build stamped 48 was installed and used to analyze before these captures
existed, so caches stamped 48 carry none of them — the intermediate-build hazard
this ledger already records for 33/34.

Golden regenerated after verifying the drift: exactly +10 Property and +10
DEFINES, every pre-existing count unchanged.

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

* fix(scope-resolution): rank production anchors above test fixtures

Found by testing R3-4 on the reporting repo instead of on its fixtures. Anchoring
returned literals took `wickRatio` from 6 definitions to 13 — and backend reads
still resolved to nothing, because SEVEN of the new JavaScript anchors compete
and four of them are in `tests/`. A test constructs throwaway shapes carrying
production field names; a read in shipped code cannot mean one of them.

Applied before the declared/return-shape split, because "is this the shipped
program" is the stronger signal — a declaration inside a test fixture is still a
test fixture. Skipped when the READER is itself a test, since a read there
legitimately means the test's own shape.

The first version of this test was vacuous and the mutation check caught it: the
reader sat in the same file as the production anchor, so the same-file tier
resolved it whether or not this tier existed. The reader now lives in a file
that imports neither anchor, which leaves production-vs-test as the only thing
that can decide.

Honest about what this does NOT do: it narrows `wickRatio` from seven candidates
to three, and three functions in different files each returning that field is
GENUINELY ambiguous — refusing is correct, and the ambiguity is now counted and
named rather than silent. The reported question ("who reads wickRatio?") is
answerable only where one producer exists; where several do, the honest answer
is the list of producers, which R3-4 made nameable for the first time.

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

* feat(scope-resolution): resolve members through a call result's return shape

The question three rounds of reports could not answer, and the one narrowing
must refuse by design: a field produced by SEVERAL functions. A read of
`spike.wickRatio` could mean any producer, so name inference correctly declines
and no amount of tier-tuning changes that. It needs evidence, not inference.

The evidence existed in two halves that had never been joined. The call-result
type binding (`const alert = formatSpikeAlert(row)` binds `alert` to a TypeRef
whose rawName is the callee) predates all of this work; it simply had nothing to
resolve to when the callee returned an anonymous literal, because an anonymous
literal named nothing. R3-4 gave it a name. Joining them:

    const alert = formatSpikeAlert(row);
    alert.wickRatio   ->   Property:...:formatSpikeAlert.wickRatio

Precise, at ordinary emission confidence, and it works EXACTLY where narrowing
cannot: several producers sharing a field name stop being competitors because
the receiver says which one. Runs before the name fallback and claims its sites,
so a precise answer is never second-guessed by a name match.

Measured on the reporting repo: 1,410 precise edges, and all six fields round 3
verified OUT-OF-SAMPLE go from 0 backend readers to 7, 11, 10, 7, 6 and 14.
Round 3 scored 0/6 on that set; this is 6/6.

The bound is asserted, not just documented: a read off a BARE PARAMETER has no
binding here, because typing it needs the caller's type to flow in — that is
inter-procedural and genuinely larger. Those reads still fall through to name
inference and are still reported when it declines. The fixture has two producers
sharing a field name precisely so the test cannot pass by name matching, and
mutation-checking the owner lookup fails it.

No SCHEMA_BUMP: this is scope resolution, not parse-time capture, so a warm
cache already carries everything it reads. Noted in the ledger because the
reflex on this branch has been to bump, and an unnecessary bump costs every user
a full re-parse.

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

* Revert "return-shape anchoring" (R3-4/R3-5): it degrades query

Reverts af5eec5c, c764847a and 4f93f32e. The capability was real and measured —
all six fields round 3 verified OUT-OF-SAMPLE went from 0 backend readers to
7/11/10/7/6/14, 0/6 to 6/6, via 1,410 precise return-shape edges. It is reverted
anyway, because it costs more than it buys in its current form.

`cli-limit-e2e` caught it. Bisected to af5eec5c: on the mini-repo fixture,
`query('message')` returned two processes before and NONE after. The mechanism
is not window displacement — that hypothesis was tested with a partition that
kept function-local property keys from taking window slots, and it changed
nothing. Indexing the keys of every returned literal adds many nodes whose names
are ordinary words, which moves the BM25 CORPUS statistics: "message" gets less
discriminating, and `createLogEntry` — the callable that actually carries the
processes — stops ranking at all. A corpus-level effect is not repairable by a
tie-break.

Trading a regression in `query`, one of the core tools, for coverage in
`context` is the wrong trade, and shipping it because the number was good would
be the same mistake this PR spent three rounds removing: a confident answer that
is worse than the honest one.

What the work established, and what re-landing needs:

  - The mechanism is right. Joining the existing call-result type binding to a
    named return shape resolves `alert.wickRatio` by EVIDENCE, which is why it
    succeeds exactly where name inference must refuse.
  - The cost is search dilution, and it needs to be measured on BM25 ranking
    BEFORE the capture lands — not discovered by a downstream e2e test.
  - The likely shape of the fix is keeping return-shape keys out of the text
    search corpus while keeping them in the graph, which needs persisted
    provenance rather than the in-memory flag used here.

Kept: everything through 8972d223, which is verified green.

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

* feat(search): give the index a notion of DETAIL symbols, and re-land R3-4/R3-5

Reverts the revert. The return-shape work was correct and measured — 1,410
precise edges, and all six fields round 3 verified out-of-sample going 0/6 to
6/6 — and it was dropped for a regression that was really a MISSING LAYER: the
search index had no way to say "this symbol is queryable but is not a concept a
text search should surface on its own".

Indexing the keys of anonymous returned literals adds many nodes whose names are
ordinary words (`message`, `value`, `timestamp`). Without that notion they
compete on equal terms in FTS, push the CALLABLES named after the same concept
past the search's row cap, and `query('message')` returned two processes before
and none after.

The layer, rather than a workaround:

  - `Property.isDetail`, persisted. A Property-only column, which that table
    already precedents with `declaredType`, set where the key is minted.
  - `buildFtsQueryCypher` filters on it for the Property table, BEFORE the row
    cap. That placement is the whole point: rows crowded out never reach the
    caller, so no downstream re-ranking can recover them. Two downstream fixes
    were tried first — a tie-break and a partition of the merge window — and
    recovered nothing, which is what located the real seam.
  - `IS NULL`-tolerant, so an index written before the column existed still
    answers instead of returning nothing.

Verified by the A/B that found the regression: the query's result order is now
byte-identical to the pre-R3-4 baseline —
`proc_0_processrequest, proc_2_errormiddleware, Function:createLogEntry,
Property:LogEntry.message` — with the return-shape coverage retained.

The determinism guard then caught prose in the new DDL comment containing the
token this repo scans for, which would have read as an unordered query. Reworded;
that suite is doing exactly its job.

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

* feat(processes): let a flow end where the program reaches outward

The item three rounds kept circling. A trace was only emitted at a node with NO
outgoing calls, so a real flow — scan, score, arm, PLACE THE ORDER — is always a
PREFIX of some longer chain that runs on into date helpers, and could never be a
process in its own right. Ranking could not fix that; the flow was never a
candidate to rank.

What blocked it was signal granularity, and the fix is the layer that was
missing rather than a heuristic. GitNexus already knew where the program reaches
outward: the parse phase collects fetch calls and ORM queries carrying
`filePath` + `lineNumber`. Those facts only ever produced FILE-level edges
(`File -[FETCHES]-> Route`), which cannot end a trace — every function in a file
containing one would qualify. Attributing each site to the function whose range
CONTAINS it turns the same facts into the function-level signal the walk needs:
no new extraction, no new relation pair, no schema change. Innermost wins, so a
closure that performs the call is the sink rather than the function spanning it.

Three touch points, and the second is the one that makes or breaks it:

  - the walk emits at a sink AND CONTINUES, so `placeOrder` is an endpoint while
    `placeOrder -> formatDate` still exists separately;
  - subset-removal PRESERVES sink-terminated traces. A sink flow is by
    definition a prefix of the chain that runs past it, so emitting one at the
    walk and deleting it one step later would have been a no-op. Mutation-
    checked: removing this preservation fails all three sink tests, including
    the one asserting the sink is reached at all;
  - selection ranks sink-terminated above leaf-terminated, then by depth.

`processes` now declares `parse` as a dependency. It historically avoided that
on the grounds the dependency was spurious for a progress counter — it is no
longer spurious, so it is declared rather than reached for implicitly, and the
read fails open so a pipeline without that output detects no sinks instead of
losing every process.

Bounded honestly: this fires where fetch/ORM extraction fires. On the reporting
repo it will do nothing until route detection handles hand-rolled dispatchers,
since that codebase routes with `pathname === '/api/...'` on raw node:http and
produces zero Route nodes — a separate gap, and the next one worth closing.

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

* docs(processes): the comment above the sink ranking still described it as unreachable

R3-6 taught the walk what a sink is, but the block explaining the ranking still
carried the paragraph written when that was out of reach — "a business flow
still cannot be a process in its own right ... fixing that means teaching the
walk what a sink is" — sitting directly above the code that does exactly that.
A reader arriving at `rankedByInterest` would take the limitation as current.

The measured-false fan-in finding stays; it is still true and still worth not
re-deriving. What replaces the stale half is the bound that IS current: sinks
fire where fetch/ORM extraction fires, so a codebase whose outward calls are not
detected as such still sees leaf-terminated traces only.

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

* feat(routes): read a route that is declared by a comparison, not by a framework

`route_map` on the reporting repo returned

    {"routes": [], "total": 0, "message": "No routes found in this project."}

for a codebase with SEVENTEEN route modules, an `apiRouteTable.js`, and 113
path comparisons. Not a partial answer — a statement about the code, and a
false one. Same confident-empty class as the rest of this branch, except here
it takes out a whole tool.

Four route-discovery paths existed — filesystem convention, single-file
framework route, cross-file framework route, decorator — and every one of them
needs a FRAMEWORK to declare the route. A raw `node:http` server declares it
the only way the language offers:

    if (req.method === 'GET' && pathname === '/api/live/portfolio') { … }

A path, a verb, and a handler. Nothing in the pipeline could read it.

The failure modes are not symmetric, so the rules are weighted accordingly: a
route this misses is a coverage limit, a route it invents is `route_map`
asserting something false. A comparison therefore qualifies only against a
demonstrable request path (`pathname`, `*.pathname`, `req.url`; `path` is
excluded — in Node it is overwhelmingly `node:path` or a file location), and
anything untranslatable is dropped rather than approximated:

  - `pathname.startsWith('/api/')` is a namespace test; minting `/api` would
    claim a route nobody serves;
  - a bare `pathname === '/'` with no verb is more often the static-file
    normalisation branch (`pathname === '/' ? '/index.html' : pathname`) than a
    route — WITH a verb the intent is unambiguous, so that form IS taken;
  - an anchored regex converts only when its body is a literal path plus
    single-segment wildcards, so `/^\/api\/research-runs\/[^/]+$/` becomes
    `/api/research-runs/{param1}` while an optional group or an alternation
    bails.

Three things went in that nobody reported, each found by measuring rather than
by a second report.

`switch (pathname) { case '/api/x': }` is the same dispatch in different
syntax, and waiting for a bug report per shape is how a graph stays permanently
one idiom behind the code it indexes.

The reconciliation had to move up a level. The reporting repo keeps its path
table (`isKnownApiPath`) in one module and its handlers in sixteen others, so a
per-file rule sees each half separately and lists every route twice — once
verb-less with the table as its "handler", once properly. Measured: 22 of the
first 94 routes were that shadow. Only the whole registry can tell them apart,
so the rule lives in the routes phase and touches dispatch-guard routes only —
a framework route without a verb is method-agnostic BY DECLARATION (a Django
function view, a Laravel resource), a fact rather than a weaker observation.

And a path composed from a constant needed folding. One of those seventeen
modules writes every one of its routes as `` `${autoTradeBasePath}/rules` ``,
where the base is an alias of a module-level literal. Refusing that lost the
whole file — and lost it INVISIBLY, since a module with unfoldable paths and a
module with no routes are the same empty answer. Same-file only, literals only,
one alias hop, and it refuses on ambiguity: a name declared twice with
different values is dropped rather than guessed, because a partially-folded
path is a wrong route and a wrong route is the failure this module exists to
avoid.

Wiring is a LanguageProvider hook, not a language check in shared code.
`extractDecoratorRoutes` was already the general "route from this file's own
AST" channel rather than a decorator-only one — express routes have flowed
through it as `decorator-express.get` for a while — so the transport, the
`(method, url)` dedup and the handler-symbol resolution all apply unchanged.
`ExtractedDecoratorRoute.source` carries the one thing that genuinely differs:
a decorator route is DECLARED, a dispatch-guard route is INFERRED. The walk is
gated behind a substring pre-filter so it costs nothing on files that cannot
produce a route, and the gate is sound by construction — every rule reaches a
route only through `isPathExpression`, which needs one of exactly those tokens.

SCHEMA_BUMP 49 -> 51, two entries. Decorator routes are worker output carried
in the parse cache, so a warm cache replays results predating the extractor and
`route_map` stays empty — the symptom this fixes, wearing the mask of "the
extractor does not work". The second bump is the v34 hazard tripping again: a
build stamped 50 had already been used to analyze before folding existed, so
caches stamped 50 carry the unfolded route set. Caught by measuring — the
post-folding run came back suspiciously fast and would have reported the
pre-folding number.

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

* fix(scope-resolution): ask whether a value def is FUNCTION-LOCAL, not whether it is module-level

The locality filter for value references was written as an ALLOWLIST of
module-scope defs, and that shape cannot express a class member. A value def has
three homes, not two: module level, a function body, and a CLASS body. Java and
C# fields and Python class attributes live in the third, so an allowlist keyed on
"module level" excludes every one of them by construction.

The guard written to make that safe could not fire either. The set arms whenever
a Module scope is FOUND, and Java has module scopes while having no module-level
values at all — so for Java it armed permanently empty, which is exactly the
state the guard exists to distinguish from "there genuinely are none".

Inverting it removes the class. A blocklist of defs positively identified as
function-local fails safe: a Java field, a Python class attribute, or a language
whose scopes could not be inspected is emitted rather than dropped. That also
retires the arming flag — an empty blocklist and an uninspected one mean the same
thing, and both mean "emit". The failure mode moves from "silently deletes an
edge class" to "retains an inert local", which is the right direction for a tool
whose stated principle is that a confident empty answer is the worst outcome.

MEASURED, because the review that prompted this reported it as a P0 deleting
every Java/C#/Python field ACCESSES edge, and that half does not reproduce.
Instrumenting the bridge over `java-write-access` shows ZERO value-ACCESSES
candidates reaching the filter: Java field references resolve to a `Property`
target and `isValueDefinitionLabel` covers only Const/Static/Variable, so the
filter is never consulted there. Pipeline-level edge sets are byte-identical with
the filter forced on and forced off, across four shapes — Java cross-file field
writes, Java cross-file constant reads, Java bare same-class constant reads, and
a Python module-constant/class-attribute mix. The defect is real and latent; the
blast radius is not. Fixed anyway, because the predicate asks the wrong question
and the next change that makes the bridge the sole emitter would ship the
deletion for real.

New `value-ref-locality.test.ts` pins the invariant triple — local dropped,
module-scope kept, class member kept — by TARGET rather than by `reason`. The
per-language suites filter on `rel.reason === 'read'|'write'` while the bridge
stamps `scope-resolution: read|write`, so they are blind to bridge-side change in
both directions. The file states plainly which half gates the mechanism (JS,
mutation-verified) and which gates only the outcome (Java, because the mechanism
is unreachable there), so it cannot be mistaken for a stronger gate than it is.

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

* fix(docs): restore the agent guidance a generated-block refresh deleted

Commit 8f8261021's message is entirely about cross-language anchor reporting; it
also regenerated the `gitnexus:start` block in AGENTS.md and CLAUDE.md against a
LOCAL, non-PDG index and swept six documentation/config files along with it. The
review caught this and it is correct. Restored:

  - the index stats, which regressed 248612 symbols / 565510 relationships /
    918 flows -> 29969 / 118986 / 762 — my machine's index described as the
    project's;
  - the whole `pdg_query` bullet and the PDG half of the impact bullet, while
    both capabilities remain live in `mcp/tools.ts` and `local-backend.ts`;
  - the "Inline staleness signal" section in the guide skill, content that never
    left `origin/main` and that this branch had no reason to touch;
  - `.mcp.json`, which had moved from `npx -y gitnexus@latest mcp` to a bare
    `gitnexus` — a fresh clone with no global install gets a dead MCP server.

The worst of it is self-inflicted in a specific way worth naming: commit
411cac9b9, four hours earlier on this same branch, ADDED the instruction telling
agents not to read `risk: UNKNOWN` as an all-clear. The refresh deleted it. So
the branch shipped a new UNKNOWN verdict and simultaneously removed the guidance
for reading it — the exact false-safe this PR exists to remove, reintroduced one
layer up in the docs.

Re-applied that guidance, and found the drift is wider than reported. The review
noted the `.claude/` copy contradicting the plugin mirror; in fact the UNKNOWN
block was present in ONE of five shipped distributions. `gitnexus/skills/` (the
npm package), `gitnexus-cursor-integration/`, and `.agents/` were missing it too,
so every non-Claude consumer of this skill had the old table.

`shipped-skills-sync.test.ts` passed 54/54 through all of that. Its byte-identical
check covers only the plan/work/review/lfg family, and the standard skills are
guarded solely by per-skill fragment lists — so a fragment nobody listed is a
fragment nothing protects. Added the UNKNOWN fragments to that list, plus a
`copies.length > 1` assertion so an empty copy list cannot make the loop vacuous.
Verified it fails against the pre-fix tree.

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

* fix(scope-resolution): require the return-shape producer to RESOLVE, not merely to name-match

Review finding 2, reached independently by three Claude lanes and two Codex
legs, and reproduced here. `emitReturnShapeMemberAccesses` took the receiver's
type binding, then filtered a WHOLE-GRAPH property index with `idNamesMember` —
a textual match on the node id. Any node whose id happened to read
`<producer>.<member>` qualified, in any file and any language, and it emitted at
the 0.9 PRECISE tier where a `minConfidence` floor cannot filter it out. The
sibling unique-name pass was given a per-language restriction for exactly this
hazard; this pass consumed the same shared index with none.

Three guards, catching different shapes:

  - the producer must RESOLVE to a definition (`findCallableBindingInScope` — a
    CALLABLE lookup: the producer is the function whose return shape owns the
    member, and it resolves through finalized import bindings so a producer in
    another file still yields its own file);
  - the member must live in that definition's file;
  - that file must belong to the language being resolved.

The third is not redundant with the second, which is the part worth recording.
A receiver typed by CONSTRUCTION (`const bound = new Loyalty()`) resolves through
the shared class registry, which is polyglot — so the producer resolves into
`Loyalty.java`, its members legitimately live in that same file, and file
equality waves the cross-language edge straight through.

Also fixes the sibling P2: a site where the receiver IS typed to a producer that
owns no such member now claims the site. That branch is the strongest negative
evidence the pipeline can produce, and letting it fall through meant the 0.5 name
fallback answered a question the precise pass had just DISPROVED — measured,
linking a read to an unrelated same-named key in another file.

`polyglot-property-isolation` gains the bound-receiver arm the review asked for,
and it is the right arm: the pre-existing case has an untyped receiver and so
only ever exercised the unique-name pass, while one extra token routes an
identical read through this one. Mutation-verified — restoring the pre-fix
matching makes exactly the new leak assertion fail. The first version of that arm
was silently vacuous (it introduced a JS key of the same name, which destroyed
the fixture's Java-only premise), which is why it now asserts on the TARGET FILE
rather than on the absence of a name.

KNOWN LIMIT, stated rather than papered over: a member-call producer
(`const r = svc.make()`) binds `svc.make`, which resolves to no callable, so this
pass now declines it. Codex B3 raised that converse case and it is real. Fixing
it means typing `svc` and then finding `make` on that type — a larger piece of
work, queued for the follow-up PR. Declining is the correct interim behaviour:
the alternative is matching `make.<member>` by name across the graph, which is
the fabrication this commit removes.

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

* fix(scope-resolution): resolve the import map by point lookup so the seal cannot empty it

Review finding 4, reproduced end-to-end by two lanes: the same commit and the
same repo produced a DIFFERENT graph depending on `GITNEXUS_DISK_SCOPE_INDEX`.

`buildDirectImportMap` built `scopeToFile` by walking `parsed.scopes`. The
out-of-core seal replaces `emitParsedFiles` with a scope-STRIPPED copy — that is
its documented contract, scopes are reachable only via `scopeTree.getScope`
afterwards — so under the seal the map came out empty, every `directImports`
lookup returned undefined, and tier-2 narrowing died repo-wide.

The reporting is the worse half. The loss surfaced as `ambiguous`, which means
"several candidates and the pass refused to choose". The truth was "the evidence
was discarded one function earlier". A reader acting on that would go looking for
better receiver typing to fix a problem that was not there.

This is the SECOND consumer of `parsed.scopes` on this branch to hit the seal.
The first was hoisted above it. This one is converted to the point lookup
instead, which is the stronger fix: a point lookup survives the seal by contract,
so there is no ordering left for a future edit to get wrong.

The parity assertion that would have caught it now exists. The sealed harness in
`javascript-const-references` already ran the fixture both ways, but every
assertion in it pinned ONE field's readers — which is exactly how a second
instance slipped in, since no assertion happened to cover a narrowed name. It now
also compares the WHOLE ACCESSES edge set between the two runs, as a sorted diff
so a failure names the edges that moved, with a non-empty guard so two empty sets
cannot compare equal and assert nothing. Mutation-verified: forcing the map empty
fails it.

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

* fix(scope-resolution): bind a producer's own returned key to itself, and stop claiming uniqueness for a ranked answer

Review finding 3, accepting the two defects it demonstrates and declining the
remedy it proposes. Both halves are mutation-verified.

1. A SITE INSIDE ITS OWN RETURN SHAPE NOW BINDS TO ITS OWN KEY.

   `export function buildB(row) { return { tickIntervalMs: row.b } }` writes the
   key that IS `buildB.tickIntervalMs`. Ranking declared anchors above return
   shapes is correct for a READ through a receiver, but applied to this site it
   handed the write to a same-named module const that `buildB` never touches —
   a wrong edge — while the node the key actually defines was left with no
   writer at all. Both halves wrong from one rule applied to the wrong shape.

   Checked before every other rule, because it is evidence rather than ranking:
   the owner qualifier on the candidate id and the enclosing callable are the
   same symbol. Nothing outranks that.

2. THE TIER NO LONGER LIES.

   `workspace-unique` is a claim that exactly one node in the workspace carries
   the name — a fact about the graph, and the label a reader trusts most. An
   answer reached by FILTERING (tests down-ranked, return shapes down-ranked)
   is a weaker claim, and it was reported under the same label. The edge is
   unchanged; what it is allowed to say about itself is not. `narrowed` now
   counts these correctly too, since it keys off the tier.

WHAT I AM NOT DOING, and why. The review proposes dropping the same-file and
imported-file tiers "and keeping only genuine workspace-uniqueness". That would
revert the measured R2 result taking backend readers of `exitMinAtrMult` from
0 to 24. Workspace uniqueness was already measured too strict on that repo: the
field carries 26 Property definitions — 16 in one-off scripts, 7 in the
frontend, one in a test, and exactly one in the backend that reads it. Strict
uniqueness declines all 24.

The alternative suggestion — require the receiver to bind to the owning object —
has the same effect by another route: the population this pass exists for is the
untyped option bag, whose receiver binds to nothing. Requiring a binding turns
the pass off for its own use case. So the two demonstrated defects are fixed and
the capability around them is kept, at half confidence, naming its inference in
the reason string, and honoured only where `fieldFallbackOnMethodLookup` allows.

The R3-5 precision test needed rescoping rather than relaxing: it asserted that
EVERY edge to the contested field is a precise return-shape edge, which the
producer's own (correct, name-tier) write now violates. It asserts the reader
edges are precise and the producer's write binds to its own key — two different
claims reached two different ways, which is what the code now models.

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

* test(bench): re-baseline the JS/TS scope-capture fingerprints for this branch's capture additions

The `Cross-language scope-capture fingerprint + scaling guards` CI step was
failing on TypeScript and JavaScript, and it had been failing for the whole PR —
the branch changed both SCOPE queries without ever updating the guard's
baseline. It only surfaced now because a merge conflict had prevented CI from
running at all, so nothing reported it.

Re-baselined per the file's own instruction ("re-baseline intentionally on a
legitimate capture change"), and verified first rather than rubber-stamped. The
capture-name sets in both scope queries, diffed against `origin/main`:

  TypeScript  + @reference.read.identifier      (A2, bare-identifier reads)
              + @reference.type                 (R2-2, type references)
  JavaScript  + @reference.read.identifier      (A2)
              + @reference.read.destructured    (R2-1c)
              + @reference.write.property-key   (R2-1b)

Nothing removed on either side. A pure superset is the check that no EXISTING
capture moved — which is the failure mode a fingerprint guard exists to catch,
and the reason to look before regenerating.

Consistent everywhere else too: `capture_groups_small`/`_large` are unchanged
(4503/14403) because those measure the SYNTHETIC scaling source this branch does
not touch, so only the fixture-corpus number moves — 2097 -> 2338 across 21 new
lang-resolution fixtures, 146 -> 151 files. Scaling stayed linear and inside
budget (typescript 1.116, javascript 1.010, both < 1.5), so the added rules cost
no super-linear time. Prior and new hashes are recorded in the baseline note, as
every previous entry in that file does.

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

* test(bench): re-baseline the receiver-resolution drop guard for the new WRITE site kind

Second of the two bench guards that had been failing for the whole PR without
anyone seeing it — CI could not run while the branch was conflicted, so both
went unreported until the merge cleared.

The drift is a new site KIND, not a movement in an existing one:

    totalDropsAllKinds  129 -> 140
    bySiteKind          {call: 102, read: 27}
                     -> {call: 102, read: 27, write: 11}

`call` and `read` are byte-identical, which is the check that matters. This
branch added write-site captures the corpus never had — `@reference.write.
property-key` (R2-1b record construction) and the destructured-read rules — so
write sites reach receiver resolution for the first time, and 11 of them have a
receiver that does not resolve. A drop is the honest outcome for those; the
alternative is the name-inferred guess this series spent three rounds bounding.

Verified it is NOT caused by this session's review fixes before re-baselining:
removing the `memberNotOnShape` site-claim added in 69047086 and re-running gives
the identical 129 -> 140 / write: 11 drift, so the movement predates today and
belongs to the capture work, exactly as the arithmetic above says.

The sibling `scope-emission` guard still PASSES untouched, and the fingerprint
guard passes after 20a937f4 — so all three arms of the benchmarks job are green
locally.

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

* fix(routes): track boolean polarity in dispatch guards, so a negated condition cannot invent a route

Reproduced exactly as reported. `dispatch-guard.ts` refuses to inherit a verb
from an `if` whose `else` branch holds the comparison — the module's own doc
comment explains why: that branch runs precisely when the condition did NOT
hold, so attributing it is backwards. `!` is the same fact written as an
operator, and it was not handled. A stated invariant with half an
implementation, which is worse than an absent one, because the comment reads as
though it were covered.

Measured against the real extractor before fixing:

    if (!(pathname === '/api/admin'))                  ->  '' /api/admin   INVENTED
    if (!(req.method === 'GET') && pathname === '/x')  ->  GET /x          INVERTED
    if (!(req.method === 'POST' && pathname === '/w')) ->  POST /w         BOTH

And the review is right that this is not additive-only. Driven through the real
pipeline with a policy module that serves nothing plus a one-line route table,
the invented `GET /api/report` collected into `verbedUrls` and
`reconcileDispatchGuardRoutes` then EVICTED the true verb-less route for that
path. A false route deleted a real one. After the fix that repo yields exactly
one route, verb-less, path intact.

Parity, not presence: `!!x` is `x`, so counting negations and testing the parity
is the only rule that keeps a doubly-negated guard working. A negated VERB drops
to verb-less rather than dropping the route — `!(method === 'GET')` means every
method except GET, which no single value expresses, while the path evidence is
untouched. Applies to the regex arm too; `!/^\/api\/x$/.test(pathname)` had the
identical hole.

Deliberately NOT keeping the `statement_block` break from the suggested patch.
It is unreachable — the `!` in `if (!cond) { … }` lives in the condition, a
SIBLING of the block, never an ancestor of anything inside it, and the only
shape that puts a `!` above a block is an IIFE, which the function-boundary stop
catches first. Unreachable in the UNSAFE direction, too: breaking early
under-counts negations, and an under-count reads a negated guard as positive and
invents the route. Verified by mutation — with the break present, deleting it
fails nothing; the other three guards each fail a test when removed.

Six new cases, all previously absent (`grep -c '!(' ` over both test files was 0,
and the only negation covered was `!==`, the form that already worked).

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

* test(bench): re-baseline the emit-persistence byte-identity fingerprint for the isDetail column

The third bench guard this branch left red, and the one the earlier
rebaseline pass missed: the `benchmarks (GITNEXUS_BENCH)` job has never
succeeded once in eleven attempts, and since step 11 aborts the job, the
two steps after it — the streaming PDG-emit guard and the cross-language
pipeline benchmarks — have never executed at all.

    [emit-persistence --check] FAIL: byte-identity fingerprint drift
      (got 4ee15e74…, expected 69e9182a…)

Cause is this branch's own `isDetail` BOOLEAN on the Property table
(PROPERTY_SCHEMA), which `streamAllCSVsToDisk` writes as one more header
field and one more cell per Property row.

Verified header-only rather than regenerated on faith. Dumping every CSV
the bench emits on both `origin/main` and this branch and diffing them
per file (name, byte length, sha256): the file set is identical at 35
CSVs, 34 of the 35 are byte-identical, and the sole difference is
property.csv growing 68 -> 77 bytes as the header gains `,isDetail`. The
synthetic graph mints no Property nodes, so not one data row moved —
which is the thing this fingerprint exists to catch. Both timing gates
were green throughout (scaling_ratio 0.783 against a 1.8 budget,
elapsed_ms_large 229ms against the 1000ms backstop), so no throughput
claim is being rebaselined away.

Justification recorded in a `_rebaselined_<reason>` key, the convention
bench/scope-capture/baselines.json already sets, and the note now says so
explicitly so the next regeneration records its reasoning too.

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

* perf(processes): build each trace key once, not once per comparison

`deduplicateTraces` held its `join('->')` inside the `some()` callback, so
every already-kept trace had its key rebuilt from scratch against every
candidate: O(T*U) joins of O(depth * id-length) characters. The
allocation, not the substring scan, is what the pass spends its time on.

Nothing about breadth-first search made that safe. It only hid the cost by
keeping traces short — measured on this repo the walk averaged 4.3 steps
before D1 and 9.4 after, which roughly doubles both the number of
surviving traces and the length of every key, so the same quadratic that
was affordable under BFS is about six times the work under DFS. That is
the whole of the slowdown D1 was carrying; the depth-first walk itself is
cheaper than the queue it replaced (`pop()` against an O(frontier)
`shift()`), and its frontier is bounded by depth rather than by breadth.

Hoisting the join into a `uniqueKeys` array removes the multiplication.
Measured back to back on one host, 5 reps, 25k callables, production sink
path (main -> this branch before -> this branch after):

    deep_chain      876.8ms -> 1233.1ms -> 101.9ms
    mixed_cycles    731.4ms -> 1130.6ms -> 132.8ms
    shallow_wide    572.5ms ->  531.8ms ->  49.6ms

and on the real gitnexus/src corpus (11,490 symbols) process detection
goes 204ms -> 89ms against main, having been slower than main before.

Output is unchanged, which is the property that matters here: swapping the
file back and forth and diffing every non-timing field across all sixteen
shape x scale x sink-variant configurations gives no difference, and the
real corpus returns the same 936 processes / 4,648 steps either way. Sink
keys are pushed alongside the traces they belong to, so the comparison set
is the same set it always was.

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

* fix(processes): type the parse-output read as ParseOutput

The R3-6 sink read declared its own structural shape for the parse output
instead of naming `ParseOutput`, which made it the only one of the five
parse consumers in the repo not bound to the real type — cross-file.ts,
orm.ts, routes.ts and tools.ts all pass the type argument.

`getPhaseOutput` is a raw `as T` cast, so a local shape checks nothing at
runtime and only severs the compile-time link: renaming `allFetchCalls` on
`ParseOutput` would still compile here and silently detect zero sinks
forever. Verified with a real `tsc --noEmit --strict` run over exactly that
rename — the typed consumers error, this one did not. The runtime `.filter`
stays, since it is the only thing actually guarding the cast.

Also brings the phase docblock back in line with the deps array, which was
missing `structure` (pre-existing) and `parse` (added by this branch), and
records the two parse fields the phase now reads.

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

---------

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-08 09:58:14 +01:00
azizur100389
7ac0c86165
fix(scope-resolution): link Record graph nodes (#2871)
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): link Record graph nodes

Register Record definitions and caller anchors so Java and C# record targets and initializer sources resolve to canonical nodes. Keep generated LadybugDB relation pairs and the production benchmark baseline synchronized.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(bench): harden schema-pair production gate

Derive the production count from executable DDL, fail closed when its budget is missing, and independently pin Record-to-Property coverage. Keep benchmark evidence machine-scoped and correct stale schema counts.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-07 18:33:27 +01:00
Gergő Magyar
997fc05b83
fix(resolution): resolve calls through a generic-typed field receiver in every language (#2833) (#2855)
* test(resolution): pin generic-typed field receivers across languages (#2833)

A field whose declared type carries a type argument (`repo: Repo<User>`)
emits zero CALLS edges — not a truncated chain, not an edge to the
interface declaration, nothing. This adds the cross-language matrix that
measures it, modelled on the #2807 inferred-field matrix: every language
runs the same two calls, one through a generic-typed field and one
through a non-generic control field, and each language is compared
against its OWN control row rather than an absolute edge count.

Measured state, pinned here as `known-gap` so the file is green on main
and flipping a row is a visible edit:

  affected    TypeScript, C#, C++, Python
  unaffected  Java, Kotlin, Go, Rust, Swift, Dart

The unaffected six erase type arguments at interpret time (Java's
`stripGeneric`, F41 #1928; Swift likewise). TypeScript, C# and Python
instead run a container ALLOW-LIST that returns the type ARGUMENT, so a
user-defined `Repo<User>` survives verbatim into a lookup that binds
nothing.

The `ts-local-vs-field` case is the bug in one file: `viaLocal` and
`viaParam` both resolve for the identical type, and only `viaField`
loses every edge — a bare name reaches Case 4 and its generic-aware
lookup, a dotted field receiver does not.

Negative controls pin what erasure must NOT do: an unbounded type
parameter denotes no declaration, and a C++ explicit specialization is a
different class from its primary template. The `Box2<T>` row pins a
PRE-EXISTING false edge (a workspace class named `T`) so it cannot later
be mistaken for fallout from this work.

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

* refactor(resolution): move resolveClassBindingForName to the shared walkers (#2833)

Pure relocation, no behaviour change: the generic-aware class lookup
moves from `passes/receiver-bound-calls.ts` to `scope/walkers.ts`, beside
the bare `findClassBindingInScope` it wraps. Its two existing callers —
`classifyReceiverOrigin` and Case 4 — import it from the new home and are
otherwise untouched.

The move is required rather than cosmetic: `receiver-bound-calls.ts`
already imports from `compound-receiver.ts`, so having the compound
receiver call into the pass would close an import cycle. `walkers.ts` is
the shared floor both already depend on.

Verified behaviour-neutral: the #2833 matrix is 44/44 identical before
and after, across all fifteen fixtures.

detect_changes attributes `resolveInheritanceBaseInScope`,
`resolveQualifiedInheritanceBase` and `EMPTY_BINDINGS` to this commit;
those are line-shift artifacts of inserting a function above them, and
their bodies are byte-identical.

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

* fix(resolution): type generic field receivers through the generic-aware lookup (#2833)

A field receiver is spelled `this.repo` — dotted — so it types through
the receiver-chain fold and the text cascade, both of which reach
`findClassBindingInScope`. That function has no notion of type arguments,
so a field declared `Repo<User>` resolved to nothing and the call site
emitted NO edge at all: not the interface declaration, not the
implementation fan-out, nothing. A local or parameter of the identical
type is a bare name, reaches Case 4 and its generic-aware
`resolveClassBindingForName`, and resolved fine. The bug was the
asymmetry, not the generics.

Three receiver-typing lookups now call the generic-aware helper instead:
`typeOfMemberOnClass`'s primary and module-hoist branches, and the
cascade's bare-identifier type-binding read. Every other one of the 38
`findClassBindingInScope` call sites is untouched — its own docstring
records that widening it globally suppresses the `?? otherResolver(...)`
fallbacks two dozen callers rely on, which would retarget inheritance
edges, and impact rates it CRITICAL with 12 direct dependents.

Order matters and is preserved: the helper tries the exact name, then an
arity- and token-exact match against `def.templateArguments`, and only
then falls back to the base name. Erasing first would collapse a C++
explicit specialization onto its primary template — `Vec<bool>` really is
a different class. A bare type parameter carries no type arguments, so it
never enters the generic branch and cannot be erased into a class that
happens to share its name.

Measured: TypeScript and C# generic-typed fields now emit exactly what
their non-generic control rows emit, primary plus interface-dispatch
fan-out. Java, Kotlin, Go, Rust, Swift and Dart are byte-identical. Both
type-parameter negative controls are unchanged. C++ and Python are still
open and stay pinned as known-gaps — they fail for different reasons and
get their own commits.

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

* fix(cpp,python): bind generic-typed member fields so their calls resolve (#2833)

Completes #2833 for the two languages the shared resolution change could
not reach. Each failed for its own reason, and both were found by
measurement rather than assumed.

C++ — a CAPTURE gap, not a resolution one. All three `field_declaration`
type-binding rules required `type: (type_identifier)`, so a member
declared `Repo<User> repo;` is a `template_type` and matched none of
them: the field got no type binding at all, and every call through it
lost its edge in both the bare and `this->` spellings. A LOCAL of the
identical type resolved the whole time, because the local declaration
rules gained their `template_type` variant long ago. Three mirrored
rules close it, one per declarator shape (plain, pointer, reference).
Written as separate patterns rather than one alternation: a node-type
alternation in a field position is a tree-sitter 0.21 hazard this repo
has been bitten by before.

Python — the bracket spelling never entered the generic branch. Its
`stripGeneric` is a container allow-list over `[...]` that returns the
type ARGUMENT (`list[User]` to `User`), so a user-defined `Repo[User]`
matched nothing and survived verbatim, and the shared lookup's generic
branch is gated on `<`. It now reduces a subscripted type neither
allow-list claims to its base name — the same rule Java and Swift
already apply to `<...>`. Deliberately the LAST resort: a container must
reach its own rule first, or `list[User]` would type the receiver as the
container and retarget every call in a for-loop chain. The as-written
spelling survives on `TypeRef.declaredSpelling`, which is what the fold's
index step reads.

Both are parse-time and land in the cached ParsedFile, so SCHEMA_BUMP
goes 45 -> 46 with its pin test. Verified free against origin/main; the
ledger in that file records three prior EXACT clashes, so re-check again
immediately before merge.

The matrix now covers the spellings real code writes, all measured: a
nullable generic, a bounded wildcard, a raw type, a nested generic and a
multi-argument one. None needed work beyond the shared lookup, which is
the evidence that base-name erasure is the right primitive. The C++
specialization control now asserts what it was written for:
`Vec<bool>.save` and `Vec.save` are DIFFERENT target ids, so the
arity/token match still wins over erasure.

scope-capture is byte-identical for cpp and c, so no rebaseline — the
bench corpus contains no generic-typed member field, which is worth its
own coverage issue.

Two pre-existing gaps were measured and are deliberately NOT fixed here,
because in both cases the language's own non-generic CONTROL row fails
identically: C++ `this->field.m()` emits nothing, and JavaScript/PHP
docblock-declared field types bind nothing at all.

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

* fix(python): do not reduce containers or typing special forms to a base name (#2833)

Review finding on this branch's own Python change, caught by probing the
interpreter directly rather than by reading it.

The base-name reduction was reached by FALLTHROUGH: "neither container rule
matched" was treated as "not a container". It is not, and two measured
shapes proved it:

  dict[str, list[User]]   ->  dict          (was: the annotation, intact)
  Dict[str, Repo[User]]   ->  Dict
  Callable[[int], User]   ->  Callable
  Literal["a"]            ->  Literal
  Union[A, B]             ->  Union
  tuple[int, ...]         ->  tuple

The dict rule's value group cannot span a nested `]`, so a nested value
declines and falls through — and the dict rule's own comment says that
shape is deliberately "left for a downstream strip pass". Collapsing it to
`dict` destroyed the value type instead. The typing SPECIAL FORMS are worse:
`Callable`, `Literal`, `Annotated` and `Union` are not classes, and reducing
them to a bare name binds any workspace class that happens to share it —
a fabricated edge, which is strictly worse than the missing edge #2833 set
out to fix, and those names are ordinary enough for a real codebase to
declare.

Reduction is now guarded by an explicit deny set covering the containers the
two allow-lists already own and the typing special forms. Everything named
there keeps its as-written text and resolves exactly as it did before #2833.

`arr[0]` also reduces to `arr` in isolation, but that is unreachable and is
now documented as such: every Python `@type-binding.type` capture is a
`(type)`, `(identifier)`, `(attribute)` or `(dotted_name)` node, so a
subscripted VALUE expression never reaches the interpreter.

Pinned by a new unit test that asserts all four groups — user generic
reduces, container reduces to its ELEMENT, declined container shape stays
intact, special form untouched. Reverting the deny set fails three of its
five cases.

Also corrects `resolveClassBindingForName`'s docstring, which this branch
had made false: it claimed only `classifyReceiverOrigin` passes the
decoration stripper, while the three receiver-typing lookups in
compound-receiver.ts now pass it too.

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

* fix(resolution): rank base-name candidates lexically and refuse arg-pinned defs (#2833)

Review of #2855 found that this PR turned a MISSING C++ edge into a
CONFIDENTLY WRONG one — the direction this subsystem calls unrecoverable.

`resolveClassBindingForName` ended with an unguarded base-name fallback
that returned the first same-named class the scope chain reached. A C++
primary template carries `templateArguments === undefined`, so it can
never satisfy the exact-args branch, and every non-specialized
instantiation fell through to that fallback. Measured through the real
pipeline: with the primary forward-declared and the specialization
defined first, `Vec<int> vi; vi.save()` emitted `Vec<bool>::save`.
Declaring the primary first gave the correct target — selection was
SOURCE-ORDER DEPENDENT. Two more triggers behaved the same way: a
partial specialization (`Vec<int*>` against `Vec<T*>`), and lexical
shadowing between a global `Box<bool>` and a namespaced `N::Box<bool>`.

Two changes, neither of which is any of the three remediations the
review proposed — each was rejected on measured evidence:

- Exact-argument matching is now LEXICAL-FIRST. Candidates come from the
  scope chain, and the workspace-wide qualified-name bucket is consulted
  only when the chain produced no exact match, so cross-file
  specializations still bind.
- The base-name route refuses a definition that pinned its own template
  arguments: if the fallback's answer carries `templateArguments`, the
  visible candidates are re-decided with those removed — exactly one, or
  decline.

Why not the filed options. "If specializations exist and none matches
exactly, return undefined" deletes a green committed row
(`neg-cpp-specialization/runInt` legitimately resolves to the primary).
"Resolve all defs for the base name, return only on exactly one" deletes
a working edge for C# `partial class Repo<T>` split across files — two
unspecialized defs under one name is legitimate, and
`QualifiedNameIndex`'s own docstring names that case. Preferring the
primary alone fixes nothing about shadowing, which is a ranking bug.

The guard is expressed as `carriesOwnTemplateArguments`, not as
"specialization", so shared pipeline code still names no language
(AGENTS.md R6). It can only fire where a declared name carries concrete
arguments — measured `undefined` for `class Repo<T>` in TypeScript and
C# and for a C++ primary template — so the blast radius is bounded to
C++-style specializations.

Partial-specialization SELECTION is deliberately not implemented:
choosing `Vec<T*>` for `Vec<int*>` needs template-argument deduction,
which is a semantics expansion and cannot live in language-neutral
shared code. The source-order dependence is what is fixed; the answer is
now deterministically the primary.

Also in this commit: dropped an unreachable `?? []` (QualifiedNameIndex
returns a frozen empty array on miss by contract) whose comment was
wrong on both clauses; made the docstring true about argument ERASURE
being what widens what binds, rather than only the decoration stripper;
and corrected a stale pointer that still placed
`resolveClassBindingForName` in `receiver-bound-calls`.

`findClassBindingInScope` itself is untouched — 38 call sites, CRITICAL.

Verified: matrix 56/56, cpp.test.ts 334, unit scope-resolution 1505.
Mutation proof: reverting this file fails the three trigger cases and
passes the non-regression cases; restoring it passes all five.

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

* fix(python): close the deny-set drift axis by case-folding, not by vigilance (#2833)

Review of #2855 found `NOT_A_USER_GENERIC` was a closed list over an
open universe: four review lanes each escaped it with a DIFFERENT set of
names. `Deque` was the sharpest — its lowercase twin `deque` was already
listed, so the omission was an internal inconsistency rather than a
judgement call, and with a workspace `class Deque` present
`self.dq: Deque[User]` fabricated a `Deque.appendleft` edge.

The structural cause is PEP 585: nearly every container has two
spellings differing only in case (`deque`/`typing.Deque`,
`frozenset`/`FrozenSet`). Exact matching forced every pair to be listed
twice, so any half-pair was a silent escape. The deny lookup is now
CASE-FOLDED, which closes that axis by construction — `Deque` becomes
impossible rather than remembered.

`SINGLE_ARG_CONTAINERS` and `MAPPING_CONTAINERS` are now the single
source of truth: they build the two container regexes (verified
byte-identical `.source` and `.flags`, so zero behaviour change) and
feed the property test. The deny set is re-scoped to a closed, auditable
universe — the documented Python stdlib type-system surface — and grew
39 -> 65 concepts: the `collections.abc` views, `contextlib` managers,
`re.Pattern`/`Match`, the `IO` family, ordinary-named stdlib generics
(`Queue`, `Task`, `Future`, `PathLike`), the remaining typing special
forms, and the generic machinery (`Generic`, `Protocol`, `TypeVar`...).

Third-party generics (`Mapped`, `QuerySet`, `Model`) are deliberately
NOT added and are pinned as a decision: that universe is open,
enumerating it only chases the last escape, and declining `Model` would
cost real edges in the many projects that declare one.

The review's suggested property test — derive the names from the
`single`/`dict` regex sources — would NOT have caught `Deque`: `deque`
appears in neither regex, only in the deny set. Both properties are
implemented, since they catch different drift.

The unit test was also TAUTOLOGICAL: it asserted members OF the deny
set, so it structurally could not detect an omission. It now asserts
case-fold closure and PEP 585 alias coverage, and the capture fixture
drops its `as unknown as` cast for the fully-typed helper pattern the
sibling `java-interpret.test.ts` already uses.

Still at interpret time, so no further SCHEMA_BUMP (already 45 -> 46).
Proving the base is a class the FILE can see — the real fix for the
remaining exposure, since `findClassBindingInScope` binds any name with
exactly one workspace def regardless of scope or imports — is a
follow-up, not reachable from this file.

Mutation proof: restoring HEAD's deny-set contents and exact-match
lookup fails four assertions including the `Deque` pair, with the
pre-existing guard rows still passing; restoring gives 125/125.

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

* fix(cpp): capture qualified generic member fields, and make the bench gate see them (#2833)

Review of #2855 found that the three `field_declaration` rules this PR
added only matched a DIRECT `template_type`, so the common real-world
spelling still bound nothing: `std::vector<Item> items;`,
`ns::Repo<User> r;` and `std::unique_ptr<Repo> p;` parse as a
`qualified_identifier` WRAPPING a `template_type`. "C++ fixed" was
overstated.

Six new patterns — three declarator shapes (plain, pointer, reference)
by two qualifier depths — written as separate patterns rather than one
alternation, keeping the tree-sitter 0.21 field-position discipline the
existing rules follow.

The design choice was measured, not assumed. Codex suggested preserving
the full qualified spelling and normalizing `::`; preserving resolves
NOTHING, because `findClassBindingInScope`'s dotted-tail fallback splits
on `.` while C++ writes `::`, and `ns::Repo` is not an index key either
(C++ emits no `@declaration.qualified_name`). Measured: `ns::Repo<User>`
resolves to nothing, `ns.Repo<User>` resolves to `Repo`. Since a
tree-sitter capture is a NODE and not synthesized text, the only lever
is which node to capture — so `@type-binding.type` goes on the INNER
`template_type`, dropping the qualifier and landing on the same
single-match-or-decline path the bare spelling already takes.

Qualifier depth 3+ (`a:🅱️:c::Repo<User>`) remains uncaptured. Stated as
a limit and pinned by a test row, not claimed as fixed.

The bench blindness the review identified is also closed. The
`scope-capture` C++ corpus contained ZERO template-typed member fields —
confirmed a fourth way by applying six demonstrably behaviour-changing
patterns and getting a byte-identical fingerprint. The corpus now
carries generic and qualified-generic members, and the gate is load
bearing for the first time: three states that all hashed to 856d02f3
before now differ (pre-#2833 0e7cbda7, +this PR's 3 rules de07d8b5,
+these 6 rules bd47c82d). Rebaselined for cpp only; c is unchanged.
Histogram diff: only 5 tags move with the fields, each by exactly +40
(20 entities x 2), and every `@reference.*` count is unchanged.

Over-match is preserved: 20 shapes still produce no field capture,
including the 8 original method/pointer/reference/function-pointer/
using/typedef/friend/operator forms plus their `std::`- and
`a:🅱️:`-qualified variants.

Not fixed here, deliberately: NON-generic qualified fields
(`ns::Address addr;`, `std::string name;`) still capture nothing.
Closing that needs six more patterns and would newly bind every
`std::string`/`std::mutex` member repo-wide, changing edges far outside
#2833. Separate issue.

The template-template-parameter hazard the review filed against these
rules is NOT capture-side: a tree-sitter query has no scope knowledge,
so it cannot know `Map` is bound by the enclosing `template <...>`
header, and the PRE-EXISTING `type: (type_identifier)` rule already
captures a bare `T item;` and erases it the same way. It is handled by
the lexical ranking in `walkers.ts` in this series.

Mutation proof: reverting this file fails 9 of 32 assertions (all eight
qualified spellings return no capture) while every over-match negative
still passes; restoring gives ALL PASS. Bench `--check` passes for all
15 languages.

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

* test(resolution): pin specialization order, shadowing and the untested spellings (#2833)

Grows the generic-field matrix 56 -> 114 tests, closing every coverage
gap the #2855 review named and turning the fix-agents' scratch evidence
into permanent rows.

The rows that discriminate against the resolver fix (they fail if
`walkers.ts` is reverted):

- C++ specialization must not depend on DECLARATION ORDER: the
  forward-declared-primary/specialization-first arrangement must land on
  the primary, same as the mirror arrangement. Plus a cross-case
  property asserting the two independently built fixtures agree.
- Partial specialization is deterministic in both orders. The note says
  explicitly that selecting `Vec<T*>` would need argument deduction and
  that flipping this row later is a deliberate expansion, not a
  regression fix.
- Lexical shadowing: the namespace-local `N::Box<bool>` wins for a field
  inside `N`, and the global specialization wins at global scope.

The NON-REGRESSION rows are load-bearing — they are why two of the three
proposed remediations were rejected: cross-file C++ specialization
binding, and C# `partial class Repo<T>` split across two files with the
field in a third (two legitimate unspecialized defs under one name).

Coverage the review found missing: C++ pointer and reference generic
fields (two of this PR's three original rules had ZERO coverage); all
six qualified patterns plus the depth-3 boundary pinned as empty;
TS/C# multi-arg container collision; an anti-vacuity sibling for
`neg-bounded-type-parameter`; Swift/Dart rows restructured so the
ANNOTATION is the only possible source (the old rows gave the field an
initializer of the same generic type and could not tell which resolved);
and cross-file, inheritance/MRO, import-alias, static-member and the
TypeScript module-hoist branch.

Six things were measured and pinned AS MEASURED rather than asserted as
wishes, each flagged in its row note: a static/class-level member emits
nothing for generic AND non-generic alike (a static gap, not a generics
one); a cross-file C++ primary template does not bind while the
cross-file specialization does; `std::unique_ptr<Payload>` types to
`unique_ptr` rather than `Payload` (smart-pointer transparency is not
applied on the qualified path); two same-named C++ specializations in
one file collapse to one node id; and the container-name collision
(`Map<string, User>` binding a workspace `class Map`) is recorded as
INTENDED, since the annotation does name that class.

The `new Set(...)` dedup was kept rather than narrowed: a per-case
surplus-edge sweep measured ZERO duplicate edges anywhere in this file,
Swift included, so the quirk that justified a blanket dedup does not
reproduce. The sweep now pins zero surplus per case, so a real
double-emit fails instead of being absorbed.

The file is deliberately NOT split: four assertions compare cases
against each other, cost is linear in cases, and the 1,800,000 ms
`beforeAll` is kept because the same run measured 271-428 s depending on
host load — a tighter bound converts contention into a red suite. The
reasoning is recorded in the file header.

Also corrects the SCHEMA_BUMP pin-test title, which still said (#2766).

Mutation proof: reverting `walkers.ts` fails exactly the five order and
shadowing assertions and passes the other 109; restoring gives 114/114.

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

* feat(resolution): capture declared type parameters so a type variable is not a class (#2833)

Three review findings were blocked on one missing fact. `templateArguments`
records the arguments a declaration was written AGAINST (`struct Vec<bool>`);
nothing recorded the parameter list a declaration DECLARES (`template <class T>`,
`class Box<T extends Repo>`). So the resolver could not tell a type variable
from a class, and:

- `class Box2<T> { t: T }` beside a workspace `class T` emitted a FALSE edge
  `run2 -> T.foo`. `T` carries no type arguments, so it never entered the
  generic branch — the plain lookup simply bound a same-named class. The
  lexical grounding added elsewhere in this series cannot help, because
  `export class T` IS lexically bound.
- `class Box<T extends Repo> { t: T }` resolved to nothing: no recorded bound
  to resolve through.
- A full specialization `template<> struct Vec<T*>` and a partial
  `template<class T> struct Vec<T*>` were byte-identical (`['T*']`).

`SymbolDefinition.typeParameters` now records `{ name, bound? }` in declaration
order (substitution is positional). `bound` is kept verbatim and un-split, so
`Repo & Closeable` stays whole; ABSENT means UNKNOWN, never "unbounded", which
is what keeps unconverted languages behaving exactly as before.

Transport is the raw parameter-list node via `@declaration.type-parameters`,
read by a language-neutral parser that recognizes TOKENS, not languages:
`extends`/`:` introduce a bound, the name is the trailing identifier, so
`class T`, `typename T`, `in T`, `out T`, `reified T` and `class... Ts` are one
rule. Populated for TypeScript, C++, Java, Kotlin, C# and Rust. JavaScript, C,
COBOL, PHP and Ruby have no declared type parameters to capture; Go and Python
spell them with SQUARE brackets, which this parser deliberately rejects as
ambiguous against subscript and array spellings (Go already has a working
main-thread sidecar in this series); Dart and Swift are straightforward
follow-ups.

Two latent hazards found and closed on the way:

- The new capture was not in `KNOWN_SUB_TAGS`, so it could out-span its own
  declaration and become the anchor — silently DROPPING the whole class def.
- A templated C++ struct matches both the standalone and `template_declaration`
  patterns, minting two defs under one id, and only one twin could see the
  parameter list. `buildDefIndex` is first-write-wins, so MATCH ORDER decided
  whether `Vec` remembered `T`. A narrow duplicate-declaration backfill gives
  both twins the list.

Also fixed by its own test: a Rust lifetime `'a` parsed as a parameter named
`a`, which would have shadowed a real class.

Parse-time output lands in the cached ParsedFile, so SCHEMA_BUMP goes 46 -> 47.
Re-checked against origin/main at write time: main is on 45; 46 was taken by
this same branch, and a warm cache stamped 46 carries ParsedFiles with no
`typeParameters` at all.

The csharp and rust capture goldens were regenerated with the tests' own
documented `UPDATE_GOLDEN=1`; only digests moved, no captureGroups.

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

* fix(resolution): ground erased base names, and stop a class name from being enough (#2833)

The review's central risk was that this PR converts MISSING edges into
CONFIDENTLY WRONG ones. Base-name erasure (`Repo<User>` -> `Repo`,
`Repo[User]` -> `Repo`) bound through a workspace-wide qualified-name
fallback that consults NO scope, NO import and NO module — it bound any name
with exactly one workspace def. That is why a Python `Mapped[User]` could bind
an unrelated `class Mapped`, and why the language deny lists were papering
over an open universe.

`resolveErasedBaseName` now admits an erased base on one of four grounds,
strongest first: the scope chain binds it; the declaration is in the SAME
FILE; the index proves the name is a template family; or the file binds no
cross-file class at all, so its silence is no evidence. The last ground fails
toward permissive on purpose — every way it can be wrong costs a wrong edge
that already existed, never a working one.

Two measurements drove that design and refuted the simpler rule. A C++
`#include` materializes NO binding whatever, and C# resolves cross-namespace
without `using` through the index — so a pure "require lexical grounding" rule
would have deleted every cross-file C++ generic member. Both are now pinned.

Python erases at CAPTURE time, so by resolution there is no `<` and the
grounded route was never entered. `erasedTypeApplication` rebuilds the
application from `TypeRef.declaredSpelling` — strictly: the raw name must be
the base and the argument list the whole balanced remainder, so `User[]`,
`vector<Item>` and `Repo<User>?` decline and behave exactly as before.

Closing it took finding FOUR emitters, not one. Three were in Case 4; the
fourth was `emitReferencesViaLookup` re-emitting the refused edge from the
pre-resolved reference index, which needed the site marked handled with a
recorded `receiver-unresolved`. A fifth lived in the text cascade: a declined
fold falls THROUGH by design, and the cascade held its own ungrounded copy of
the member-typing lookup. This file typed a receiver from a `TypeRef` in five
places and the PR had wired three; all five now go through one
`classOfDeclaredType`.

Also here, from the same review:

- Type parameters no longer bind a same-named class (uses the new
  `typeParameters`), and a BOUNDED parameter resolves through its bound.
- A cross-file C++ PRIMARY template now binds: a ranking bug, not a capture
  one — the index fallback needs exactly one candidate and `Vec` held two, so
  removing the argument-pinned declaration leaves one.
- `this->field.m()` resolved to nothing for generic AND non-generic alike. A
  language that declares `this` IS the enclosing class
  (`resolveThisViaEnclosingClass`) synthesizes no `this` typeBinding, so a
  chain whose BASE is `this` could never seed its head. Reading the provider
  flag keeps the rule language-free.
- Class-level (static) member receivers emit nothing in TypeScript and Kotlin
  — for the non-generic control too. Case 6 types them from the DEF side
  (`isStatic` + `declaredType` on the field node), which needs no capture
  change; the target lookup stays the ordinary instance walk, so a static
  field HOLDING an instance still binds an instance method and a genuine
  static call is untouched.

Partial-specialization SELECTION is deliberately not implemented: it needs
argument deduction against a parameter list, and full C++ partial ordering is
a real algorithm with no measured driving case. The discriminator now exists
if someone wants it.

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

* fix(cpp,js,php,go): close the remaining per-language generic-field gaps (#2833)

Four language gaps the review measured, each with a different cause.

**C++ qualified member fields.** `std::vector<Item> items;`, `ns::Repo<User> r;`
and `ns::Address addr;` captured NOTHING: every field rule required the type
node to BE a `type_identifier` or `template_type`, and a qualified member type
is neither — tree-sitter wraps both in a `qualified_identifier`. Three
depth-agnostic rules (one per declarator shape) now match the outer node, which
also REMOVES the depth boundary rather than raising it: depths 1-4 capture,
generic and non-generic alike.

Preserving the qualifier resolves nothing — measured: `ns::Repo<User>` binds
neither way, because the dotted-tail fallback splits on `.` while C++ writes
`::`, and `ns::Repo` is not an index key. Since a capture is a NODE and not
synthesized text, the qualifier is dropped in `interpret.ts` by a top-level-only
`::` split, so `std::vector<std::string>` reduces to `vector<std::string>`, not
`string`.

Measured cost of the non-generic half, which was the reason to hesitate: field
captures go 8 -> 32 across the C++ bench corpus, but the resolution-level census
over those 13 repos is 32 CALLS edges before and 32 after, BYTE-IDENTICAL. It
fabricates only where a workspace class shares a std name (`class string` beside
`std::string name;`), which is the same accepted policy the already-landed
qualified-generic rules carry, pinned in the matrix as intended.

**JavaScript `@type {Repo<User>}` and PHP `@var Repo<User>`.** Neither bound a
field type — and neither did the NON-generic control, so this was a docblock gap
rather than a generics one. PHP needed TWO captures, not one: with only the type
binding, `$this->repo->save()` resolved until a second class declared `save` and
then went unresolved, because narrowing a same-named method needs the receiver's
member owned. Generics do NOT come free in PHP — `normalizePhpType('Repo<User>')`
returns `'User'` by the container-element convention, so passing the raw spelling
through would have emitted `User::save`; type arguments are erased at capture
instead. In JavaScript they DO come free, verified byte-identical to the
TypeScript control. Both decline what they cannot prove: arrays, `list<User>`,
unions, `Promise`/`Array` wrappers (via an exported predicate rather than a
copied name list), statics, and any property that already has a native type.

**Go generic interfaces.** `UserRepo` genuinely DOES implement `Repo[User]` —
the spec says a generic type must be instantiated, that instantiation
substitutes type arguments and yields a new non-generic type, and that a type
implements an interface when it is in its type set. So the old behaviour was a
FALSE NEGATIVE and the matrix note calling it "already correct" was wrong.
Satisfaction is now checked against POSITIONALLY SUBSTITUTED method sets, so
`Repo[Order]` does not match a `Save(x User)` implementor — substitution, not
erasure. #2829's exact method-set model is untouched: pointer receivers still
follow MS(*T), unexported names stay package-scoped, the declaration's own
method set is still checked first, and the harvest is gated so a repo with no
generic interface never runs it. `go.test.ts` is unchanged at 296 passing.

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

* test(resolution): pin every fix from the review, 114 -> 155 rows (#2833)

Eight rows in this matrix pinned gaps that the fixes in this series close, so
each asserted the opposite of the new truth. All eight are flipped, and the
prose describing them as open gaps is corrected. Nine new cases cover the fixes
that would otherwise have shipped unpinned.

Flipped, each measured: the type-parameter FALSE edge (`run2`) is gone; a
bounded parameter now resolves through its bound with fan-out; the cross-file
C++ primary binds; the C++ qualifier depth boundary is removed rather than
raised; Go gains its two structural implementors and JOINS the paired sweep,
which had quietly excluded it — that exclusion was the taxonomy admitting a bug;
and both static-member rows resolve.

Added: JS `@type` and PHP `@var` docblock fields with three PHP declines; a
Kotlin `companion object` receiver (given an INTERFACE control so the paired
sweep can check it, which `ts-reach-shapes` cannot — its two sides are not
count-comparable); the Python third-party grounding refusal plus the ground that
still ADMITS, so an empty row can never be read as "erased names never resolve";
the four mirrors that would break if grounding were tightened (same-file and
imported Python, a C++ `#include`, C# cross-namespace without `using`); C++
qualified non-generic fields including the fabrication policy and its absence
case; `this->field.m()` for generic and non-generic with bare controls; and a Go
negative proving substitution is positional, not erasure.

Three shapes are pinned AS MEASURED with notes saying they are deliberate limits
so nobody "fixes" them by accident: C++ partial-specialization selection is
deterministically the primary (real selection needs argument deduction);
`std::unique_ptr<T>` types to the pointer, not the pointee (`.` and `->` are
indistinguishable to the resolver, so transparency would trade a recoverable
miss for a confident wrong edge); and two same-named C++ specializations in one
file collapse to one node id, which is why the shadowing fixture uses two files.

One row pins a REMAINING wrong edge rather than hiding it: `m.inner.ping()` on
a `Mapped[User]` head still binds the unrelated workspace class, while the
one-segment-shallower `m.save(u)` correctly declines. The obvious one-line guard
was written and MEASURED not to close it, so the surviving route is elsewhere
and wants its own diagnosis — a broader refusal would change chain-head
resolution for every language without pinning the shape it is meant to fix.

`bench/scope-capture` is rebaselined for the six languages whose captures moved,
regenerated from a fresh measurement rather than pasted; `--check` passes for all
15.

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

* perf(resolution): remove three measured hot-path regressions this series added (#2833)

A quality pass over the #2833 series found three performance defects it had
introduced, all measured, plus dead code and stale docs from six agents having
appended to the same files across four rounds. No behaviour change: the
resolver suite is identical before and after, and every scope-capture
fingerprint is byte-identical.

**An accidental quadratic in Go instantiation harvesting.** `collectGoInstantiations`
calls `record()` for every type binding and every declared, return and parameter
type in every Go file, and the `includes('[')` gate does not filter Go's most
common types — `map[string]string`, `[]map[string]*v1.Pod` and
`map[string]map[string]int` all produce a `map` candidate. Each false base then
failed a full scope-chain walk and fell through to a LINEAR SCAN OF EVERY
INTERFACE IN THE PROGRAM, with no dedupe on the spelling, so the same
`map[string]string` written 10,000 times paid 10,000 scans. Now a
qualified-name index built in `buildDetectionIndexes` (one probe, ambiguity
semantics preserved exactly) plus a per-scope base memo:

    8,000 interfaces / 80,000 spellings:  6,662 ms -> 104 ms   (64x)

`resolveEmbeddedInterface` held a byte-identical copy of that scan and now
shares the helper. `GoInstantiation` was a single-field wrapper and collapses
to the array it wrapped; its two parallel maps fold into one whose inner key IS
the dedupe. `candidateStructIdsFor` was rebuilt per instantiation although
every substituted method set has the same key set — hoisted, and materialized,
because one branch returned a live iterator that would have yielded nothing on
a second pass.

**`scanForCrossFileClass` asked a name-keyed question that needs no name key.**
It answered "does this file bind any cross-file class" by probing every
accessible namespace once PER NAME. It now iterates the channels directly,
taking whichever side is smaller so a large namespace table cannot reintroduce
the product. Predicate and early exit preserved:

    5,000 module names x 1,000 namespaces:  159.0 ms -> 1.2 ms   (132x)

**A duplicated scope walk on every generic receiver.** `resolveClassBindingForName`
computed the lexical candidate list, then `resolveErasedBaseName` recomputed
the identical `findAllBindingsInScope`. Computed once and passed:

    receiver at depth 8:  5,617 ns -> 3,091 ns   (-45%)

**A whole extra AST traversal per JavaScript and PHP file.** The docblock
synthesis passes each added a full tree walk to find one node kind — the ninth
in the JS emitter, the third in PHP. `node.namedChildren` materializes a
wrapper array across the N-API boundary for every node, so one added pass cost
1.9x what parsing the entire file costs. Folded into the existing walks as one
more node kind; capture output is byte-identical and every fingerprint is
unchanged. Total emit time per file drops 4-7%.

Hygiene, all verified stale rather than assumed:

- `receiverOriginOpts` passed `resolveThisViaEnclosingClass`, which
  `classifyReceiverOrigin` never reads — the "both hooks" comment above it is
  true again.
- The `stripDecoration` docstring's caller roll-call claimed the only
  edge-emitting caller "emits no edge and can only change a diagnostic label".
  Case 6 passes it and does emit edges. Replaced the roll-call with the rule;
  six rounds each appending a name to a list is how it went wrong.
- A Python comment described the resolution-time grounding as a follow-up that
  "this parse-time pass cannot do" — it landed in this same branch and is
  pinned by `py-erased-grounding`.
- `classOfDeclaredType` took a `scopeId` all five callers derived from the
  `TypeRef` they also passed. Dropped, so "these five are the same call" is
  enforced rather than asserted.
- Three exports with no consumer outside their own file.
- PHP had three copies of one preceding-comment sibling walk and two regexes
  for one tag, so a fix to either reader of `@var` would land on one and not
  the other — the symptom being a field typed differently from its own foreach
  element type. One walk, one regex.

Tests: the new matrix leaked a fixture repo per case; it now carries the
sibling suite's `cleanupTempDirSync` and the Windows EBUSY reasoning that goes
with it. `PAIRED` was a second hand-maintained list and 19 of 41 cases had
silently fallen out of it — it is derived from the cases now, with a new
assertion that each case is either swept as a pair or carries a written reason
it is not. That recovered one genuine omission (`php-typed-property`).

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

* test(bench): rebaseline receiver-resolution for the #2833 this-> fix

The `Receiver-resolution drop guards` CI step failed on this branch:

  shapeArm.cpp.fieldReceiverCall:  "INVISIBLE-GAP" -> "RESOLVES"
  shapeArm.cpp.decoratedFieldType: "INVISIBLE-GAP" -> "RESOLVES"

Both are the intended improvement. The guard is exact-match by design —
the drop count cannot move without a deliberate rebaseline, and the
rebaseline path demands the movement be explained — so this records the
two shape flips and leaves the call-drop count arm untouched.

BASELINE.md still claimed `this->repo.save()` and `this->repo->save()`
were INVISIBLE-GAP. That is now false: the `resolveThisViaEnclosingClass`
head seed added in this PR resolves both. Also notes what the control
established — this was never a generics gap, since the non-generic
control failed identically before the fix.

* docs(parse-cache): narrow the SCHEMA_BUMP ledger to what the bump delivers

The ledger claimed a warm cache would make "the whole fix ... a silent
no-op on every incremental analyze". That overstates the constant. The
bump invalidates the PARSE half; whether the re-parsed captures reach the
graph is gated separately and does not move:

  - `isIncremental` (core/run-analyze.ts) tests `!options.force`, an
    existing meta, `!schemaFingerprintMismatch(...)`, feature parity,
    non-empty `fileHashes` and a git repo. SCHEMA_BUMP is in none of them.
  - the incremental branch writes back only `hashDiff.toWrite` and logs
    the rest as "unchanged file rows preserved".
  - SCHEMA_FINGERPRINT hashes node/relation DDL, untouched here, so it is
    byte-identical and moves nothing either.

So an incremental analyze re-parses an unchanged file correctly but keeps
its existing rows; the new edges land on the next full rebuild. That is
the pre-existing contract for every capture change, not a regression in
this PR — but the comment should not promise more than it delivers.

Comment only; no behavior change. SCHEMA_BUMP stays 48.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:14:13 +01:00
Gergő Magyar
a033b04c46
fix(go): scope and define each type_spec, not the type_declaration (#2837) (#2843) 2026-08-06 00:55:48 +01:00
Gergő Magyar
aaa78f9590
fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) (#2842)
* fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832)

Case 3b (chain-typebinding) folds a receiver through the same
`resolveCompoundReceiverClass` call and the same `[owner, ...mroFor(owner)]`
walk Case 0 uses, but emitted its edge without calling
`emitInterfaceDispatchFor`. When that fold landed on an Interface the site got
one edge to the interface's bodiless declaration and none to any
implementation — the defect #2813 reported for field receivers, in the half
#2829 did not cover.

The gap was a property of how a receiver was SPELLED rather than of what it
resolved to. `d.repo.save()` contains a dot, so it took Case 0 and fanned out;
binding the identical field to a local first — `const r = d.repo; r.save()` —
made the receiver a bare name with a dotted typeBinding, which is Case 3b, and
lost every implementation edge.

`ownerDef` is the receiver's own folded type, matching Case 0's `currentClass`
and Case 4's `ownerDef`, not the owner of the member the MRO walk settled on:
a receiver folding to a concrete class that merely inherits an interface method
must not fan out, because its runtime type is that class. The closure
self-gates on `ownerDef.type !== 'Interface'`, so the call is inert for every
concrete receiver and needs no language check. Confidence is the 0.85 literal
this case's own primary emits, so dispatch edges never outrank the edge they
hang off; Case 4's site.kind-dependent value has no counterpart here because
Case 3b's primary does not vary that way.

The new fixture pins the route as well as the fix. `const r = d.repo` reaches
Case 3b and nothing else can take the site: Case 0 needs a `.`/`(` in the
receiver name or a minted receiver chain, and `encodeReceiverChain` returns
undefined for the empty step list a bare identifier produces; Case 4 excludes
itself on the dot. Before the fix the primary assertion passed while the
fan-out came back empty — the exact "reached Case 3b and stopped at the
declaration" signature.

Resolution-side only: this changes what the resolver produces, not how it is
stored, so no SCHEMA_BUMP applies. An existing index must be re-analyzed to
show the new edges.

Follow-up from #2829.

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

* docs(scope-resolution): record Case 3b's interface-dispatch fan-out in I4 (#2832)

Invariant I4 documented the fan-out as something "Cases 0 and 4 both perform"
and spelled out Case 0.5's exclusion, while saying nothing about Case 3b —
which is what made 3b's missing fan-out an undocumented asymmetry rather than
a deliberate exclusion someone could defend or point at.

With the fan-out added, Case 0.5 is the only case that folds or walks to a
receiver type without dispatching to implementations, and its exclusion is
gated behind `resolveThisViaEnclosingClass`. Saying so explicitly keeps the
next reader from having to re-derive which cases fan out by reading the pass.

Comment-only; `detect-changes --scope staged` reports no graph change.

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

* test(scope-resolution): add the concrete-implementor control for Case 3b (#2832)

The Case 3b fan-out shipped with one negative control — a chain folding to
PlainCache, a class that implements nothing. That proves only the weak claim:
no interface anywhere near the site, no fan-out.

Add the stronger negative. SqlRepo implements Repo, so an interface IS in
scope and `save` is a name Repo declares, yet the receiver's folded type is
the concrete class and nothing may fan out. This is the control that fails if
a later change fans out from the interface a member is DECLARED in rather than
from the receiver's own folded type.

The comment says what the control cannot do, too: it cannot catch "member
owner passed instead of folded type" in TypeScript, because an implementing
class always declares the member itself, so the MRO walk never settles on the
interface's bodiless declaration.

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

* docs(scope-resolution): correct four overclaims the review found (#2832)

A multi-lane review of this PR reproduced, against the real pipeline, that
several claims in the comments and one test name assert more than the code
delivers. No behavior changes here — only the text, and one test rename.

1. The test comment gave the WRONG REASON why the concrete-implementor control
   cannot catch "member owner passed instead of folded type". It said an
   implementing class always declares the member itself; `class C extends Base
   implements I {}` is valid TypeScript and inherits it. The real reason is that
   TypeScript's MRO chain never contains an implemented interface, so the walk
   cannot settle on an interface declaration for a concrete receiver. The
   mutation IS expressible where a concrete class inherits a `default` interface
   method (Java, Kotlin) — reproduced during review — so this is language-scoped,
   not inherent, and a follow-up fixture is tracked.

2. "fans out to every implementation of the folded interface" certified a
   completeness that does not exist. TypeScript emits heritage edges for
   `class_declaration` only (languages/typescript/captures.ts:749, stated in its
   own docstring at :732-733), so `abstract class X implements I` and `interface
   B extends A` produce no heritage edge and still dead-end on the bodiless
   declaration. Renamed to name the shape actually covered, with a KNOWN GAP
   note. The gap is in the capture layer and predates this fan-out.

3. Invariant I4 said Case 0.5 is the ONLY case that resolves a receiver type
   without fanning out. Cases 3 and 5 do too, by direct lookup rather than a fold
   or MRO walk. The sentence now says which distinction it means and states the
   reachability argument (no known language reaches Case 3 with an Interface —
   every one that could strips the namespace qualifier first, sending it to
   Case 4) instead of implying a completed audit.

4. The gate's rationale claimed the `ownerDef.type !== 'Interface'` test is right
   for every non-Interface receiver. An abstract-class receiver also dead-ends on
   a declaration-only member and does not fan out. Noted, with why widening the
   gate belongs to Cases 0 and 4 across all languages rather than to #2832.

Also completes the module-level case ladder, which still credited the fan-out to
Case 0 alone and omitted it from the Case 3b entry.

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

* docs(scope-resolution): name Case 2 in the I4 exclusion list too (#2832)

The first pass at this correction listed Cases 3 and 5 as the other cases that
resolve a receiver type without fanning out, and was itself incomplete: Case 2
also walks an MRO and its binding admits `Interface`. It is excluded for a
different reason than 3 and 5 — its receiver IS the type name, so the site is
static dispatch and a fan-out would be wrong, whereas 3 and 5 resolve by direct
lookup rather than a fold or MRO walk. Say both rather than enumerate one.

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

* fix(scope-resolution): close the two gaps the #2842 review left open

Both were pre-existing and reached by Cases 0 and 4 as well; the Case 3b
fan-out only widened the population of sites that hit them. Researched against
the real TypeScript compiler and the language service before choosing
semantics, plus how comparable tools draw the same lines.

1. THE FAN-OUT COULD TARGET A STATIC MEMBER

`class C implements I { static save() {} }` does not satisfy `I` — TypeScript
rejects it as TS2420, "Property 'save' is missing" — so an edge from an
`I`-typed receiver to a static member names a target dispatch can never
produce. The closure picked targets with `pickOverload`, which applies no
static filter, while the surrounding cases pick their own primary with
`pickFirstNonStaticOnly`: the speculative edges were picked with weaker rules
than the certain edge they hang off. A same-name static+instance pair also made
`pickOverload` return OVERLOAD_AMBIGUOUS, suppressing the CORRECT edge too, so
this was a false negative as well as a false positive.

Every comparable tool draws this line: tsserver partitions static from instance
results, clangd gates on `isVirtual()` (C++ forbids virtual statics), jdtls
filters abstract-or-static, and class-hierarchy analysis expands only VIRTUAL
call sites.

The guard prefers `provider.isStaticOnly` where a language declares it and
falls back to the graph node's `isStatic`. That order is load-bearing, not
stylistic: the method extractor derives `isStatic` from the OWNER type as well
as the member (`staticOwnerTypes`), and the JVM config lists
`object_declaration` — so reading the flag first would delete Kotlin `object`
implementations, which are singleton INSTANCES and genuinely reachable. Kotlin
is the only hook implementor and marks exactly the companion-promoted set;
Ruby's `singleton_class` (`def self.foo`) is correctly filtered by the
fallback.

2. TYPESCRIPT HERITAGE WAS CLASS-ONLY

`interface B extends A` and `abstract class X implements I` emitted no heritage
edge at all, so the subtype closure had nothing to descend and both shapes
dead-ended on a bodiless declaration — including the very example the closure's
own docstring cites as the reason it exists. Since Case 3b's dotted-alias
binding survives qualifier-stripping only in TS/JS, this was the language that
actually reaches the new path.

The two shapes reach their bases differently: an abstract class carries the
same `class_heritage` child a concrete one does, while an interface's bases
hang off `extends_type_clause` directly. That clause's `type` field is
`multiple: true`, so `childForFieldName('type')` would silently drop `C` from
`interface B extends A, C` — hence iterating named children.

Deliberately NOT structural matching. TypeScript is structurally typed, so a
class satisfies an interface without `implements`, but tsc's own navigation is
declaration-only and says why: "users are typically only interested in explicit
implementations... The type checker doesn't let us make the distinction between
structurally compatible implementations and explicit implementations, so we
must use the AST." scip-typescript reached the same design independently. gopls
does match structurally, but only because Go has no `implements` keyword to
prefer.

Abstract declarations are still walked THROUGH rather than targeted — the rule
everywhere is "does it have a body?", which is what `isDeclarationOnly`
already tests.

VERSIONING. The capture change is parse-time, so a v43 warm cache would serve
entries missing the new matches: SCHEMA_BUMP 43 -> 44 with its pin test moved
in the same commit, verified against origin/main at a857f4c5a (still 43).
Rebaselined only the `typescript` scope-capture fingerprint, justified by a
capture-name histogram diff over the same 145-file corpus: the only deltas are
@reference.inherits 17 -> 20 and its paired @reference.name 245 -> 248, emitted
together by `emitTsInheritanceBase`. Every other capture count is byte-identical
and javascript is unchanged, the language having no interfaces.

Tests: the fan-out now covers a static-shadowing subclass, a concrete class
below an abstract intermediate, and an implementor of an extending interface.
Resolvers 3176 passed; all five bench gates pass.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:50:08 +01:00
Gergő Magyar
cabd5b82f9
fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813) (#2829)
* test(go): pin calls through an interface-typed struct field (#2813)

A call through an interface-typed struct field never reaches the
implementation: the CALLS edge stops at the interface DECLARATION, so
`impact()` on the implementing method reports 0 callers. This commit adds
the executable statement of that defect; the fixes follow.

Two stacked defects produce it, and either alone is enough to reproduce —
which is why no existing fixture could observe it:

  D1  `buildDetectionIndexes` skips every POINTER-receiver method, so a
      struct whose methods are all `func (r *T)` has an empty method set,
      structurally satisfies nothing, and gets no IMPLEMENTS edge. Go's
      rule is that the method set of *T includes pointer-receiver methods,
      and idiomatic Go stores *T in an interface-typed field.
  D2  Case 0 (compound receiver) emits its primary edge and short-circuits
      without the interface-dispatch fan-out Case 4 performs. A struct
      field receiver `s.orderRepo` contains a dot and so always takes
      Case 0; a local or parameter receiver is a bare name and reaches
      Case 4.

Every implementor in both pre-existing structural-dispatch fixtures uses a
VALUE receiver, and the one pointer-receiver type is pinned as a negative
(`not.toContain('PointerOnlyThing -> PointerOnly')`), so the corpus could
not see D1 by construction. The new fixture is pointer-receiver
throughout, cross-package, and carries concrete-field controls in the same
structs.

Failing-first, verified against this tree: 7 of the 11 new assertions fail
and 4 pass. The 4 that pass are exactly the controls that must not
regress — the primary edge to the interface declaration, the concrete-field
call, the absence of fan-out on a concrete field, and the partial-signature
negative — so the suite discriminates rather than merely failing.

Two recorded artifacts move here because the FIXTURE was added, not
because capture output changed:

  - test/fixtures/go-captures-golden/expected-captures.json — regenerated
    additively (32 insertions, 0 deletions).
  - bench/scope-capture/baselines.json — go fingerprint, fixture_count
    102 -> 110.

Both are regenerated in this commit rather than deferred to the end of the
series: the fixture is their only cause, no later commit touches capture
emission, so they cannot re-drift and every commit stays green. The check
that this is corpus growth and not a capture regression is that go was the
only one of 15 language fingerprints to move on the same run.

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

* fix(go): count pointer-receiver methods toward structural interface satisfaction (#2813)

D1 of two stacked defects. `buildDetectionIndexes` skipped every method whose
receiver is a pointer, so a struct declaring `func (r *OrderRepo) DeleteItem(...)`
had an EMPTY method set, structurally satisfied nothing, and produced no
IMPLEMENTS edge at all.

Go's method-set rule is per-type, and there are two types involved: the method
set of `T` holds only value-receiver methods, while the method set of `*T` holds
both. #1966 implemented the `T` reading, which is exactly right for `T` — and
leaves `*T` permanently empty. GitNexus models one Struct node per type with no
separate `*T` node, so only one of the two can be represented, and the `T`
reading is the one idiomatic Go almost never uses: methods take pointer
receivers so they can mutate, and `*T` is what gets stored in an interface-typed
field.

The cost was silence rather than caution. With no IMPLEMENTS edge, a call
through an interface-typed field resolved to the interface DECLARATION and
`impact()` on the implementing method returned 0 callers — byte-identical to a
symbol that genuinely has none, which is what made the reporter's blast-radius
check unusable rather than merely incomplete.

This picks the `*T` reading: the graph now answers "which types provide this
interface's behaviour", and no longer proves `var x I = T{}` invalid. The trade
is deliberate and was checked against every consumer of IMPLEMENTS before being
made — MRO/METHOD_IMPLEMENTS derivation, community clustering, the
receiver-dispatch fan-out index, and the epistemic heritage probe. None performs
value-assignability checking.

Two negative pins encoded the #1966 decision and are REVERSED here rather than
deleted, each keeping a comment that explains why the polarity moved:
  - go.test.ts: `PointerOnlyThing -> PointerOnly` now expected to be emitted.
  - go-hooks.test.ts: the pointer-receiver-only unit case now expects the
    implementor instead of `undefined`.

`goReceiverKind` is still stamped in method-owners.ts — it is the hook a future
value/pointer-aware model would read — but is deliberately no longer a filter.
Its now-dead local predicate and type alias are removed so the file no longer
carries a helper asserting the reverted rule.

Measured on the #2813 fixture, this commit alone: the two IMPLEMENTS assertions
flip to passing (6 pass, up from 4) while the five interface-dispatch fan-out
assertions still fail — those are D2, fixed in the next commit. Keeping the two
commits separate is what makes that attribution visible.

Go unit suite: 91 passed.

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

* fix(resolution): fan out interface dispatch from a compound receiver (#2813)

D2 of two stacked defects, and the one that closes the issue. Case 0
(compound receiver) emitted its primary edge and short-circuited without the
interface-dispatch fan-out that Case 4 performs, so a call whose receiver is a
struct FIELD stopped at the interface's method DECLARATION and never reached
any implementation.

The gap was a property of receiver SYNTAX rather than of types. Case 0 is
selected by `receiverName.includes('.')`, so a field receiver (`s.orderRepo`)
always lands there, while the very same interface reached through a local or a
parameter is a bare name and falls through to Case 4 — which fans out
correctly. Field-held interfaces, i.e. dependency injection, were the half that
silently lost every implementation edge; the pre-existing fixtures exercise the
local and parameter forms only, which is why the suite was green.

The fix is the call Case 4 already makes, placed after Case 0's primary
`tryEmitEdge` and before its `handledSites.add`. It stays language-agnostic
(AGENTS.md section 42): `emitInterfaceDispatchFor` self-gates on
`ownerDef.type !== 'Interface'`, so a receiver that folds to a Struct emits
nothing extra and no language check is needed. Confidence is Case 0's own 0.85
literal, not Case 4's site.kind-dependent value — Case 0 has no read/write arm
to mirror.

The case ladder itself is untouched: invariant I4 in contract/scope-resolver.ts
makes the ordering a contract, so the fan-out is added INSIDE Case 0 rather
than by reordering or merging cases.

Also flips a second, previously unnoticed encoding of the #1966 value-only
reading that the full sweep surfaced: the exact-set assertion at
go.test.ts:361 enumerates every structural IMPLEMENTS edge, and D1 correctly
adds `PointerOnlyThing -> PointerOnly` to it. It is D1 fallout rather than D2's,
but D1 had already landed; recording it here with its reason beats amending a
commit whose separate measurability is the point.

Measured:
  - #2813 suite: 11 of 11 pass (was 7 failing after D1 alone, which fixed only
    the two IMPLEMENTS rows).
  - go.test.ts: 160 passed.
  - Full cross-language sweep, test/integration/resolvers: 3027 passed,
    1 skipped, across 52 files. The single failure in that run was the
    exact-set assertion above, fixed here; no other language regressed.

`detect_changes` rates this HIGH (6 affected flows, all EmitReceiverBoundCalls
at step 1) — inherent to editing a hub symbol in the resolution pipeline. The
sweep above is the empirical answer to that label.

An existing index must be re-analyzed to show the new edges; this changes what
the resolver produces, not how it is stored, so no SCHEMA_BUMP applies.

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

* test(go): pin the heritage edges that make impact() hedge an interface-bound count (#2813)

The epistemic half of the issue, resolved by MEASUREMENT rather than by new
code, and pinned at its mechanism.

The reporter's disqualifying complaint was that `impact()` reported
`impactedCount 0, epistemic "exact", risk LOW` for a method reachable only
through an interface-typed field — byte-identical to what it reports for a
symbol that genuinely has no callers. A zero therefore could not be used
defensively, which was the entire use case.

That verdict comes from `computeEpistemicBoundary`, which has two producers and
neither fired: the call sites were not DROPPED (they resolved, just to the
interface declaration, so the #2744 receiver-typing producer saw nothing), and
its heritage probe walks IMPLEMENTS/METHOD_IMPLEMENTS edges out of the queried
symbol — of which there were none, because the pointer-receiver exclusion (D1)
meant no such edge was ever emitted.

Restoring those edges fixes the epistemics as a side effect, so the planned
conditional change to local-backend.ts is NOT needed. Measured on this fixture
against the fixed tree:

  impact(OrderRepo.DeleteItem, upstream)
    before: impactedCount 0,  epistemic "exact"
    after:  impactedCount 3,  epistemic "lower-bound", with an interface
            boundary note; the three callers are OrderHandlers.Delete,
            PickService.StartSession and WaveService.Release — all correct.

  impact(CartRepo.Get, upstream)  [concrete receiver, no interface]
    after:  impactedCount 1,  epistemic "exact"

The second row is the one that matters for trust: the hedge discriminates
instead of firing on everything, so "exact" still means exact.

This test asserts the METHOD_IMPLEMENTS edges the probe walks. Pinning the
mechanism keeps the resolver suite from reaching into the MCP layer while still
failing loudly if the edges regress; the impact() numbers above are recorded in
the commit message and PR body rather than re-asserted here.

#2813 suite: 12 passed.

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

* fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813)

Replaces the approximate structural-interface model with the rules the Go spec
actually defines, so the graph answers what the compiler answers instead of a
useful-but-wrong summary of it. Three answers were provably wrong before; all
three are now exact and covered.

Method sets (go.dev/ref/spec#Method_sets):
  MS(T)  = methods declared with receiver T
  MS(*T) = methods declared with receiver *T OR T

Promotion (#Struct_types):
  S embeds T  -> MS(S) and MS(*S) get promoted methods with receiver T;
                 MS(*S) ALSO gets those with receiver *T
  S embeds *T -> MS(S) AND MS(*S) both get receiver T or *T

Identifier identity (#Uniqueness_of_identifiers): "Two identifiers are different
if they are spelled differently, OR IF THEY APPEAR IN DIFFERENT PACKAGES AND ARE
NOT EXPORTED."

  func (b *Base) Ping()      // pointer receiver
  type ByValue struct{ Base }
  type ByPointer struct{ *Base }

  type            before          exact answer
  Base            IMPLEMENTS      only *Base implements
  ByValue         IMPLEMENTS      only *ByValue implements
  ByPointer       IMPLEMENTS      the VALUE type implements

All three were the same edge. Two of the three were wrong, and nothing in the
graph could tell them apart.

Worse, in a different direction:

  package sealed;  type Sealed interface { seal() }
  package foreign; func (t *T) seal() {}

`foreign.T` cannot implement `sealed.Sealed` in Go — `seal` is unexported, so the
two identifiers are DIFFERENT. Matching on the bare name emitted a FALSE
IMPLEMENTS edge, and the interface-dispatch fan-out then turned it into an
impossible CALLS edge. That is the entire basis of the sealed-interface idiom.

- `methodSetKey` qualifies UNEXPORTED method names with their declaring package,
  leaving exported names unqualified (which is what makes cross-package
  satisfaction work at all). Exactness, not a heuristic: the sealed case now
  emits no edge, while the legitimate same-package implementor is retained.
- `collectStructMethodEntries` builds MS(T) and MS(*T) together and applies the
  promotion table above. The embed FORM is load-bearing, so it is now captured:
  `@reference.embedded-pointer` records `*T` versus `T`, which the parser
  previously discarded (the `*` is an unnamed token).
- Detection returns `{ structDefId, receiverForm }`. `receiverForm: 'pointer'`
  means the value type does NOT implement and only `*T` does — the fact
  `var x I = T{}` turns on.
- The form rides in the edge `reason` (`-structural-implements-pointer`).
  Relationships carry no arbitrary properties, so a new field would change the
  relation DDL, move SCHEMA_FINGERPRINT and force a rebuild for a fact a string
  already expresses. Value-form implementors keep the ORIGINAL unsuffixed
  reason, so a consumer matching the old string now sees exactly the assignable
  set — which is what that string always claimed to mean.

- `emitInterfaceDispatchFor` walks the SUBTYPE CLOSURE (IMPLEMENTS + EXTENDS) and
  skips bodiless declarations, instead of stopping at depth 1. Two reproduced
  Java shapes emitted an edge to a second abstract declaration while the only
  class with a body got nothing: a sub-interface that re-declares the method, and
  an abstract base between interface and implementation. Both now reach the
  implementation and neither emits the declaration edge.
- The fan-out is bounded by `MAX_INTERFACE_DISPATCH_FANOUT` (32,
  `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and reports what it dropped, mirroring
  `MAX_PROPERTY_DISPATCH_FANOUT`. A bare cap would silently discard valid dispatch
  targets, which is the same false-safe silence this issue is about.
- Corrects a rationale comment that was factually wrong about the code 70 lines
  above it (Case 0 DOES branch on `site.kind`, at :713-716; what it lacks is a
  read/write branch in its reason/confidence computation).
- Updates both copies of the case-ladder contract, which still described the
  fan-out as Case-4-exclusive.

The embed-pointer marker is PARSE-TIME capture emission, so a warm cache would
replay the pre-marker capture set and the distinction would never appear —
silently, the v27/v30 failure mode. 43 and not 40 because origin/main allocated
40, 41 and 42 while this branch was in review, which is exactly the window this
file's history records both prior EXACT clashes landing in. Pin moved with it.
RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGE.

- Go unit: 93 passed, including new rows pinning that `populateGoOwners` stamps
  `goReceiverKind` (previously the field had no reader and could rot silently)
  and that a pointer-receiver-only type implements in POINTER form only.
- Cross-language sweep, test/integration/resolvers: 3034 passed, 1 skipped,
  52 files, zero regressions.
- scope-capture bench: PASS (15 languages). Go is the ONLY fingerprint that
  moved, which is the check that this is a Go capture change and not a
  cross-language regression; rebaselined with rationale.
- Also closes review gaps in this PR's own tests: the concrete-field control was
  vacuous with respect to the type gate (repointed at a struct that IS an
  implementor), the two-service-file row could not distinguish the two files it
  is named for (both ends now file-qualified), plus new rows for signature
  mismatch, emitted confidence, and an exact N-by-M fan-out bound.

An existing index must be re-analyzed; the schema bump forces it.

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-04 21:52:37 +01:00
Gergő Magyar
c1103f38f2
fix: type an inference-typed class field so it can act as a call receiver (#2807) (#2810)
* test(helpers): add the shared temp-repo lifecycle helper

`createTempDirPool` gives a suite one owner for its temp fixture repos —
create on demand, remove them all in one `afterAll` — instead of a hand-rolled
mkdtemp/rmSync pair per file. The PDG receiver pin added in the next commit
uses it.

Cherry-picked verbatim from ec36c6dda on the #2802 branch, where it was
extracted to collapse five hand-rolled cleanups. Identical content, so if both
branches land the add resolves as a duplicate rather than a divergence.

Refs #2807

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

* fix(typescript): type a class field from its initializer so it can be a receiver

A field whose type had to be inferred from its initializer produced no CALLS
edge at all — not a truncated chain, nothing. `this.p.inner().compute(x)` lost
`Outer.inner` too, an ordinary named-receiver call, because `typeOfMemberOnClass`
found no `typeBindings` entry for `p` and `foldReceiverChain` declines at its
first untypeable step rather than folding on a guessed owner.

The initializer was never invisible: `new Outer()` emitted its own constructor
edge exactly as the annotated twin does. What was missing was the step turning
that initializer into a TYPE BINDING, i.e. capture patterns for the two shapes
the query never covered:

  private p = new Outer();                       // public_field_definition value:
  private p; constructor() { this.p = new … }    // this.<field> = new …

Both are `@type-binding.constructor`, so `annotation` still outranks them in
`typeBindingStrength` and an annotated field keeps resolving through its
annotation. The assignment form carries a narrow `@type-binding.this-field`
marker on its `(this)` node — anchorCaptureFor takes the broadest range, so the
statement stays the anchor — which `tsBindingScopeFor` reads to hoist the
binding onto the Class scope, the only place `typeOfMemberOnClass` looks. The
marker must stay specific to that pattern: hoisting every constructor-inferred
binding would move method-local `const o = new Outer()` out of its own scope.

Kotlin and Swift needed no such pattern for the initializer form because one
grammar node (property_declaration) covers both a local and a stored property;
TypeScript splits them, and only the local half was ever covered.

Both self-diffing pins flip and gain rows: a method-assigned field, and a
deliberately mistyped `private p: Mismatch = new Outer()` that asserts the
source-strength tie-break executably. That row also pins a pre-existing
artifact — `Inner.compute` still resolves through the hoisted module-level
return-type binding — verified byte-identical on the pre-fix tree.

Fixes #2807

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

* fix(javascript): type a class field from its initializer so it can be a receiver

JavaScript has no field annotations at all, so a class field's type can only
ever come from its initializer — which made this the strictly worse half of
#2807: `class C { p = new Outer(); }` gave `this.p` no type, and
`this.p.inner()` emitted nothing.

`synthesizeConstructorFieldBindings` in captures.ts already covered the sibling
shape, `this.p = new Outer()`, which is why THAT row resolved — but it only
walks `constructor` bodies, so a field initialized at its declaration matched
no pattern anywhere.

Adds the `field_definition` + `value: (new_expression)` patterns (the JS grammar
names the field `property:`, not `name:`), anchored so the binding lands in the
class body scope where `typeOfMemberOnClass` reads it. No hook change needed:
`jsBindingScopeFor` already delegates to `tsBindingScopeFor`, so it inherits the
`@type-binding.this-field` branch too.

Measured: `InferredField.run` now emits `Outer.inner`, exact parity with both
the local-const control and the constructor-assigned row. The second chain link
(`Inner.compute`) stays absent in ALL THREE rows — that is JavaScript's separate
return-type-inference gap, not this one.

Refs #2807

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

* fix(python): infer an instance field's type from the constructor it calls

`self.outer = Outer()` in `__init__` bound nothing, so `self.outer.inner()`
had no receiver type and the fold declined the whole chain — the Python half
of #2807. An annotated field (`self.outer: Outer = ...`) or one assigned from
an annotated parameter already worked.

`synthesizeConstructorFieldTypeBindings` deliberately refused to infer "from
arbitrary unannotated RHS expressions ... not a name-only guess". A CALL is not
that: Python has no `new`, so a call to a plain (or dotted) name is the only
syntactic construction form there is, and it is the same positive evidence
every other language reads from `= new X()`. A bare name, subscript, await or
comprehension is still refused.

Adds it as a THIRD and weakest tier. The existing explicit/parameter boolean
becomes a rank, so precedence is now explicit annotation > parameter annotation
> construction, and a later same-tier assignment still wins (the last write in
`__init__` is the live one). `interpretPythonTypeBinding` maps the new marker to
`constructor-inferred` (strength 1) — checked before the parameter branch, which
would otherwise have read the absent parameter marker as `annotation` and
promoted a guess to the strongest tier.

The Class-scope hoist needed no change: `@type-binding.instance-field` already
carries it in `pythonBindingScopeFor`.

Measured: `AssignedField.run` now emits `Outer.inner`, exact parity with the
annotated-field and local-const rows.

Refs #2807

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

* fix(ruby): infer an instance variable's type from the constructor it calls

`@service = UserService.new` in `initialize` bound nothing, so `@service.inner`
had no receiver type and the fold declined the whole chain — the Ruby half of
#2807. An instance variable is the ONLY way a Ruby object gets a field, and
Ruby has no annotations, so this was the single shape that could have worked
and did not: the existing constructor-inferred patterns bind a local
(`x = Foo.new`) and a constant (`SERVICE = Foo.new`), never an ivar.

Adds the plain and `Foo::Bar` qualified ivar forms. `@type-binding.name` is
captured on the `instance_variable` node so the bound name keeps its `@` sigil
and matches the receiver text at the call site verbatim — the resolver compares
spellings, and `service` would never have matched `@service`.

`rubyBindingScopeFor` gains a Class hoist gated on a narrow
`@type-binding.ivar-field` marker riding the same node: an ivar declares a field
of the enclosing class, so the binding must live on the Class scope or no other
method can see it. Gated on the dedicated marker, never on
`@type-binding.constructor` at large, which also fires for `x = Foo.new` locals
that must stay in their own method.

Measured: `AssignedField.run` now emits BOTH chain links, exact parity with the
local-const control.

Refs #2807

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

* test(resolvers): pin inference-typed field receivers across eight languages

#2807 was filed against TypeScript, but the defect class is cross-language:
"can a field whose type is inferred act as a call receiver". This measures all
eight languages where the shape exists at all, in one table.

The metric is parity with EACH LANGUAGE'S OWN CONTROL ROW, not "both chain
links present". JavaScript, Python, Dart and PHP lose the second link
(`Inner.compute`) even for a plain local, because nothing annotates `inner()`'s
return type — a separate return-type-inference gap. Scoring against "both
links" would have accused those four of a bug they do not have; scoring against
their own control isolates the field-typing question cleanly.

Recorded state: TypeScript, JavaScript, Python and Ruby now match their
controls. Kotlin and PHP already did before #2807 and are pinned so the shared
fold cannot regress them unnoticed — the languages that got receiver typing for
free are precisely the ones nobody re-checks.

Two rows stay pinned BROKEN, at their exact current value:

  Dart  — real and narrow: the annotated control resolves, the inferred one
          does not. Its bindings are synthesized in dart/captures.ts rather
          than by a query, so the fix is its own change.
  Swift — blocked by a different defect found while measuring: with several
          classes each defining `run`, every `run`'s edges are attributed to
          the FIRST-declared one, which collects duplicates while its siblings
          — including the ANNOTATED control — collect none. Receiver typing
          cannot be measured there until that is fixed, and "fixing" it against
          this observable would be fitting to a broken measurement.

Both gap rows carry a `callerExists` probe in the same assertion object, so an
empty list can never read as "resolved fine, wrong node id", plus a whole-matrix
guard that every language keeps a resolving control — that is what makes a gap
row mean "broken" instead of "fixture never worked".

Targets are deduplicated before comparison: Swift emits one edge more than once
per call site, and edge multiplicity is a different question from whether the
receiver typed at all.

Refs #2807

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

* fix(python): a method call on the receiver is not a construction

Review finding on f4e1ead0d. `constructorCallTypeName` accepted ANY call with
an identifier or attribute callee, so `self.p = self.build()` bound `p` to the
non-type `"self.build"` — and because that shares the weakest tier with a real
construction, a later such assignment DISPLACED an earlier `self.p = Outer()`
and left the field untyped again.

Measured before the fix: `self.p = Outer()` followed by `self.p = self.rebuild()`
emitted no CALLS edge at all from a method chaining off `self.p`, and
`self.q = self.make()` bound a type name that resolves to nothing. After:
the real construction survives the reassignment, and a pure method call binds
nothing rather than something wrong.

Rejects a callee rooted at the receiver name. `models.Outer()` still binds —
only `self`-rooted callees are refused, which is exactly the method-call shape.

The matrix gains a `reassigned-from-method-call` row that fails without this
rejection; that discrimination is the only reason the row exists.

Refs #2807

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

* fix(swift): resolve a method def to its own node when the labels disagree

Two classes in one Swift file each declaring `func run` collapsed onto one
node: every call in BOTH bodies was attributed to whichever `run` registered
first, which collected duplicate edges while its twin collected none. Renaming
one method fixed it; moving it to another file fixed it; so the collision was
name-keyed and per-file, not positional.

Root cause is a LABEL split, not a name. Swift's structure phase emits a type's
methods as `Function` nodes, while the scope extractor derives `Method` from the
`@declaration.method` anchor. Every key in `resolveDefGraphId` — qualified,
parameter-types, arity, shape — is label-scoped, so such a pair misses all of
them and lands on the bottom fallback, `simpleKey(filePath, name)`, which is
deliberately label-agnostic and first-write-wins.

Fixed at both ends:

  - Swift qualifies a method def as `<Type>.<method>`, matching the qualifier
    the structure phase already encoded in the node id. `class`, `struct` and
    `extension` all parse to `class_declaration`, so one ancestor walk covers
    them; a generic `class Box<T>` and an `extension Foo` wrapping a `user_type`
    both reduce to the bare owner name.
  - The bridge retries the qualified keys under the sibling callable label.
    Gated on the name containing a dot: `A.run` names one construct whatever the
    label, while a bare `run` is exactly the top-level-vs-method aliasing the
    label was added to prevent, so the original guarantee is untouched.

This also unmasked Swift's #2807 row. `let p = Outer()` had always bound
correctly — its edges were being credited to the wrong caller, so the
inference-typed receiver looked broken when it was not. `InferredField.run` now
emits `Outer.inner`, matching its control.

Verified on the full resolver + CFG suite: 3165 passed, 0 failed, against a
3164-passing baseline.

Refs #2807

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

* fix(dart): declare inference-typed class fields so they can be receivers

`var b = Outer();` produced no `@declaration.property` capture at all — no
Property node, and nothing for the capture layer to hang a type binding on — so
`b.inner()` could not type its receiver while the annotated twin
`Outer b = Outer();` resolved fine (#2807).

The gap was in the query, one layer below where the binding is emitted: both
class-field patterns require a leading `(type_identifier)` or `(nullable_type)`,
i.e. a WRITTEN type. Dart puts the keyword there instead for an inferred field,
and spells it two ways — `inferred_type` for `var`, `final_builtin` for `final`
and `late final`. Covering only `var` would have left the more idiomatic Dart
style broken, so both are matched.

With the field declared, the capture layer types it from the constructor its
initializer calls, as `constructor-inferred` — the weakest source, and the
annotated branch returns before it, so an annotated field is untouched. Only a
direct construction is accepted (a bare identifier followed by a `selector`
carrying an `argument_part`, the same shape `findDirectCallValue` accepts for
locals); a literal, member call or await is left alone rather than guessed at.

Note this is the LOCAL/field split that made the gap invisible: `emitVarTypeBinding`
already handled `initialized_variable_definition`, but a class field is
`declaration(<keyword>, initialized_identifier_list(initialized_identifier))`.

`InferredField.run` now emits `Outer.inner`, matching its control. Dart's
`var r; C() { r = Outer(); }` shape stays pinned as a known gap: Dart writes the
field with no receiver prefix, so binding it means treating assignment to a bare
identifier as a field write, indistinguishable from a constructor-local.

Refs #2807

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

* test(resolvers): record Swift and Dart reaching parity in the matrix

Both languages' inference-typed field rows move from KNOWN GAP to resolving,
which is the self-diffing signal this file was built to produce: closing either
gap failed it with the newly resolved ids in the diff.

The header table and prose are corrected together with the rows, as the file's
own instructions require — including WHY Swift moved. Its `let p = Outer()`
binding had always been correct; a separate label-split defect attributed the
second same-named method's calls to the first, which masked this row entirely.
Recording that is the point: a future reader comparing the table against the
code needs to know the row was never a receiver-typing failure.

One row stays pinned: Dart's `var r; C() { r = Outer(); }`. Dart writes fields
without a receiver prefix, so binding it means treating assignment to a bare
identifier as a field write — indistinguishable from a constructor-local until
the field set is known. Idiomatic Dart writes `final r = Outer();`, which the
inferred-field row now covers.

Every language keeps its resolving control row, so the remaining gap still means
"broken" rather than "fixture never worked".

Refs #2807

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

* fix(dart): type a field from a constructor assigned to it

`var r; C() { r = Outer(); }` bound nothing, so `r.inner()` had no receiver
type — the last inference-typed field shape still failing after the initializer
form was fixed (#2807).

Dart is the one language here that writes a field with NO receiver prefix, so
`r = Outer()` inside a constructor is syntactically identical to assigning a
constructor-local. That ambiguity is why this was initially left pinned — but
the field set IS knowable: the class body declares `var r`, which the
initializer fix already turned into a property declaration. So a bare name binds
exactly when Dart itself resolves it to the field: the enclosing class declares
it AND the enclosing body declares no local of that name. A `this.`-prefixed
write is unambiguous and needs neither test.

The shadowing case is asserted, not assumed: with a body-local `var s = Outer()`
in scope, the field stays unbound while the local still resolves on its own.

Binds `constructor-inferred` (weakest source, so an annotation still wins), and
only for a direct construction — an identifier followed by a `selector` carrying
an `argument_part`, the same shape accepted for locals. The narrow
`@type-binding.dart-field` marker drives the Class-scope hoist in
`dartBindingScopeFor`; gating on it rather than on `@type-binding.constructor`
at large is what keeps genuine locals in their own scope.

All three shapes now match their control: bare `r = Outer()`, `this.s = …`, and
a non-constructor `setUp()` assignment.

Refs #2807

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

* fix(swift): type an optional field and read through its force-unwrap

Swift cannot declare a stored property with neither a type nor an initializer,
so its "declare now, assign in init" idiom is an OPTIONAL field read back
through a force-unwrap. That shape resolved nothing, and it was broken in two
independent places — each alone leaves it broken:

  1. `var a: Outer?` parses as `type_annotation(optional_type(user_type(…)))`,
     but the property-annotation pattern required the `user_type` to be a DIRECT
     child, so an optional field was never typed at all. The pattern added here
     captures the INNER `type_identifier`, so the binding is `Outer` without
     relying on `stripOptional` reducing an `Outer?` spelling.
  2. `self.a!` is a `postfix_expression`, which the receiver walk did not peel,
     so even a typed field could not be read through the unwrap.

For (2), `postfix_expression` is NOT added to `TRANSPARENT_RECEIVER_WRAPPERS`
outright: unlike TypeScript's `non_null_expression` — which is only ever `!` —
Swift's node also carries user-defined postfix operators, which can return
anything. Peeling those would type the receiver as the operand and mint a
confidently WRONG owner, the failure mode compound-receiver.ts calls strictly
worse than no edge. So the peel is operator-gated: transparent only when the
node's text ends in `!`, which is provably type-preserving.

Verified: force-unwrap `self.a!.inner()`, optional chain `self.b?.inner()`, and
the plain annotated field all resolve; previously only the plain one did.

The gate keeps this off every other language — `postfix_expression` is not a
node type the other grammars produce here — and the full resolver + CFG suite is
green at 3166 passed / 0 failed, against a 3165 baseline.

Refs #2807

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

* test(resolvers): close the last two matrix gaps

Dart's `assigned-field` and Swift's new `optional-assigned-field` rows now
resolve, leaving no known-gap row in the matrix: every language reaches parity
with its own control on both the initializer and the assigned shape it can
express.

The Swift row is new because the shape it covers did not exist in the fixture:
Swift cannot declare a stored property with neither type nor initializer, so its
assigned form is an optional field written in `init` and read through a
force-unwrap — a shape that needed both an optional-annotation pattern and an
operator-gated receiver peel, which is why the row's comment names both.

The header records how the two hard cases were fixed, including the Dart
shadowing rule the fix depends on: a bare `r = Outer()` binds only when the class
declares that field and the body declares no local of the same name.

Refs #2807

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

* chore(bench): rebaseline the receiver-resolution and scope-capture gates

Both gates are exact-match, so the improvements in this branch fail CI until the
baselines move and the movement is explained. Caught by running the CI gates
locally — the resolver and CFG suites are green throughout and never see these.

receiver-resolution — three shapes moved to RESOLVES, no drop-count changed:

  ruby.fieldReceiverCall     INVISIBLE-GAP -> RESOLVES  (`@ivar = Foo.new`)
  swift.decoratedFieldType   INVISIBLE-GAP -> RESOLVES  (`var a: Outer?`)
  kotlin.nonNullAssert       VISIBLE-GAP   -> RESOLVES  (`x!!` receiver)

scope-capture — swift and typescript fingerprints, both ADD captures and remove
none; the per-language `_rebaselined_inferred_field_receiver_2807` notes carry
the detail and the prior digests. The other 13 languages are unchanged, which is
the check that this is the intended emission and not a capture regression.

CORRECTION to d5d878033's message, which claimed the operator-gated
`postfix_expression` peel "keeps this off every other language — postfix_expression
is not a node type the other grammars produce here". That is wrong: Kotlin's
grammar produces it too, and `kotlin.nonNullAssert` moving to RESOLVES is the
proof. The peel is still correct there — Kotlin `!!` is a non-null assertion with
exactly the type-preserving semantics the `!` gate tests for — but it is a
BEHAVIOUR CHANGE IN KOTLIN, not Swift-only as stated. The gate is what surfaced
it; the claim should have been verified rather than asserted.

Refs #2807

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

* fix(cache): bump SCHEMA_BUMP for the six-language capture change, + review fixes

SCHEMA_BUMP 39 -> 40. THIS IS THE MERGE-BLOCKER of the review: every language
change in this PR is PARSE-TIME capture emission, and `analyze` skips tree-sitter
dispatch for byte-unchanged chunks (GUARDRAILS.md:34), so a warm cache replays
the pre-fix capture set verbatim and the new receiver edges never appear —
silently, no error. Exactly the v27/v30 failure mode this file already documents.
The PR description's claim that "no schema or version constant applies" was
wrong on both counts: a bump IS required, and a plain re-analyze does NOT
surface the captures without it. Re-check against origin/main before merging —
main was also at 39 when 40 was allocated, and this file records eight prior
collisions.

Also from the review:

- dart/simple-hooks.ts hand-rolled a 9-line parent walk byte-identical to the
  shared `walkToScope(innermost, tree, 'Class')` that TypeScript and Ruby call
  in one line in this same PR. Now uses the helper.
- utils/call-analysis.ts: the doc framed the postfix-`!` peel as Swift-only. It
  is not — Kotlin `!!` parses as the same node and is peeled too, which the
  receiver-resolution bench proved (kotlin.nonNullAssert VISIBLE-GAP ->
  RESOLVES). The comment now says so, and names the `!` gate rather than the
  language as the bound.
- test/helpers/temp-dir-pool.ts: its doc claimed four consumers; on THIS branch
  only `pdg-chained-receiver-callees` uses it (the other three convert on
  #2802). Corrected, and the byte-identical-to-#2802 intent recorded.
- inferred-field-receiver-matrix: adds the Dart shadowing assertion the header
  comment already CLAIMED to make but never did. First attempt was vacuous —
  `var s = Outer()` is a declaration, so it never produced the bare
  `assignment_expression` the guard inspects; removing the guard did not fail
  the row. Fixture corrected to `var s; s = Outer();`, and mutation-verified:
  guard present 35 pass, guard removed the row goes red.

Refs #2807

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

* test(cache): move the SCHEMA_BUMP pin to 40

The pin at incremental-parse-cache.test.ts asserts the exact value on purpose —
it exists to catch two branches claiming one number, and it has earned that
eight times. Bumping the constant to 40 without moving the pin turned it red.

Found by the Codex (gpt-5.6-sol) review leg, which flagged it as a
deterministic committed-test failure. The Claude lanes could not have caught it:
they were dispatched before the bump landed.

The comment now records the 39 -> 40 movement and its reason, matching the
existing convention in that block.

Refs #2807

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

* fix(dart): treat every binder as a field shadow, not just local declarations

Review P1, reproduced by two independent reviewers. `emitDartFieldAssignmentBindings`
binds a bare `r = Outer()` to the FIELD when the class declares `r` and the body
declares no local `r` — but the shadow set was built by walking for
`initialized_variable_definition` only. That is one binder form out of many, so a
formal PARAMETER named like a field slipped through:

    void reset(Alpha r) { r = Alpha(); }   // r is the PARAMETER

retyped the FIELD to `Alpha`, fabricating an edge AND destroying the correct
`Beta` binding the constructor had established. The mutation test shows exactly
that: the pre-fix result is not a missing edge but a WRONG one (`Other.inner#0`
instead of `Outer.inner#0`) — the failure mode compound-receiver.ts:519-537 calls
strictly worse than no edge.

The node types were chosen from real grammar output, not assumed. Two facts drove
the design: formal parameters live on the SIBLING `method_signature`, never inside
`function_body`, so no walk of the body could ever have seen them; and
`formal_parameter` carries a `name` field only when typed — untyped, `this.` and
`super.` forms do not. `collectDartBodyShadows` therefore walks the signature AND
the body, collecting formal/closure/local-function/named/optional params,
`this.`/`super.` constructor params, catch bindings, for-in variables, and both
local-declarator forms. A parameter shape whose name cannot be read contributes
nothing — declining to bind is the safe direction.

A 27-case binder sweep passes: 26 shadow shapes bind nothing, the no-binder
control still binds.

Four new matrix rows (param, closure param, catch, loop var) assert a surviving
POSITIVE target rather than an empty list — deliberately, because the pre-fix
value is a different non-empty target, so these rows cannot pass vacuously the way
an empty-assert row can. Mutation-verified: reverting captures.ts turns exactly
those four red and leaves every pre-existing row green.

SCHEMA_BUMP is already at 40 on this branch for the six-language capture change and
has not shipped, so it covers this too; re-check against origin/main before merge.

Refs #2807

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

* fix(ruby): don't bind a class-object @ivar as an instance field

Review P1. The `@ivar = Foo.new` patterns added on this branch hoist to the
enclosing Class scope without asking WHOSE `self` owns the ivar. In Ruby an ivar
written in singleton context belongs to the class object, not to instances, so

    def self.build; @pool = Alpha.new; end
    class << self; def make; @cache = Alpha.new; end; end

bound `@pool`/`@cache` as INSTANCE fields, fabricating edges from instance methods
that read an ivar which is never assigned on an instance.

Three corrections came out of fixing it:

1. The detection premise was wrong. `def self.build` is NOT a `method` node with a
   `self` receiver — it is its own node type, `singleton_method`, and
   `childForFieldName('receiver')` returns NONE on it. Matching on a receiver field
   would have detected nothing, silently. Detection is by node type:
   `singleton_method` / `singleton_class`.

2. A THIRD form exists that the review did not name: a class-body-level
   `class C; @shared = Outer.new;` is the same defect (self is the class object),
   and is likewise new on this branch — before it, `left: (instance_variable)`
   matched nothing at all.

3. Dropping only the `@type-binding.ivar-field` marker is NOT sufficient, and the
   class-body case is what proves it: with the marker gone the binding falls back
   to its innermost scope, which at class-body level ALREADY IS the Class scope, so
   it still lands in the wrong place. The whole match is therefore discarded.

The check lives in `languages/ruby/captures.ts` because `Capture` carries only
`{name, range, text}` — no AST node — so `rubyBindingScopeFor` structurally cannot
ask whose `self` owns the ivar. All Ruby logic stays under `languages/ruby/`.
`method` alone is not a sufficient "instance" signal, since a `def` inside
`class << self` is reached through a `method` node first.

Cost relative to main is zero: a class-object ivar goes back to binding nothing,
exactly as before these patterns existed.

The three new rows are structurally two-sided, not just mutation-checked: each
empty row is paired with a non-empty `*-instance-ivar` row on the SAME fixture
class, so breaking the hoist entirely turns the partner red while an unconditional
hoist turns the empty row red. Mutation-verified in both directions.

Refs #2807

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

* fix(typescript,javascript): bind `this.field = new X()` only inside a class method

Review P0, the most serious finding of the tri-review and reproduced by two
independent reviewers. The `this.<field> = new X()` patterns added on this branch
were CONTEXT-FREE: they matched anywhere in the file, and `tsBindingScopeFor`
hoisted to the nearest enclosing Class without asking whose `this` that was. Since
the binding lands on the same Class scope with the same `constructor-inferred`
source as the field-initializer pattern, and pass4CollectTypeBindings prefers the
later match on `>=`, it OVERWROTE the class's real field type. Reproduced from a
non-arrow callback, an object-literal method, a static method, and module level.

Fixed STRUCTURALLY, in the query: both patterns are now nested under
`class_body -> method_definition -> body: (statement_block) -> (expression_statement)`,
which kills the callback, object-literal and top-level triggers with no runtime
code and mirrors JavaScript's `synthesizeConstructorFieldBindings` discipline.
TypeScript still accepts ANY method, not just `constructor`, so the setter case
this branch deliberately supports keeps working.

`static` needed one emit-side guard: it is an ANONYMOUS token on `method_definition`
with no field name, and tree-sitter patterns cannot negate an anonymous token
(checked against node-types.json), so `isStaticMethodThis` drops it in captures.ts.
`simple-hooks.ts` is comment-only — the unconditional Class hoist is now documented
as safe BECAUSE the marker's producers are bounded, with a note that widening them
means re-establishing that.

Also fixes a `.ts`/`.js` disagreement the narrowing itself created: JavaScript's
synthesis matched `method_definition` ANYWHERE, so an object literal containing a
method named `constructor` still typed the enclosing class's field. Measured on
identical source — JS emitted `p -> Alien`, narrowed TS emitted nothing — and
closed with a `node.parent?.type !== 'class_body'` guard in javascript/captures.ts.
The two languages must not disagree about the same source.

Deliberately NOT matched (a missing binding, never a wrong one — JS declines these
too): an assignment in a nested block, or inside an arrow where `this` genuinely IS
the instance.

Evidence the narrowing removed nothing legitimate: `bench/scope-capture --check`
passes with the TypeScript AND JavaScript fingerprints BYTE-IDENTICAL. The five new
matrix rows use an `Alien` class that also declares `inner()`, so a regression SWAPS
the target rather than emptying the set — they cannot pass vacuously. Mutation
test: reverting the source turns exactly those rows red (`+ "Alien.inner#0"`,
`- "Outer.inner#0"`).

SCHEMA_BUMP stays at 40 — this PR's existing bump covers the capture change being
narrowed, and the buggy variant never shipped outside this branch.

Refs #2807

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

* fix(resolution): consult the sibling callable label in the position key too

Review P1. This branch added a sibling Method<->Function retry to the qualified
keys in `resolveDefGraphId`, but not to the #2699 POSITION key or its fail-closed
guard, which both stayed scoped to `def.type`. Since the premise of the whole fix
is that Swift defs are `Method` while nodes are `Function`, the position lookup
missed and the fail-closed guard could NEVER FIRE for exactly the case the retry
serves — so a function-local `func helper` inside `Host.run` was deterministically
aliased onto the class method `Host.helper`, even with differing arity.

Two earlier reviewers REFUTED this by arguing the guard runs before `lookupTagged`.
That is true and irrelevant: the guard is scoped to `def.type`, so in the
label-split case it is unreachable. Recording it because two independent lanes
agreeing on a refutation is not proof.

`siblingCallableLabel(label)` is now the single definition, consulted by all three
key families:
  - position key: retried under the sibling label, gated on `posHit === undefined`
    so an AMBIGUOUS_POSITION tombstone still falls through to the name keys rather
    than being resolved by relabelling. Deliberately NOT dot-gated — a position key
    is not a name, so the aliasing risk the dot gate exists for does not apply.
  - fail-closed guard: mirrored unconditionally (it only ever returns undefined).
  - qualified retry: dot gate untouched.

Measured before -> after on a Swift fixture: `Host.helper#1 -> sink` (the local
body's call credited to the public 1-arg method) becomes
`Host.run.helper@8:8#2 -> sink`, with the local's own node no longer edgeless.

SCOPE CORRECTION to the P1 report: only the first consequence is a bridge defect.
The second — "`run`'s call to the local resolves to the method" — is NOT reachable
from ids.ts. Both defs carry qualifiedName `Host.helper` and label `Method`, and
the binding hands the target side the class-member def, so the scope walk in
free-call-fallback picks the member. No def->node mapping can change that; it is
pinned as an explicitly labelled KNOWN GAP rather than left implied.

Verification, on shared code so the full bar: resolvers+cfg 3170 passed / 1 skipped
/ 0 failed; `bench/receiver-resolution --check` OK; `bench/scope-capture --check`
PASS (15 languages, Swift fingerprint unchanged) — i.e. the bridge change altered
no capture output. The 3170 reconciles against the 3167 pre-existing at a5bf4c2da
plus exactly 3 new tests; 3167 differs from the older 3166 baseline because
0418b0aac added the matrix's only known-gap row, which emits one extra `it`.

Mutation test: with both arms reverted, 3 of the 5 new cases go red, each arm
pinned independently — the guard case registers no position key, the position case
registers no local-name key.

Refs #2807

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

* refactor: apply cleanup-review findings across the receiver-typing change

Four parallel quality lanes (reuse, simplification, efficiency, altitude) over
`origin/main...HEAD`. Eleven fixes; both exact-match bench gates hold with every
capture fingerprint BYTE-IDENTICAL, so none of this changed what the analyser emits.

Reuse — stop re-rolling helpers that already exist:
- `walkToScope` moved out of the TypeScript provider into a language-neutral
  `utils/scope-tree-walk.ts`. Ruby and Dart had begun importing it FROM
  `languages/typescript/`, which made three unrelated providers depend on the TS
  module for a generic `Scope`/`ScopeTree` walk. Python's hand-rolled copy — the
  one this PR's new `self.x = Outer()` path routes through — is folded in, so all
  six languages now share one traversal.
- Swift stops string-parsing a type name. `swiftEnclosingTypeName` split on `<`
  and `.`; `swiftBaseTypeIdentifier` + `swiftQualifiedBaseTail` do it structurally
  and correctly skip the sibling `type_arguments` node, which the string form only
  guessed at. `findEnclosingTypeDeclaration` replaces the inlined ancestor walk.
- TypeScript uses the canonical `hasKeyword(method, 'static')`. The previous
  `child.type === 'static'` is the exact form `isStaticMember` documents as
  grammar-version-fragile: "`static` can appear as an unnamed token or as a
  keyword node depending on grammar version; check text."
- Both new test suites use `cleanupTempDirSync`, which exists because a pipeline
  test's open handle surfaces as EBUSY/EPERM on Windows and `force` does not
  suppress it. This repo shards Windows CI.

LATENT DEFECT, found by the reuse lane and fixed: `var a = X(), b = Y();` parses
as ONE `declaration` with two declarators, and the query matches it once per
declarator with the SAME node — so the first-descendant search handed every
declarator the FIRST one's initializer. `b` resolved as `X`. Now reads
`nameNode.nextNamedSibling`, which is both correct and free. Pinned by a
`multi-declarator-inferred-field` row ordered so the declarator under test is the
second; reverting the fix turns exactly that row red with the wrong edge.

Efficiency — measured, not asserted:
- Dart's shadow set was built eagerly for EVERY method body and discarded 87-100%
  of the time (a `this.`-prefixed write never reads it). Now lazy and memoised per
  body, gated on `fields.has()`. Semantics are unchanged: the set is body-wide, so
  deferring construction cannot change its contents.
  Worth recording WHY CI could never have caught this: `bench/scope-capture` gates
  the SCALING RATIO, and the work is linear — ratio stays 1.0 against a 1.5 budget
  while a constant-factor regression passes straight through.
- `isTransparentReceiverWrapper` crossed the `node.type` native getter twice on the
  common path. One hoisted read, and — since absent and ungated are distinguishable —
  one `get` replaces `has`+`get`.

Simplification:
- One `Map<string, string | null>` replaces the parallel Set + Map that both
  expressed "this wrapper is transparent", with `null` meaning unconditional.
- `ids.ts` computed `siblingCallableLabel` twice under two names. The three retry
  blocks are deliberately NOT collapsed — they use different key builders and
  materially different gates.
- Python's `interpret.ts` nesting was only a consequence of arm ORDER; swapping the
  arms is unconditionally equivalent (the two differ only when both markers are
  present, and both orders then yield `constructor-inferred`).
- One `isDirectConstruction` predicate replaces the construction-shape test that
  had been written four times in dart/captures.ts.

Deliberately NOT done, each needing a fingerprint rebaseline or new node ids:
unifying the six `@type-binding.*-field` markers into one canonical capture (it
would change Python's anchor semantics, which must be verified not assumed); a
Swift `labelOverride` mirroring Kotlin's four-line fix, which is the real cure for
the Method/Function split the bridge currently compensates for; generalising the
Swift optional-annotation pattern to `(type_annotation (_))` so the existing
strippers handle every wrapper; and merging the TS query with the JS walker, which
also carries a JSDoc branch no query can express.

Refs #2807

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

* test(swift): regenerate the Swift captures golden for the optional-annotation pattern

CI caught what my local runs did not: `swift-captures-golden.test.ts` pins
`emitSwiftScopeCaptures` output across every `swift-*` fixture, and this branch
changes that output. It is a THIRD capture gate, separate from the two
exact-match benches already rebaselined here — `bench/scope-capture` hashes a
different corpus, so its Swift fingerprint moving did not imply this one, and
passing it was not evidence this was clean.

The drift is digest-only: 37 changed lines, 37 in each direction, no capture
entry added or removed. That is the expected shape for
`(type_annotation (optional_type (user_type …)))` making optional properties emit
an annotation binding they previously did not, plus the `@declaration.qualified_name`
now carried on Swift method declarations.

Regenerated with the mechanism the test itself prescribes (`UPDATE_GOLDEN=1`),
not by relaxing the assertion. Verified after: all Swift unit + resolver suites
green (4 files, 124 tests), and `bench/receiver-resolution --check` still exactly
matches its baseline.

Refs #2807

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

* fix: three more wrong-owner defects, found by a second review round

A second tri-review of this PR found THREE new P1 wrong-owner defects — every one
of them in code the FIRST round had already fixed. All three are the same root
shape: an incomplete ENUMERATION of binder or scope forms. That class has now
bitten this branch four times (formal parameters, then these), so two of the
three fixes below deliberately attack the class rather than the instance.

1. DART 3 PATTERN BINDERS (P1, reproduced by two independent lanes).
   `addDartBinderName` enumerated five binder node types, and every Dart 3
   pattern form parses into node types in NONE of them — so a pattern-bound local
   did not count as a shadow and its write retyped the CLASS FIELD:
       class Host {
         var session = Cache();
         void load() { final (session, count) = (Session(), 2); }
         void use() { session.ping(); }   // resolved Session.ping, not Cache.ping
       }
   The grammar hides every rule that would carry a binder (`_pattern_field`,
   `_list_pattern_element`, `_guarded_pattern`, …), inlining children onto the
   enclosing visible pattern node, so binders land as direct `identifier` children
   of just two leaf types. Covers all 10 pattern types that can hold one; the
   eight container types are defence, since the grammar demonstrably inlines
   identifiers onto containers already.
   THE COMPOUNDING PART: a grammar-derived coverage guard reads `nodeTypeInfo` and
   fails if the grammar declares a `*pattern*` type the fixtures do not exercise.
   A grammar bump adding an 11th type now turns the suite red instead of silently
   reopening this bug a third time.

2. RUBY BLOCK-RECEIVER `self` REBINDING (P1 here, both Claude lanes + Codex, which
   rated it P2 — the engines agreed the defect is real and disagreed on severity).
   `isRubyInstanceIvarWrite` enumerated `singleton_method`/`singleton_class` as the
   ways `self` gets rebound. A `def` inside a BLOCK attaches to the block's
   receiver, so `Struct.new(:x) do def warm; @a = Beta.new; end end`,
   `Class.new do … end`, `class_eval`, and `other.instance_eval { @a = … }` all
   published onto the nearest LEXICAL class.
   Deliberately NOT fixed by listing rebinding call names: that set is OPEN —
   `def helper(&blk) = Foo.class_eval(&blk)` rebinds a block it merely receives,
   and nothing in the block's own syntax reveals it. An allow-list of "safe"
   iterators would be the same defect one level down. The rule is structural:
   crossing ANY block boundary makes ownership unprovable, so discard. Complete by
   construction rather than by enumeration.
   ACCEPTED COST, asserted not hidden: `[1].each { @shared = X.new }` in an
   instance method really is the instance's `self`, and this drops it — that block
   is syntactically identical to the `instance_eval` one. It has its own row
   (`plain-block-self-ivar`) so the loss is visible rather than discovered later.

3. STATIC FIELD INITIALIZERS (P1, found by Codex/gpt-5.6-sol, corroborated).
   A `static` field initializer was captured as an ordinary instance binding, and
   since both land on one Class scope at the same `constructor-inferred` strength,
   the later wins the `>=` tie-break — so a static field retyped the instance
   field of the same name (`this.p.hit()` -> `Wrong.hit`). Unguarded in BOTH
   `javascript/query.ts` and `typescript/query.ts`; the existing
   `isStaticMethodThis` only ever covered the `this.x =` assignment form.
   Two things surfaced while fixing it: the TS `annotation` pattern collides
   identically and is PRE-EXISTING, not introduced here; and JS `static
   constructor(){}` had no guard where TS did — the .ts/.js divergence this PR's
   own comment claimed could not happen.
   Dart has no same-name twin (the language forbids it), but a static method's
   receiver-less write named a library-level variable and DISPLACED the
   constructor's binding. Fixed narrowly, with a counterweight row
   (`static-field-declaration-still-types-its-receiver`) that goes red if anyone
   widens the guard into "drop every static binding" — reading a static by bare
   name from an instance method is ordinary Dart and must keep working.
   ACCEPTED COST: `typeBindings` has one map per Class scope with no static/
   instance split, so a static field is dropped rather than recorded separately,
   losing typing on a TS/JS `Host.p.hit()` static receiver chain. Missed edge over
   wrong edge, per compound-receiver.ts:519-537.

Every new row asserts a SURVIVING POSITIVE target, never an empty set: the pre-fix
value in each case is a DIFFERENT non-empty target, so none can pass vacuously —
the trap this branch already fell into once. Mutation-verified per fix: reverting
each turns exactly its own rows red (17 Dart, 6 Ruby blocks, 5 static) with every
pre-existing row green.

Matrix 49 -> 80 tests. Siblings 510 passed. tsc clean. scope-capture PASS (15
languages, all ratios within gate).

Refs #2807

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

* fix(dart): mask a shadowed field on the READ side, not just the write side

The review critic refused to pass this PR while this was open, and it was right
to: this is the same wrong-owner shape as the three defects fixed in the previous
commit, except this one is introduced BY this PR rather than merely missed by it.

THE DEFECT. Typing an unannotated field from a constructor assignment
(`var conn; Host() { conn = Alpha(); }` binds `conn` on the CLASS scope) is this
PR's whole point. `emitDartFieldAssignmentBindings` correctly declines to WRITE
that binding when a member body rebinds the name — but the shadow set gated
writes ONLY. `collectDartBodyShadows` had exactly one call site, inside the
bare-name write branch. Nothing consulted it on the read side, so a bare-name
READ of a shadowing binder the resolver cannot type walked straight past the
local and hit the field binding this feature mints:

    class Host {
      var conn;
      Host() { conn = Alpha(); }
      void probe(List<Beta> xs) {
        for (final conn in xs) { conn.inner(); }   // conn is a Beta element
      }
    }
    // resolved Alpha.inner, not Beta.inner

Reproduced in SEVEN binder shapes, not the one the review reported: for-in
(`final` and `var`), untyped formal parameter, plain local `var`, catch binding,
closure parameter, and record pattern. Delete the constructor and the same read
emits NOTHING — which is what proves this PR introduced it. "No edge" became
"wrong edge", the one failure mode compound-receiver.ts:519-537 exists to prevent.

THE FIX uses `Scope.ownsReceivers` (#2701), the primitive that already exists for
exactly this, rather than inventing a mechanism. `scope/walkers.ts` consults
`typeBindings` FIRST at every scope and only then honours the mask, so a shadow
the resolver CAN type still wins — an annotated `void probe(Beta conn)` keeps
`Beta`, because `synthesizeDartSignatureBindings` anchors parameter bindings on
the same body node and they land on the same Function scope. The mask fires only
where the alternative was a fabricated type.

Plumbing follows TypeScript's `@receiver-owner.this` precedent: the marker rides
the same synthesized match as `@scope.function` and sits outside the `@scope.`
namespace so `anchorCaptureFor` cannot mistake it for the anchor. Dart differs
only in that its function scopes are synthesized in captures.ts rather than
declared in the .scm, so the names travel as capture TEXT — a `CaptureMatch`
carries no AST node, so the reader cannot re-derive them.

SCOPE, and the costs taken knowingly rather than hidden. The mask is
`shadows ∩ fields` and nothing wider. Masking every locally bound name would
also fix a library-level `var logger = Logger();` shadowed by a loop variable,
but it changes resolution for code this PR never touched. Three consequences are
documented on `dartShadowedFieldsCapture`, not buried: the wider case is left
open; an ANNOTATED field shadowed by a binder is masked too (correct Dart, but it
touches resolution predating #2807); and `mixin` bodies are reached, since the
grammar gives them a `class_body`.

PERFORMANCE, measured rather than asserted. The mask is emitted eagerly in Pass A,
where `collectDartBodyShadows` used to be lazy — the replaced comment recorded
87-100% of eagerly built sets being discarded, ~15% of Dart emission. Actual cost
on the scope-capture large corpus, median of 3: 405.6ms with the mask vs 390.0ms
without, ≈ +4%. Fingerprint and capture_groups are byte-identical across both
arms, so no corpus fixture emits a mask at all — that 4% is the cost of the CHECK
alone. Not visible to `bench/scope-capture`, which gates the scaling RATIO and is
blind to a linear constant factor; stated here because the gate cannot state it.
(3 samples per arm, blocked not interleaved — an estimate, not a rigorous number.)
A per-file memo keyed by node span makes both passes share one walk per body, so
the write side no longer pays a second one.

SCHEMA_BUMP 40 -> 41 with its exact-value pin, since capture emission changed.
Re-check against origin/main immediately before merge — main was 39 at commit time.

Mutation-verified both directions, which is the part that matters:
  - unwire `scopeOwnsReceivers`, rebuild -> exactly 2 rows red
    (`loop-var-read-does-not-see-the-field`, `pattern-read-does-not-see-the-field`),
    83/85 green.
  - over-widen the mask (drop the `shadows.has` test) -> 28 Dart rows red,
    including `unshadowed-read-in-a-shadowing-class-still-resolves`.
The three control rows stay green under the first mutation BY DESIGN — they guard
overreach, not the defect; the second mutation is what proves they are live. Pre/post
on the trigger row: `{Class:Alien, Alien.inner#0, Outer.inner#0}` -> `{Class:Alien,
Alien.inner#0}`, so no row can pass vacuously.

Matrix 80 -> 85 tests. Sweep 3220 passed (was 3215; exactly +5). tsc clean. All four
capture gates green: receiver-resolution OK, scope-capture PASS (15 languages, no
fingerprint moved, nothing rebaselined), callable-value-flow PASS, swift golden 9.

Refs #2807

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

* fix(ts,js): let each language name its own class-field node type

CI caught a defect the whole review round missed. `grammar-literal-validation`:

    1 dead grammar literal(s) found:
      - node-type "field_definition" — languages/typescript/captures.ts:0
        — not valid in [typescript]

`isStaticClassFieldBinding` held BOTH spellings in one set —
`public_field_definition` (TypeScript) and `field_definition` (JavaScript) — so
that one predicate could serve both languages. But the predicate lives in
`typescript/captures.ts`, and the gate checks every literal against the grammar
of the FILE it appears in. `field_definition` is not a TypeScript node type.

The literal was NOT dead code: `javascript/captures.ts:42` imports the predicate
and calls it against real JS nodes, so the guard worked. The gate is still right
to fail it, and for exactly the reason this predicate's own docblock gives for
preferring `hasKeyword` over a node-type test — "a node-type test silently stops
firing on a grammar bump and every static field starts retyping its instance
twin again". A literal already dead in its own file is that failure shipped
pre-broken: nothing in the TypeScript file would ever have told us.

Each language now names its own node type and passes it in
(`TS_CLASS_FIELD_DEFINITION_TYPES` / `JS_CLASS_FIELD_DEFINITION_TYPES`), so every
literal is checked against the grammar it belongs to. The `hasKeyword` logic and
the static/instance reasoning stay shared and unchanged — only the node-type set
moves to the caller.

WHY THE LOCAL SWEEP DID NOT CATCH IT: I ran `test/integration/resolvers` and
`test/integration/cfg`. The gate is `test/integration/grammar-literal-validation.
test.ts`, in the parent directory. Scoping a sweep to the subdirectories a change
touches is precisely how a cross-cutting gate gets skipped.

grammar-literal-validation 4 passed. tsc clean. Full `test/integration` +
`test/unit/scope-resolution`: 6305 passed, 14 failed — all 14 in e2e/environment
suites (fts-extension-e2e 9, analyze-heap-oom-e2e, cli-e2e,
analyze-wal-checkpoint-failure, plus interproc-taint and parse-impl-env-reads,
which BOTH pass in isolation and fail only under 28-worker load). CI runs the
same files green.

Refs #2807

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

* test(dart,ts): pin all seven read-side binder shapes; correct a wrong accepted-cost claim

Two review findings, one of which turned out to be a documentation defect rather
than the design defect it was filed as.

S8 — THE READ-SIDE FIX PINNED 2 OF THE 7 SHAPES IT REPORTED REPRODUCING.
`ab2f48c17` reported the wrong-edge defect reproducing in seven binder shapes and
landed rows for two. The stated mitigation was that all seven route through one
`collectDartBodyShadows` enumeration whose completeness the grammar-derived
coverage guard protects. That mitigation is NARROWER THAN CLAIMED: the guard
filters `nodeTypeInfo` on `type.includes('pattern')`, so it covers the pattern
family and NOT catch bindings, closure parameters, plain locals, or formal
parameters. Narrowing `addDartBinderName`'s catch arm would have turned no row red.

All seven were re-measured by unwiring `dartScopeOwnsReceivers` and rebuilding.
Every one gained the wrong edge `Outer.inner#0` — none had to be dropped as
non-reproducing. Five new rows: formal parameter, plain local `var`, catch
binding, closure parameter, for-in `var`.

Non-vacuity established structurally, not by assertion: the Dart AST was dumped
first to confirm each fixture produces the node `addDartBinderName` actually
inspects (`formal_parameter`, `initialized_variable_definition`,
`catch_parameters`, `for_loop_parts`). The catch row uses a bare `catch (zf)`
rather than `on Err catch` deliberately — an `on` clause names a type, which
would make the row measure type resolution instead of the mask.

S7 — THE ACCEPTED-COST COMMENT WAS WRONG, AND THAT IS THE FINDING.
It claimed dropping a static field's binding trades a wrong edge for a missed one.
Measured on a same-name twin, that is false:

    read                     with the drop      without it
    this.p  (instance twin)  Outer  correct     Alien  wrong
    Host.p  (static twin)    Outer  WRONG       Alien  correct
    Host.q  (static, no twin) none — missed     Alien  correct

The wrong edge did not disappear. It MOVED to the static read, which now picks up
the instance twin's type. Only the no-twin case is a genuine missed edge. The
trade is still right — `this.p` is far more common than `Host.p` — but it was
documented as safer than it is, and a reader deciding whether to revisit it was
being given the wrong picture.

NAMESPACING WAS EVALUATED AND DELIBERATELY NOT DONE. `Host.p.hit()` resolves
through `foldReceiverChain` in shared `compound-receiver.ts`, which explicitly
discards whether a chain's base was a class reference or a value (:519-527). The
class-constant bit exists only on the text-cascade path (`currentIsClassConstant`)
and is consumed solely by `isConstructionSelectorHop`; TS/JS take the fold, not
the cascade. `Scope.typeBindings` is `ReadonlyMap<string, TypeRef>` with no static
field. `ownsReceivers` cannot help — it is a suppressor that can only REMOVE a
binding, never route to a second one. A real fix needs `FoldState` to carry the
bit plus a key convention in shared code (an AGENTS.md:42 hook if not
language-neutral), it crosses the worker boundary so it needs a SCHEMA_BUMP, and
`compound-receiver.ts:826` iterates every binding for `fieldFallback` so a
namespaced key would leak straight back in as an ordinary field. Not a cheap or
safe change — and it would have been made with ZERO existing tests pinning
static-read behaviour.

So: smallest safe step instead. Two rows pin the measured behaviour
(`static-read-of-a-same-name-twin-picks-up-the-instance-type` asserts the positive
wrong target, not an empty set; `static-read-without-a-twin-loses-its-type` is a
known-gap), and the comment now says what actually happens. Anyone who revisits
this starts from measurements rather than from a claim.

No SCHEMA_BUMP: the `captures.ts` change is comment-only — verified, the diff has
no non-comment added lines.

Mutation red-rows 2/85 -> 7/90; each new row fails with a strictly larger set
(`+Outer.inner#0`), so none can pass vacuously. Overreach control still live:
dropping `shadows.has` turns 28 rows red including
`unshadowed-read-in-a-shadowing-class-still-resolves`.

Matrix 85 -> 92 tests. Sweep 3231 passed, 0 failed. tsc clean. All four gates
green, no fingerprint moved, nothing rebaselined.

Refs #2807

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

* fix(python): stop a dotted callee from fabricating a constructor type

S4 and S5 from the review round. They are ONE defect, not two, and the real one
is wider than the review described. Both live in a 32-line block THIS PR adds
(`@@ -116,0 +117,32 @@` — a pure addition), so neither is pre-existing.

THE DEFECT. `constructorCallTypeName` rejected only a callee rooted at the
receiver and returned every other dotted callee whole to `resolveTypeRef`, which
resolves dotted names through `QualifiedNameIndex` — and that index matches the
TRAILING SEGMENT against a class of that name even when the callee is a method on
an unrelated object:

    class Alpha:
        def ping(self): return 1
    class Factory:
        def Alpha(self): return "not an Alpha"    # a METHOD
    class Host:
        def __init__(self, f): self.svc = f.Alpha()   # svc is a str
        def run(self): return self.svc.ping()
    # measured: Host.run -> Alpha.ping, fabricated

WIDER THAN FILED: the review framed the trigger as a callee rooted at an
`__init__` PARAMETER. Measured, the root's binding form is irrelevant — a
module-level variable (`shared_factory.Alpha()`) fabricates identically. Any rule
written against what the root binds to would have fixed half the defect and left
the other half looking fixed. Both fabrications now have rows.

S5 IS A SYMPTOM, NOT A SECOND DEFECT. `self.conn = Outer()` then
`self.conn = Registry.get()` typed the field as `"Registry.get"` (resolving to
nothing, so the edge vanished) only because the dotted arm accepted
`Registry.get` as a constructor in the first place. Once dotted callees yield no
candidate, there is nothing weak left to displace with and `Outer` survives. So
`>=` is untouched and NO second mechanism was added: between two REAL
constructions last-write-wins is correct, and the existing `ReassignedField`
matrix row depends on it. Tightening the tie-break would have been the wrong fix
to a symptom.

THE FIX: accept a bare `identifier` callee only. Refusing ambiguous evidence at
CAPTURE time rather than resolving-then-rejecting is deliberate — the target-kind
route is not reachable from this file (`resolveTypeRef` already filters
`TYPE_KINDS`; the fabrication comes from a trailing-segment match in
`scope/walkers.ts`), and the root-alias route would collide with PR #2828, which
is rewriting exactly how an unaliased dotted namespace import resolves. This
change is orthogonal to #2828 by construction: it changes what is CAPTURED, never
how a name is looked up, and touches none of its files.

WHAT THE DOTTED ARM WAS ACTUALLY BUYING: nothing. The review (and this PR's own
docblock) justified it with `self.u = models.User()`. Measured, that shape emits
NO edge before or after this change — an instance field's binding lands in CLASS
scope, which never reaches the namespace split. The shape that really resolves is
the module-level local `u = models.User()`, which comes from `query.ts` and is
untouched here. The arm's entire measured contribution was fabrications, which is
what made the fix cheap.

#2828 COMPATIBILITY, checked not assumed: `import pkg.user` -> `self.u =
pkg.user.User()` resolves to nothing both before and after, so this cannot stop it
resolving. No test row pins that shape ON PURPOSE — asserting its current empty
state would plant a tripwire that goes red the moment #2828 lands. If #2828 also
teaches the FIELD path the namespace split, re-enabling dotted field callees
becomes a live option; the docblock says so, and says why redoing it capture-side
would re-open the fabrication.

SCHEMA_BUMP 41 -> 42 with its pin. This is parse-time capture emission: after the
fix `self.svc = f.Alpha()` emits no `@type-binding.constructor` capture at all, so
a v41 warm cache replays the pre-fix capture set for byte-unchanged files and
keeps serving the fabricated edge (GUARDRAILS.md:34). A within-PR re-bump, not a
collision fix — 40/41/42 are all this unmerged branch's, and `origin/main` is at
39. Re-check against origin/main immediately before merging.

Mutation-verified in BOTH directions, which is what shows the fix is placed at the
right width rather than merely working:
  - revert the fix     -> exactly 3 red: both S4 fabrication rows + the S5
                          displacement row (8 green)
  - reject EVERY callee -> exactly 3 red: the three positive-typing rows (8 green);
                          the S4 rows correctly stay green
The two mutations hit DISJOINT row sets — too loose and too tight each break a
different half.

No row asserts an empty set: the three "must not type" rows call `Alien.ping()` as
a witness so a regression SWAPS a target in rather than emptying. Non-vacuity is
asserted in the test itself — one guard checks every caller node is live, another
asserts the `Alpha` class / `Factory.Alpha` method name collision the fabrication
NEEDS is actually present, so the rows cannot rot into passing for the wrong reason.

Sweep 3268 passed, 0 failed. Python unit + python.test.ts 342 passed. tsc clean.
All four gates green — no bench cell moved, nothing rebaselined.

Refs #2807

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-04 19:31:25 +01:00
Gergő Magyar
010a7d806a
fix(schema): declare the full scope-resolution relation cross product (#2792) (#2793)
* fix(schema): declare the full scope-resolution relation cross product (#2792)

`RELATION_SCHEMA` was hand-listed, and every prior fix added only the
FROM/TO pair named in a crash report — `Const→Method` in #2769, the
Swift/Rust member pairs before it. So `analyze` kept aborting at
`assertDeclaredPair` on the next codebase whose edges happened to land on
a different pair; #2792 reports `Class→Variable` on Java.

Audit the surface instead of the symptom. `buildGraphNodeLookup` skips
any node whose label is not in `isLinkableLabel`, so the lookup holds
only linkable-labelled nodes — and both endpoints of every graph-bridge
edge resolve through that lookup. The emittable surface is therefore
exactly:

  FROM  LINKABLE_LABELS + File   (the module-level caller fallback)
  TO    LINKABLE_LABELS + CALL_TARGET_TYPES

`isCallerAnchorLabel` is a strict subset of linkable and contributes
nothing on top. `CALL_TARGET_TYPES` contributes `Delegate`, which
`tryEmitEdgeWithExplicitTargetId` can emit without going through the
lookup at all.

Generate that 14x14 block into the DDL rather than listing it: 223 -> 322
declared pairs, and no future pair from these sets can be missing by
construction. The containment/inheritance/DI/route/cluster/PDG pairs stay
hand-declared — no single predicate describes them.

Both label sets live in the ingestion layer, which `core/lbug` must not
import, so schema.ts carries twin lists. test/unit/schema-pair-coverage.ts
derives the requirement from the originals and fails CI when either set
grows without the pairs landing here — the piecemeal loop this fix ends.

Measured before widening: at 322 pairs the cost is inside noise
(1.09s vs 1.12s per 300 anchored queries on a 32-table DB), but the full
32x32 cross product is ~1.8x on untyped-endpoint anchored queries. The
audited subset is the right scope, not "declare everything".

INCREMENTAL_SCHEMA_VERSION 34 -> 35: LadybugDB fixes endpoint pairs when
the rel table is created, so a pre-v35 database physically cannot store
these edges.

Closes #2792

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

* fix(schema): declare the non-bridge structural pairs COBOL and Vue emit

The generated scope-resolution block closed the half of RELATION_SCHEMA a
label predicate can describe. The hand-declared half was still stale: with
#2791's Function->Variable fix applied, `analyze` continued to abort on this
repo's own test/fixtures/lang-resolution with

  Relationship label pair Module→Property is not declared

A full sweep (assertDeclaredPair patched to log-and-skip, run over the whole
fixture corpus) found 13 undeclared pairs over 106 edges. This branch already
covered 3 of them via the cross product; the remaining 10 come from emitters
outside the graph bridge:

  - cobol-processor.ts mints Module / Namespace / Record / Property /
    CodeElement and wires them with CONTAINS, CALLS and ACCESSES (9 pairs)
  - vue-sfc-extractor.ts emits BINDS_EVENT_HANDLER from a handler Function to
    the child component's File, the only edge whose target is a File (1 pair)

CodeElement, Namespace, Record and File are in neither scope-bridge label set,
so neither the generated block nor schema-pair-coverage.test.ts can reach them.

Adds test/integration/structural-pair-coverage.test.ts, which derives the
requirement from a corpus instead of a predicate: it runs the real pipeline
over the non-bridge fixtures and requires every FROM/TO pair they produce to
be declared. Mutation-checked — dropping `FROM Function TO File` fails it with
exactly Function|File.

Verified: cobol-app, vue-basic and php-transitive-traits now index instead of
aborting; the full lang-resolution corpus completes at 10,876 nodes / 18,517
edges; scrypster/muninndb at 0b7a4272 (the #2789 repro) completes at 20,069
nodes / 71,580 edges, matching #2791 exactly, so this supersedes that PR.

* refactor(test): simplify the structural pair coverage guard

Cleanup pass over the previous commit. No behaviour change to the schema.

- reuse `FIXTURES` and `runPipelineFromRepo` from resolvers/helpers.ts instead
  of re-deriving the fixture root and importing pipeline.js directly
- gate on `distWorkerExists()` like every other integration test that passes
  `workerUrlForTest`, so a missing dist skips rather than fails
- run the three fixtures with `it.concurrent.each`; they share nothing and the
  cost is almost all worker spawn plus grammar load, which overlaps well
  (tests phase 21-24s -> 5.6s measured)
- replace the sentinel-in-a-Set filter with a plain `.filter()` chain, matching
  the sibling unit test, and move the declared/table lookups off the per-edge
  path onto the deduped set
- move the pure string pin out of the integration tier into
  schema-pair-coverage.test.ts, where the identical construct already lives, so
  it needs no build and survives fixture deletion
- trim the schema and test prose that restated the code, and correct the
  BINDS_EVENT_HANDLER attribution: it is emitted by
  languages/vue/scope-resolver.ts, not vue-sfc-extractor.ts
- amend the v35 comment to mention the 10 structural pairs it now also stamps

Still mutation-checked: dropping `FROM Function TO File` now fails both the
integration sweep and the unit pin with exactly Function|File. 89 tests green.

* fix(schema): generate the attachment pair surface and close four analyze aborts

Review of the generated scope-bridge cross product found four `analyze`
hard-aborts still live at head, each reproduced end-to-end on the default
user path (`analyze --index-only --skip-git`):

  Method→Annotation   Spring `@Bean` + `@ConditionalOnMissingBean` (Java + Kotlin)
  Method→File         Vue Options-API `methods:` handler bound to a child event
  Namespace→Record    COBOL `DECLARATIVES` / `USE AFTER STANDARD ERROR ON <file>`
  Class→Tool          `@mcp.tool()` applied to a class

All four are pre-existing on main, and both existing guards were structurally
blind to them: the unit guard derives from LINKABLE_LABELS ∪ CALL_TARGET_TYPES
(none of Annotation/Tool/Record/File-as-target is a member) and the corpus
guard ran three fixtures that exercise none of these emitters. All 16 tests
passed while all four crashes were live.

The PR's model — "bridge endpoint × structural endpoint" — does not fit:
Namespace→Record is structural on both sides. The property that does hold is
that the ANCHOR is a lookup result, not a literal at the emit site, so the
emitter cannot constrain its label. That gives a second closed-form rule:

  DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS

DEFINITION_ANCHOR_LABELS is derived from NODE_TABLES by subtraction, so a new
node table joins automatically. 332 → 450 declared pairs.

Sized against a committed harness (gitnexus/bench/schema-pairs), real
@ladybugdb/core, identical data: 450 costs 0.93–1.05× of 332 on untyped-endpoint
anchored queries — inside noise — versus 1.22–1.43× at 641 and 2.03–2.34× at
1024. The harness reproduces the known #2792 cliff, which is what makes the 450
figure trustworthy.

Also in this change:

- Delete the 161 hand-declared pairs the rules already generate (233 → 72).
  The declared set is byte-identical at 450; those lines were load-bearing
  shadow, because the generator suppresses anything already declared
  structurally, so narrowing a rule later would silently keep pairs alive.
  A new guard fails CI if a hand-declared pair is ever re-added inside a rule.
- Import LINKABLE_LABELS / CALL_TARGET_TYPES instead of hand-copying them.
  The twins' stated justification ("the ingestion layer must not be imported
  here") is false: csv-generator.ts and lbug-adapter.ts, siblings in the same
  directory, already do, and no rule in AGENTS.md / ARCHITECTURE.md /
  CONTRIBUTING.md / GUARDRAILS.md states otherwise.
- Resolve `resolveStreamGraphEmit` after the guards that rebind `options.force`,
  not at function entry. It gates on `force`, and every freshness guard runs
  ~360 lines later, so the v34→v35 bump would have pushed every existing index
  down the non-streamed emit path — losing the #2680 memory streaming added for
  the #2649 kernel-scale OOM, for exactly the population most likely to be
  memory-constrained.
- `UndeclaredRelationPairError` now carries the relationship type, both node ids
  and the source file, with a matching CLI branch. The old message named only
  the abstract label pair, which a user could not act on. Found through the
  cause chain, since pipeline-phases/runner.ts rewraps every phase failure.
- Share one classifier (`relPairKeyFor`) across the router, both emit sinks and
  the corpus guard, which previously hand-mirrored the router's skip rule; one
  cause-chain walker in lib/utils.ts; one exported pair-matching regex.
- Corpus guard: four new fixtures reproducing the aborts, per-fixture sentinel
  pairs so a fixture that stops emitting fails loudly instead of passing
  vacuously on an empty graph.

The per-edge path stays allocation-free: the failure context is passed
positionally and the message is built only inside the throw.

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

* test(bench): re-baseline the COBOL capture fingerprint for the new fixture

`bench/scope-capture` globs `lang-resolution/cobol-*`, so the
`cobol-declaratives` fixture added in 81daf370e (to reproduce the
`Namespace→Record` analyze abort) joined that corpus and shifted the
fingerprint — 14 → 15 files.

Verified corpus-only, not a capture change: with that one fixture moved
aside the fingerprint is byte-identical to the prior baseline
(d45bb091…), and 81daf370e touches no COBOL capture code. The new value
reproduces CI's reported hash exactly. Scaling 0.677 < 1.5 budget.

`bench/scope-capture/measure.mjs --check` → PASS (15 languages).

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:26:25 +01:00
Gergő Magyar
74409a37f6
perf(cpp): index qualified namespace members once per pipeline run (#2788) (#2794)
* perf(cpp): index qualified namespace members once per pipeline run (#2788)

`resolveCppQualifiedNamespaceMember` walked every parsed file — rebuilding a
per-file `scopesById` map each time — once per qualified `ns::member()` call
site, so the scope-resolution emit phase cost O(callsites x scopes). On a
1,473-file C++ repo that was 25.3 min of a 33-min analyze, with 75% of total
self-time in this one function. Its inner `findMemberInNamespaceTransitive`
compounded it: each recursion step filtered `scopesById.values()` by parent,
O(scopes^2) per file on its own.

This is the same bug #1990 fixed in the sibling ADL path (`pickCppAdlCandidates`
-> `AdlCandidateIndex`), so it gets the same fix: a `QualifiedNsMemberIndex`
(receiver simple name -> member simple name -> callable defs) built lazily once
per `parsedFiles` identity and reset by `clearCppInlineNamespaces`, which runs
from `cppScopeResolver.loadResolutionConfig` at the start of every pass. Per
call site the work drops to two Map lookups.

Ordering is preserved exactly — file-major, `parsed.scopes` declaration order,
a namespace's own `ownedDefs` before its inline-namespace children, depth-first
— because the caller takes `allHits[0]` for the single-hit case and
`narrowOverloadCandidates` is first-wins. Non-inline nested namespaces are
still not descended into, and same-name hits across inline children still
report `'ambiguous'` (#1564).

Measured with `PROF_SCOPE_RESOLUTION=1 analyze --force --index-only` on a
synthetic corpus (`namespace ns_i { inline namespace v1 { ... } }` plus 20
`ns_j::fn()` call sites per file):

| files | emit before | emit after |
|-------|-------------|------------|
| 100   | 153ms       | 16ms       |
| 200   | 704ms       | 24ms       |
| 400   | 3,293ms     | 42ms       |
| 800   | 16,898ms    | 78ms       |

Before, doubling the file count quadrupled emit; now it doubles. At 800 files
total scope resolution goes 17.2s -> 394ms.

Output is unchanged, verified rather than assumed: a full graph dump (sorted
nodes + relationships) from a baseline build at the parent commit and from this
one are byte-identical on all 134 `cpp-*` fixtures merged into a single repo
(1573 nodes / 1997 relationships) and on the 400-file synthetic corpus.
`test/integration/resolvers/cpp.test.ts` passes 334/334.

#1990 shipped its ADL fix without a scaling gate, which is how the bug class
came straight back here, so this adds one: `bench/cpp-qualified-ns` measures
`(t_large/t_small)/(1600/400)` — 0.93-1.21 indexed versus 3.45 for the old
per-call-site scan — alongside a fingerprint over every
`receiver::member -> outcome` the corpus resolves, and CI runs it with
`--check`. `test/unit/cpp-qualified-ns-index.test.ts` covers the cache
invalidation the index introduces.

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

* fix(cpp): address tri-review findings on the qualified-namespace index (#2788)

Multi-engine review of this PR (Claude swarm + ce-code-review, Codex
gpt-5.6-sol swarm + ce + adversarial) returned two P1s and five smaller
findings. All are fixed here.

P1 — the index defeated the pipeline's post-language memory release.
`scope-resolution/pipeline/phase.ts` evicts each language's files and then
calls `forceGc()`, on a stated premise that "This language's ParsedFiles are
now unreachable", sizing C/C++ at ~17-20GB on the Linux kernel. The
module-level `let qualifiedNsIndexSource` falsified that: it pinned the whole
`parsedFiles` array, and the index held defs reaching into those files'
scopes, until the *next* C++ pass cleared it — which in a single analyze never
comes. C++ is 7th of 16 in SCOPE_RESOLVERS, so the set survived nine later
language passes plus emit. Replaced with a
`WeakMap<readonly ParsedFile[], QualifiedNsMemberIndex>`, the pattern already
used by `moduleScopeIndexByPass` in `cpp/file-local-linkage.ts`.
`clearCppInlineNamespaces` still swaps in a fresh WeakMap, because the index
has a second input (`inlineNamespaceScopeIds`) the key cannot observe.
Measured with `--expose-gc`: 61.2MB retained after the caller drops the array
before, 0.1MB after.

The ADL twin (`adl.ts`) has the same pattern, so the hazard predates this PR —
but `pickCppAdlCandidates` returns early before `ensureAdlIndex` on
`noAdlSites`/empty `argInfoBySite`, so it rarely arms, whereas a qualified
`ns::member()` index arms on almost every C++ workspace. Moving the ADL twin
to a WeakMap is left as a follow-up.

P1 — the new bench could not see the regression class it exists to gate.
`callSites()` drew every receiver from `ns_${...}`, so the receiver lookup
never missed; production is the opposite, since Case 1.5 in
`receiver-bound-calls.ts` is reached by every plain-identifier receiver call
and misses on most. A rescan reintroduced only on the receiver-bucket-absent
path scored 1.279 and PASSED the old bench. The corpus now mirrors production
(~1 in 5 receivers name a declared namespace) and adds a namespace reopened
across files, a same-name inline nest, a member declared at both namespace and
inline-child level, and call sites carrying a real `Callsite` so
`narrowOverloadCandidates`/`cppConversionRank`/
`isOverloadAmbiguousAfterNormalization` are inside the fingerprinted surface at
all. That same rescan now measures 4.538 and FAILS; defeating the dedup now
fails the fingerprint arm where it previously passed byte-identical. The
fingerprint moved once, deliberately, for the corpus expansion — recorded in
`_rebaseline_2788_review`, explicitly not precedent.

Also fixed:

- Unbounded recursion aborted analyze. `collectNamespaceMembers` recursed per
  inline child with no bound and threw an uncontained `RangeError` at inline
  depth 8000 (`phase.ts`'s try has a `finally`, no `catch`), and a receiver
  *miss* paid full recursion where the deleted walker skipped on a name
  mismatch. An explicit work-stack alone would only have converted that into
  an OOM at depth 6000, because the eager table was quadratic in memory too:
  for a depth-D chain it legitimately holds D(D+1)/2 entries, since `v2::foo()`
  is a valid receiver at every level. Replaced with a lazily-queried node graph
  (per-scope own-member buckets plus direct child links, resolved on demand and
  memoized per receiver+member). Build is now linear; depth 100000 costs 133ms
  where 8000 previously threw.
- "#1990 shipped without a scaling gate" was false. #1990 did ship
  `test/integration/cpp-adl-benchmark.test.ts` (f1b843838). Corrected in the
  bench header and the CI step comment. The accurate point is narrower and
  stronger: that bench asserts `callsResolved === 0`, so it never drives the
  qualified-receiver path, and it is `skipIf(!GITNEXUS_BENCH)` while the only
  step setting that variable lists neither C++ bench — so it has never run in
  CI. Wiring it in is a follow-up.
- "Ordering is load-bearing" was not a live property: `allHits[0]` is only
  reached at length 1, and both tail branches return `'ambiguous'`. Order is
  still preserved for byte-identity with the pre-#2788 walker; the comment now
  says that instead, and the test named for ordering is renamed to the
  parent/inline-child visibility it actually asserts.
- "Same contract as `ensureAdlIndex`" overstated parity — the sibling ships a
  `validateAdlSeqCoverage` guard because it reads
  `seqByNodeId.get(...) ?? 0`, which can silently collapse candidates. This
  index has no analogous defaulting read, so no guard is added; the comment now
  says why.
- Two coverage gaps closed, both mutation-verified: cross-file merge of one
  namespace reopened in two files (no existing test covered it — confirmed by
  making each file clobber the previous and watching only the new test fail),
  and the same-name inline nest whose dedup, when defeated, flips a resolved
  def to `'ambiguous'`.
- `resolveCppQualifiedNamespaceMember`'s JSDoc now names both production call
  sites, including the callsite-less `resolveAdlCandidates` path.
- Memoized candidate buckets are frozen, so a future in-place sort in
  `overload-narrowing.ts` throws instead of silently corrupting later
  resolutions now that the array is shared across call sites.
- `_scaling_note`'s "measured 0.93-1.21" band did not reproduce; it is now the
  honestly observed 1.28-1.45, with the small arm widened to ~14ms (halves the
  spread) and a triage line saying a scaling failure is a timing signal to
  re-run, unlike the deterministic fingerprint arm.

Verification: 840,000 differential probes against the pre-#2788 walker
extracted from base, 0 mismatches, plus 48,000 candidate-order comparisons,
0 mismatches. cpp resolver integration suite 334/334. Unit suite 10/10.
tsc, eslint, prettier clean. Bench --check PASS.

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

* fix(bench): escape the NUL separator so measure.mjs stays a text file

The fingerprint key separator was written as a literal NUL byte instead of the
`\u0000` escape. Git classifies any file containing a NUL as binary, so the
whole bench showed as `Bin` with no diff on GitHub and could not be reviewed —
the same defect this branch already fixed once before the tri-review.

Escaping it is byte-for-byte equivalent at runtime (both produce U+0000), so
the committed fingerprint is unchanged and `--check` still passes.

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

* refactor(cpp): quality cleanups from a four-angle review of the #2788 series

Reuse, simplification, efficiency and altitude passes over the diff. No
resolution behaviour changes: the bench fingerprint is unchanged and the C++
resolver integration suite still passes in full.

Efficiency

- The `Object.freeze` added in the review-fix commit to close an aliasing
  residual costs 4.6x on the narrowing path — V8 moves frozen arrays to
  PACKED_FROZEN_ELEMENTS, off the fast path for the `.filter`/`.map`/`.some`
  runs `narrowOverloadCandidates` does at every multi-candidate call site.
  The hazard it guards is already a compile error (both the memo and the
  parameter are `readonly SymbolDefinition[]`), so it is now a dev-only
  tripwire. Gated on `isSemanticModelValidatorEnabled()` — the repo's opt-IN
  form used by `phase.ts` and `validate-bindings-immutability.ts` — not
  `adl.ts`'s opt-out `NODE_ENV !== 'production'`, which would keep paying the
  cost in CLI runs where `NODE_ENV` is unset. Large bench arm 100.3 -> 70.6ms.
- The index build scanned every scope in every file twice; pass 1 now collects
  `[scope, node]` pairs for pass 2 to iterate. 800k scopes 14.40 -> 8.21ms.
- `simpleNameOf` uses `lastIndexOf('.')` + `slice` instead of
  `split('.').pop()` (83 -> 24 ns/call), semantics verified byte-equivalent
  over 12 edge cases including `undefined`, `''`, `'a.'`, `'.b'` and `'a..b'`.
- The per-call `hookCtx` literal is hoisted to a module const.
- The bench's fingerprint pass resolved 960k call sites to produce 5,800
  distinct outcomes; it now dedups on the key it already builds. Bench wall
  time 2.4 -> 1.9s, fingerprint byte-identical.

Reuse

- `bucketOwnMembers` used an inlined `Function | Method | Constructor` compare;
  it now calls the canonical `isOverloadableCallable`. `graph-bridge/ids.ts`
  already carries a note that inlining this list recreated twin-list drift once.

Altitude

- `adl.ts` had the same retention defect this series just fixed next door: a
  module-level `let adlIndex` + `let adlIndexSource` strongly pinning the whole
  `parsedFiles` array until the next C++ pass, which in a single analyze never
  comes. Converted to the same `WeakMap` shape. Measured with `--expose-gc`:
  89.11MB retained after the caller drops the array before, 0.17MB after. Six
  file-local helpers now take the index as a parameter; no exported signature
  changed.
- The index's freshness depended on `clearCppInlineNamespaces()` being called
  from another file, guarded only by a warning paragraph. An epoch bumped in
  both `populateCppInlineNamespaceScopes` and the clear is now stored with the
  memo, so a missed clear degrades to a rebuild instead of a stale answer —
  confirmed by driving inline state mid-pass without the clear. Roughly line
  neutral, since it replaces most of the paragraph.
- `test/integration/cpp-adl-benchmark.test.ts` is wired into the
  `GITNEXUS_BENCH` step. It is `skipIf`-gated and was absent from that step's
  explicit file list, so #1990's ADL emit-scaling guard had never executed in
  CI. It passes; ~50s added to a 25-minute job. `cpp-pipeline-benchmark.test.ts`
  is deliberately NOT wired: it costs 115s for guards covering generic
  per-language pipeline scaling that nothing here touches.

Simplification

- Deleted a comment referencing a `inlineChildrenByParent` map that only ever
  existed inside this branch's own first commit, so "the legacy map" pointed a
  reader at code that never shipped.
- Dropped three unreachable `undefined` guards (`strict: false`, no
  `noUncheckedIndexedAccess`), keeping the load-bearing `visited` check.
- Compressed the `rootsByReceiver` doc from 17 lines to 7 — it was the longest
  comment in the file and guarded the least consequential property — the
  `validateAdlSeqCoverage` paragraph from 7 lines to 3, and turned three
  restatements of the uncaught-throw and dedup arguments into pointers.
- Test fixtures: dropped the dead `'Module'` union arm, added a one-line `ns()`
  builder for the nine hand-written scope literals, and moved the file to
  `test/unit/scope-resolution/cpp/` where every other C++ scope-resolution unit
  test lives. 403 -> 337 lines, same 10 tests, and the cross-file mutation check
  still fails exactly one test.

Not done, and why: merging this index into `AdlCandidateIndex` (they key on
different names with different inline-transparency depth — a refactor with
correctness risk, not a cleanup); `ScopeTree.getChildren` (trades in-memory
`parsed.scopes` for store hits on a hot path); a shared `cpp/` util for the five
pre-existing `simpleName` copies; sharing fixtures across the bench/test
boundary (no precedent in this repo); and an exact-count arm for the bench,
which is a gate redesign worth its own change.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:52:33 +01:00
Gergő Magyar
911151e230
fix(resolution): resolve Go pointer-receiver calls, and report the program boundary instead of hedging (#2766) (#2782)
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
2026-08-01 22:42:18 +01:00
azizur100389
84f584449d
fix(python): resolve classes through module imports (#2770) 2026-08-01 06:02:47 +01:00
Gergő Magyar
27ab37c432
feat(resolution): type receiver chains from AST structure across all 14 languages (#2708) + epistemic lower-bound (#2744) (#2747) 2026-07-31 07:12:57 +01:00
Gergő Magyar
9c24e3459e
fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742) (#2745)
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(rust): let the qualified-call filter see inline modules

The negative filter added in #2741 builds its set of known module names from
FILE PATHS, so an inline `mod x { … }` — which appears in no path — was absent
from it. Every module-qualified call into an inline module was therefore
rejected before any candidate channel ran, which is a hole in that optimisation
rather than in the resolution logic it guards.

The per-pass index now unions the file-derived names with inline module names
taken from the scope model: a `mod` declaration binds a `Namespace` def locally
in the declaring scope, and that binding is the only place an inline module's
name exists. Collected in the same walk that already builds the module → scope
map, so it costs no extra pass.

Found while fixing #2742, where a correctly resolved call into `mod inner { … }`
still could not reach its target.

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

* fix(scope-resolution): try the namespace-prefixed node key before the bare one

`resolveDefGraphId` looked up the plain `qualifiedName` key first and only
retried with the `namespacePrefix`-qualified key afterwards. For the defs that
carry a prefix the qualified name is a bare TAIL, so the plain key happily
matched a same-named item at a different namespace depth in the same file and
returned it before the more specific retry was ever reached.

The namespace-prefixed key is strictly the more specific of the two, so it is
now tried first. Where no such node exists the lookup falls through to exactly
the previous order, which keeps the #1982 behaviour this retry was added for.

Without this, a call into `mod inner { fn dispatch }` resolved to the correct
definition and then mapped it onto the crate-root `fn dispatch` node — the
self-loop #2742 describes.

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

* fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742)

Node identity is `<label>:<file>:<qualifiedName>` and carried no module path, so
an inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
file collapsed onto `Function:<file>:dispatch`, first-wins. Resolution already
picked the right definition — the target simply was not representable, so a
correct resolution still rendered as a self-loop and `impact` reported the real
callee as unreached.

The mechanism already existed: `qualifyRustImplTargetByModScope` has walked
`mod_item` ancestors for impl targets since #1982. Generalised to
`qualifyByEnclosingModScope` and applied to free items, so
`mod inner { fn dispatch }` becomes `Function:<file>:inner.dispatch`. Keyed
purely on the `mod_item` node type, exactly as the impl qualifier already was,
so it is a no-op for every language whose grammar has no such node.

Two constraints found by tests rather than by reading, both now encoded:

  - The helper normalised `::` to `.` unconditionally. With no enclosing `mod`
    that rewrote a top-level `impl a::Inner` from `a::Inner` to `a.Inner` and
    moved its node id away from the one the HAS_METHOD owner edge emits,
    breaking the #1975 scoped-impl ownership. It now returns raw text untouched
    when there are no mod segments, which also makes the change strictly
    additive for every id that has no enclosing module.

  - Qualification is scoped to items with no enclosing class/impl. A method
    already carries its owner's name, and that owner's id is mod-scoped by the
    impl qualifier, so qualifying the method again breaks the same byte-for-byte
    agreement. Same-named methods on same-named types in sibling modules
    therefore still collapse — a narrower residual than the free-item case fixed
    here, and one belonging to the owner edge rather than to this path.

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

* fix(storage): bump schema versions for the mod-qualified Rust node ids (#2742)

`INCREMENTAL_SCHEMA_VERSION` 24 -> 25 and `SCHEMA_BUMP` 31 -> 32.

Node ids change for every Rust item inside any `mod` block, and
`#[cfg(test)] mod tests` makes that close to every Rust repository. A pre-v25
index therefore holds ids an incremental top-up cannot reconcile — the old nodes
would simply be stranded — so the reuse gate has to force a full re-analyze. The
qualified name is computed in the parse worker, so a warm parse cache would
likewise replay the old unqualified ids and keep the collapse.

This branch originally claimed v24; #2708 took that number and merged first, so
it is renumbered to v25 here. That is exactly the collision the v29 note in
parse-cache.ts warns about, and re-checking against origin/main at rebase time
rather than at branch time is what caught it. #2708 did not touch `SCHEMA_BUMP`,
so 32 is free.

The version-pin test moves with the bump by design, including the new pre-v25
row in the reuse-gate table.

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

* fix(rust): stop mod-qualifying container ids while their owner edges stay bare (#2745 review)

#2742 re-keyed Rust node ids by the enclosing `mod` chain. The mint moved; the
owner-edge anchor did not. `findEnclosingClassInfo` mints a member's owner id
from the container's BARE `nameNode.text` and only follows a qualified shape when
the provider sets `classExtractor.qualifiedNodeId`, which Rust does not.

So every `struct` / `trait` / `enum` / `impl` declared directly inside a `mod`
got a node id that none of its member edges pointed at. Five lines of idiomatic
Rust were enough:

    pub mod engine { pub struct Config { pub retries: usize } }

    NODE     Struct:src/lib.rs:engine.Config
    DANGLING HAS_PROPERTY Struct:src/lib.rs:Config -> Property:src/lib.rs:Config.retries

The rows are discarded by the IGNORE_ERRORS COPY retry, so the struct silently
lost every field. A trait impl inside a `mod` additionally dropped its
METHOD_IMPLEMENTS edge outright.

The same gap put `impl a::Inner` inside a `mod` back on the #1975 rake that
`qualifyByEnclosingModScope`'s own docblock warns about. The impl-target branch
deliberately fires only for an UNSCOPED `type_identifier`; the new gate had no
such restriction and picked up the scoped targets that branch had just excluded,
minting `Impl:<file>:outer.a.Inner` against an anchor still reading
`Impl:<file>🅰️:Inner`.

The member side was already excluded via `!enclosingClassInfo`. This adds the
owner side, gated on `MEMBER_OWNER_NODE_TYPES` — derived from
`CLASS_CONTAINER_TYPES`, which is already the single source of "this node type
owns member edges" and already carries an INVARIANT note binding it to
`CONTAINER_TYPE_TO_LABEL`. A language adding a container therefore cannot gain a
mismatched id shape here without also failing that invariant. Keyed purely on
tree-sitter node types, so no language name enters shared ingestion.

`union_item` is listed too: its fields are captured as Property but it is not a
recognized owner, so they carry no HAS_PROPERTY edge and cannot dangle — it is
here so a union's id keeps the same shape as the struct beside it.

Containers still collapse across sibling modules, exactly as before this fix.
That residual belongs to the owner edge, and is not worked around here.

Regression tests use the UNFILTERED `findDanglingEdges(result)`. Every other
dangling assertion in `rust.test.ts` passes `['HAS_METHOD']`, which is precisely
why the HAS_PROPERTY breakage shipped with a green suite. They assert the NODE
id rather than only the edge's anchor, because the anchor was already bare while
the bug was live — an edge-only assertion passes in both builds. All four fail
when the new gate clause alone is reverted.

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

* fix(rust): resolve modules nested inside an inline mod (#2745 review)

The #2730 self-loop survived one `mod` deeper:

    pub mod outer {
        pub mod tools { pub fn dispatch() {} }
        pub fn dispatch() { tools::dispatch(); }
    }

    CALLS outer.dispatch -> outer.dispatch          <- the #2730 symptom
    NODE  outer.tools.dispatch                      <- correct target, unlinked

Two gates were blind to a nested inline module, so the hook refused and the shared
lexical tier bound the call to the enclosing same-name `dispatch`:

`knownModuleNames` was collected by walking `moduleScopeByFile`, which maps a file
to its ROOT `Module` scope only. A `mod` nested inside an inline `mod` binds in the
parent module's scope, so the walk saw depth-1 inline modules and missed every
nested one — `tools` never entered the set and the negative filter rejected the
qualifier before any candidate ran.

`declaresSubmodule` had the same root-only assumption, so even with the name known
the candidate `outer::tools` was never yielded.

Both now read the def index. Names come from every `Namespace` def; inline module
PATHS are derived from the members' `namespacePrefix` rather than from the `mod`
defs, because a `mod` def carries no nesting information of its own — inside
`mod outer { mod tools { … } }` the inner def is `qualifiedName: 'tools'` with NO
`namespacePrefix`, while every def within it is stamped `outer.tools`. A
`Namespace` scope also owns its OWN def rather than its children's, so the scope
tree cannot answer this either: the `mod outer` scope lists `outer`, never `tools`.

Restricted to non-empty prefixes, so this stays a DECLARATION check. Including
file-derived modules would let an undeclared or `cfg`-gated file on disk outrank a
real `use` binding — the regression #2741's review already fixed once. File-backed
submodules therefore keep going through the binding check.

A module with no defs at all is absent from the set, which is harmless: it has no
member for a qualified call to resolve to.

Cost is one pass over an already-resident def index, memoized per resolution pass
on the existing WeakMap — the same order of work as the binding walk it replaces,
and it subsumes it. `isLocalNamespaceBinding` was going to single-source the
duplicated "locally declared submodule" predicate the review flagged; deriving
paths from members removed the second copy outright instead.

Regression fixture covers depth 2 and depth 3, so the fix is depth-agnostic rather
than depth-2 special-cased.

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

* fix(rust): let an imported type outrank a same-named module (#2745 review)

Widening the negative filter to inline `mod` names let a type-qualified call
through whenever a module happened to share the type's name. The
crate-root-relative candidate then captured it:

    // src/lib.rs
    pub mod Buffer { pub fn with_capacity() -> usize { 111 } }
    // src/b.rs
    use crate::c::Buffer;                        // the real target lives in c.rs
    pub fn call() -> usize { Buffer::with_capacity() }

    base: (no CALLS edge — unresolved)
    PR:   CALLS b::call -> Function:src/lib.rs:Buffer.with_capacity   <- fabricated

`ids.ts` states the doctrine this broke: a missing edge is the correct failure
direction for a graph whose consumers include `impact`; a fabricated caller is not.
The base produced the missing edge and the PR produced the fabricated one.

That third candidate is the loosest of the three — a guess at a crate-root-relative
path the caller never wrote, kept for 2015-edition style. In Rust 2018 a bare first
segment resolves in the CALLER's module, so a local binding for that segment
settles the question: it is now skipped when the head names anything non-module in
the caller's own module. Candidates 1 and 2 are untouched, and they run first, so
the legitimate `use crate::tools;` path is unaffected.

The binding lookup goes through `lookupBindingsAt`. A first attempt read
`Scope.bindings` directly and the guard never fired: a `use` binding is finalize
OUTPUT and absent from the scope's own local table, which is exactly the
imported-type case being guarded. Contract I8 in `contract/scope-resolver.ts`
requires that channel anyway.

The regression test asserts the forbidden TARGET rather than an empty edge set, and
separately asserts the module member still exists as a node — otherwise the test
would pass just as well if the call went unresolved for some unrelated reason, or
if the module node disappeared entirely.

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

* fix(scope-resolution): try the namespace-prefixed name on the TAGGED keys too (#2745 review)

`resolveDefGraphId` gained a namespace-prefixed retry for the plain qualified key
in this PR, but the five tagged keys above it — template constraints, parameter
types, parameter shape, arity, template arguments — kept composing from the bare
`qualifiedName`.

For a namespace- or `mod`-qualified def those keys are simply dead:
`node-lookup.ts` registers them under the QUALIFIED name (`inner.dispatch#0`)
while this side built `dispatch#0`. The keys exist to separate overloads, so a
mod-scoped overload set was relying on whichever later key happened to catch it.

Verified as a miss rather than a mis-hit before changing anything — an end-to-end
run with a crate-root decoy of the same name and arity binds correctly — so this
is hygiene, not a live bug. Worth doing while the code is open rather than leaving
five keys dead and the behaviour dependent on fallback order.

Both name forms now go through one `lookupTagged` helper, most specific first, so
a sixth tagged key cannot be added with the bare form only. That also removes the
five hand-repeated `qualifiedKey(...)` / `nodeLookup.get(...)` pairs.

Also pins the C++ `EXTENDS` retarget this PR's reorder produces.
`cpp-two-phase-dependent-base-cross-ns-deep` declares a global `Inner` decoy
alongside `ns:🅰️🅱️:Inner`; the base's `qualifiedName` is a bare `Inner` with the
path on `namespacePrefix`, so only the prefixed key separates them, and only if it
runs first. The improvement was riding unasserted in a Rust-scoped PR.

The captures golden covers every `rust-*` fixture, so the three fixtures added by
this review series drift it; regenerated with UPDATE_GOLDEN=1.

Verified: 785 tests across cpp / csharp / rust resolvers and the
callable-id-lockstep unit test.

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

* fix(rust): keep a mod declared inside a fn from hoisting above the callable (#2745 review)

`fn wrapper() { mod helper { fn dispatch } }` minted
`Function:<file>:helper.wrapper.dispatch@2:8` — the mod segment composed OUTSIDE
the enclosing-callable prefix, inverting the real nesting.

Nothing dangled: the `@line:col` suffix already makes a function-local callable's
id unique, which is also why the mod segment adds no identity in this position. The
path simply read as a lie about the source. It is now skipped rather than
reordered — interleaving two qualifier passes to fix the order would be real
machinery for a shape whose ids are already unique.

Also folds in the three documentation and structure findings from the same review:

- The 4-clause gate is extracted to a named `qualifiesByEnclosingModScope`, matching
  the two conditions directly above it in the same function, which were already
  named consts.
- `qualifyByEnclosingModScope`'s docblock documented only the impl-target contract
  even though the generalized name has had a second, looser caller since #2742. It
  now states both, and says which gate belongs to which — that gap is what let the
  #1975 scoped-impl regression through in the first place.
- The "cheap rejection BEFORE any index work" comment was no longer true:
  `passIndexFor` walks the def index on its first call in a pass. Corrected rather
  than left to mislead the next reader into thinking the filter is free. What it
  still buys — skipping the per-site candidate search, the part that scales with
  the workspace — is stated instead.

Verified: 279 tests across the Rust resolver suite and the Rust scope-resolution
unit tests. Captures golden regenerated for the extended fixture.

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

* test(storage): move main's SCHEMA_BUMP pin to 32 for the mod-qualified ids (#2745 review)

`#2736` added a pin asserting `SCHEMA_BUMP === 31` on main, which arrived on this
branch through the merge of main while `0062a5c2` had already bumped the constant
to 32. Neither side conflicted textually — the pin and the constant live in
different files — so the merge was clean and the test failed instead.

That is the pin working as designed: it exists so a bump cannot ride along
unnoticed, and this is the fifth time a SCHEMA_BUMP collision has been caught by a
guard rather than by review. Updated to 32 with the reason recorded inline.

`INCREMENTAL_SCHEMA_VERSION` needs no second bump: 25 was introduced by this
unmerged branch, so no released index carries it, and its own pin in
`call-summary-schema-version.test.ts` is already consistent.

Verified: 119 tests across the parse-cache, schema-version, incremental-orchestration
and the two identity suites that arrived with the merge.

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

* test(bench): rebaseline the Rust capture fingerprint for the three new fixtures (#2745 review)

CI caught what I missed:

    [scope-capture --check] FAIL: rust: capture fingerprint drift
      (got 05acbaca..., expected 90fda086...)   fixture_count 202

`bench/scope-capture` fingerprints the whole `rust-*` fixture corpus, so the three
fixtures added by this review series drift it. I rebaselined the
`rust-captures-golden` snapshot and stopped there — a new fixture is a call site of
BOTH, and updating only one is how this reached CI red.

This is the same class as PR #2743's headline finding, from the other direction: an
id-shape change makes every synthetic corpus a call site, and the author fixed the
unit-test fixture and missed the bench. Here it is a fixture-count change rather
than an id-shape change, and the review that flagged the #2743 lead as "REFUTED,
bench/ has no Rust node-id corpus" was right about node ids and wrong about the
corpus fingerprint. Noted for the next author in the baseline entry itself.

Verified as pure corpus growth rather than a capture-logic shift: removing ONLY the
three new fixture directories and re-running reproduces the prior fingerprint
exactly (196 fixtures, capture_groups_fp 3432), and restoring them gives the new
one (202, 3556). `emitRustScopeCaptures` is untouched by this series. Scaling 1.022
local / 1.057 CI, well inside the 1.5 budget.

`bench/python-scope` globs `python-*` only and is unaffected; no other bench walks
the Rust corpus. `--check` now PASSes for all 15 languages.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:08:00 +01:00
azizur100389
e723f3c2ee
fix(scope-resolution): parse def coordinates after file paths (#2743)
* fix(scope-resolution): parse def coordinates after file paths

Anchor coordinate parsing to the known file path so coordinate-like path fragments and private symbol names cannot corrupt closure attribution.

* fix(bench): use production definition ids
2026-07-30 07:34:32 +01:00
Gergő Magyar
bc76ba2f25
fix(resolution): type inline constructor receivers in every spelling (#2708) (#2737)
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(resolution): resolve constructor-expression receivers (#2708)

`Service(db).do_work()` emitted no CALLS edge, so the caller was missing
from `impact(direction: "upstream")` and `context()` while the two-step
spelling of the same call (`s = Service(db)` then `s.do_work()`) resolved.

The receiver reaches `resolveCompoundReceiverClass` intact — Case 0 in
`receiver-bound-calls` routes it there because the text contains `(`. The
free-call branch then only knew one shape: a function whose return-type
binding names a class. A class has no return-type binding, so `Service`
resolved to nothing and the member call was dropped.

Handle the constructor shape: in languages that construct without a `new`
keyword (Python, Kotlin, Swift, Scala) a free call naming a class IS a
constructor call, so the expression's type is that class. The existing
return-type path still runs first and wins, keeping this strictly
additive — `new`-keyword languages never reach the new line because their
receiver text keeps the keyword (`new Service(db)`), which matches no
class binding.

Verified on the issue's 4-file repro: `route_inline` now emits
`CALLS → Service.do_work` and `impactedCount` goes 1 → 2.

Note the issue's second ask — degrading `epistemic` to `lower-bound` when
a receiver goes unresolved — is NOT addressed here.
`computeEpistemicBoundary` keys only on the target's own heritage edges
and runs at query time against the index, while unresolved references
live in an in-memory `resolutionOutcomes[]` that is never persisted. That
needs unresolved-receiver counts in the index first, so it is left for a
follow-up.

Tests: new `python-inline-constructor-receiver` fixture plus three
integration cases (inline resolves, two-step still resolves, no
cross-class fan-out). Two of the three fail without the source change.
Full `test/integration/resolvers` suite passes (2928 tests) — the fix is
shared across every language, so no-regression coverage matters more than
the new cases. Python captures golden regenerated: additions only, no
existing digest changed, confirming capture output is untouched.

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

* refactor(resolution): state the construction rule once, cover every spelling (#2708)

The first commit fixed `Service(db).do_work()` by special-casing a bare
class-name callee inside the free-call branch of the compound receiver
resolver. That was the right rule in the wrong place: it covered one
surface syntax out of three, and asserted rather than declared which
languages it applied to.

Probing the same shape across languages showed the bug is wider:

  | spelling               | languages          | dropped before? |
  |------------------------|--------------------|-----------------|
  | `Service(db).m()`      | Python             | yes             |
  | `new Service(db).m()`  | JS/TS, Java, C#    | yes             |
  | `Service.new.m()`      | Ruby               | yes             |
  | both forms             | PHP, Swift, Dart,  | no — already    |
  |                        | Kotlin             | resolved        |

So the rule is stated once — "constructing a class yields an instance of
that class" — and the per-language surface syntax is declared through a
new `ScopeResolver.constructionSyntax` hook, matching how this file
already gates language-varying behaviour (`stripReceiverCastExpressions`,
`hoistTypeBindingsToModule`). Shared pipeline code names no language.

  - `bare: true`      — Python
  - `keyword: 'new'`  — JS/TS, Java, C#
  - `selector: 'new'` — Ruby, including the parenthesis-less `Service.new`
    spelling that reaches the chain walker rather than the call branch

Opt-in is per-language for two reasons. Correctness: `bare` would mistype
`stat(&st).field` in C, where a struct and a function may share a name.
Evidence: PHP, Swift, Dart and Kotlin resolve this shape already, so they
stay unwired instead of carrying a declaration that changes nothing —
each verified by diffing analyzer output between builds with and without
the change, not assumed.

The keyword gate also keeps a bare factory call honest: in a `new`
language, `makeOther(db).doWork()` still resolves through the factory's
return type and is never read as constructing a same-named class.

Tests: TypeScript fixture (inline `new`, a plain `.js` file for the
javascript provider, two-step, and the factory guard) and a Ruby fixture
(`Service.new` with and without an argument list, plus two-step). With
the source change stashed, the inline cases fail and the factory/two-step
cases still pass. The Python cases from the first commit are unchanged.

No Kotlin fixture: its cases passed without the change, so they would
document coverage this commit does not provide.

Full `test/integration/resolvers` + `test/unit/scope-resolution`: 4234
passed, 1 skipped. Ruby captures golden regenerated — additions only, no
existing digest changed.

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

* fix(resolution): only treat a construction selector as construction on the class itself (#2708)

The `selector: 'new'` rule fired on any receiver whose type was class-like,
which is true both when the receiver IS the class constant (`Factory.new`) and
when it is a value of that class (`factory.new`). `isClassLike(...)` cannot
tell those apart, so an instance receiver took the construction path too and
skipped the member lookup that should have run.

That replaced a CORRECT edge with a wrong one. Measured against the base build
on a class defining an instance method `new` returning a `Product`:

  factory = Factory.new; factory.new.run
    before this PR:  Product#run   (correct)
    after  this PR:  Factory#run   (wrong)

Track whether resolution currently sits on the class constant or on a value of
that class, and apply the selector rule only to the former. The head of a chain
is a class constant only when it resolved straight to a class binding rather
than through a typeBinding; every hop past it yields a value, so the flag
clears. The `obj.method()` branch derives the same fact from whether `objExpr`
is a bare name resolving to that class.

`Factory.new.run` keeps the behaviour this PR introduced (Factory#run), which
is itself a fix over the base build's Product#run.

KNOWN LIMITATION, now documented on the contract field and asserted by a test
so a future change to it is deliberate: a class-level override
(`def self.new` returning another type) is still read as construction. The
scope model records no staticness per member, so `def new` and `def self.new`
are indistinguishable at this layer; separating them needs the language
provider to record staticness first. An earlier attempt to use
`TypeRef.source` as a proxy was abandoned after tracing showed Ruby records
body-inferred return types as `return-annotation` too, so it does not
discriminate.

Tests: `ruby-construction-selector` fixture pins all three shapes — class
constant, instance receiver, and the documented class-level-override
limitation. Ruby resolver suites: 185 passed.

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

* fix(resolution): resolve generic construction receivers (#2708)

`new Box<string>().unwrap()` reached the class lookup as `Box<string>`, which
names no class binding, so the member edge was still dropped while the
non-generic spelling resolved. `new Foo<T>()` is ordinary in all three
keyword-wired languages, so the fix covered a materially narrower slice of
real code than intended.

Retry the lookup on the base name via `stripTemplateArguments` — the same
normalization `resolveClassBindingForName` already applies to typed receivers
in the sibling `receiver-bound-calls` pass. The exact-name lookup still runs
first, so a class whose name legitimately contains `<` is unaffected.

Measured on the probe that first showed the gap:

  before: | viaGeneric | Class:src/box.ts:Box |            (construction edge only)
  after:  | viaGeneric | Method:src/box.ts:Box.get#0 |     (member edge resolved)

Tests: `viaGenericCtor` added to the typescript-inline-constructor-receiver
fixture, asserting both the target file and that the resolved id is `Box`.

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

* fix(resolution): resolve construction in the chain-head position (#2708)

`new Service(db).inner.deep()` emitted only the construction edge. The chain
walker seeds its starting class from the head segment, which arrives as
`new Service(db)` and reduces via `stripCallParens` to `new Service` — no
binding and no class of that name, so the walk was never seeded and every
segment after it resolved to nothing.

Seed the head through the same construction rule the call branch already uses.
A constructed value is an instance, so the class-constant flag from the
previous commit correctly stays false — `new Factory().new` does not get the
selector treatment.

The gap was asymmetric across the languages this PR wires: Python's bare form
strips to a plain `Service` and was already seeded, so only the keyword
languages were affected.

Tests: `viaChainHead` added to the typescript-inline-constructor-receiver
fixture. Note the fixture annotates `readonly inner: Inner` explicitly —
with an unannotated initializer the walk stops at the field, which is
field-type inference and a separate concern from head seeding.

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

* fix(resolution): match the construction keyword by token, not by one space (#2708)

The keyword form was matched with `startsWith(`${keyword} `)`, so only a
single space separated `new` from the type. Any other trivia the source used
— a tab, a line break — failed the match and the member-call edge was lost.

Match the keyword as a whole token followed by one or more whitespace
characters instead. `newService()` still fails the match, which is the point:
it is an ordinary call, not a construction, and must keep resolving through
its own return type.

The keyword is escaped before it enters the pattern. It comes from a language
provider rather than from user input, but a keyword containing a regex
metacharacter would otherwise build a silently wrong pattern.

Tests: tab-separated and newline-separated `new` added to the
typescript-inline-constructor-receiver fixture. Note these cases only survive
because `gitnexus/test/fixtures/` is listed in the repo-root `.prettierignore`
— running prettier from inside `gitnexus/` does not pick that file up and
normalizes the tab away.

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

* fix(resolution): resolve qualified construction callees (#2708)

`new ns.Service().doWork()` emitted only the construction edge. The call
branch splits the callee at its last `.` before construction is considered,
so a qualified type name was routed into `obj.method()` resolution as if
`ns` were a receiver and `Service` a member.

A keyword-marked expression is never a member call, so resolve it as
construction before the split. The callee lookup now also handles a dotted
name: an unambiguous `qualifiedNames` match first, then the trailing simple
name, mirroring how receiver resolution elsewhere in this pass degrades.

Measured:

  before: | viaQualified | Class:src/svc.ts:Service |            (construction only)
  after:  | viaQualified | Method:src/svc.ts:Service.doWork#0 |

Bare-form qualified construction (Python `models.User(db).save()`) is NOT
addressed here: that shape currently emits no edges at all, including no
construction edge, so it is a namespace-import resolution gap upstream of
this pass rather than a construction-typing one.

Tests: `viaQualifiedCtor` added to the typescript-inline-constructor-receiver
fixture.

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

* fix(java): drop the unreachable constructionSyntax declaration (#2708)

Java was wired `{ keyword: 'new' }`, and the PR described it as one of the
languages that needed the fix. Measuring both ways shows it never did: Java
resolves `new Svc().doWork()` identically with and without the change,
because `java/captures.ts` (#2564) already rewrites an
`object_creation_expression` receiver to the constructed type's simple name,
so the raw `new Svc()` text never reaches this resolver.

The decisive evidence is generics: Java resolves `new Box<User>().doWork()`,
which the keyword path could not do before the template-argument fix earlier
in this series — the resolution demonstrably comes from the capture rewrite,
not from here.

Removing the declaration rather than leaving it as defensive configuration:
an unreachable per-language opt-in reads as coverage that does not exist, and
the contract now records why Java is excluded so the omission is not mistaken
for an oversight.

Verified after removal: the Java probe still resolves both the inline and
two-step spellings, and the Java resolver suites pass (252 passed, 1 skipped).

An earlier coordinator measurement in this review claimed Java WAS broken on
base; that comparison was invalid (the "without fix" build had not been
rebuilt). Corrected here.

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

* refactor(resolution): state the selector rule once and derive its option type (#2708)

Two follow-ups from review, no behaviour change (643 resolver tests pass
unchanged before and after):

The `Class.new` selector rule was written out twice — in the `obj.method()`
branch and again in the chain walker — against differently named locals,
while the construction helper's own doc comment claimed the rule was stated
in exactly one place. Both sites ask the identical question, so they now call
one `isConstructionSelectorHop` predicate, and the doc comment says what is
actually true.

`ResolveCompoundReceiverOptions.constructionSyntax` re-declared the contract's
object shape by hand. It was the file's first object-shaped duplicate, and
because the value arrives as a non-literal variable, TypeScript's excess
property check would not fire: a sub-field added to the contract later would
type-check and then be silently ignored here. It is now derived with
`ScopeResolver['constructionSyntax']`.

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

* test(resolution): cover the C# construction path and pin the wiring inventory (#2708)

Three coverage gaps from review, no behaviour change.

C# had no fixture despite being the only keyword-wired language whose
behaviour genuinely depends on the construction rule — measured absent on base
and present on head. `csharp-inline-constructor-receiver` covers the inline
spelling, the two-step spelling, and a static factory that must keep resolving
through its return type rather than being read as construction.

The TypeScript two-step assertion checked only `toContain('Service')`, and the
same fixture defines `LegacyService` — `'LegacyService'.includes('Service')` is
true, so the assertion could not distinguish the two targets. It now pins
`targetFilePath` the way its sibling assertions already do.

Nothing guarded the deliberate opt-in set, so an accidental wiring of a
language that already resolves the shape, or a silent loss of one that needs
it, would pass the whole suite. `construction-syntax-wiring.test.ts` pins the
inventory in both directions: exactly which languages declare
`constructionSyntax` and with which spelling, and that java/php/swift/dart/
kotlin stay unwired.

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

* chore(storage): bump INCREMENTAL_SCHEMA_VERSION to 23 for the #2708 edge changes

This series changes which CALLS edges are emitted for source whose CONTENT has
not changed — inline constructor receivers that previously emitted nothing now
resolve, and the Ruby selector fix moves one edge back to the member it always
belonged to. That is precisely the class of change the version-history block in
this file requires a bump for, and the reuse gate is a strict equality on the
persisted stamp.

Without it, every existing v22 index passes the gate on the next `analyze` —
or is served by the same-commit "already up to date" fast path — and keeps
returning the pre-fix graph for unchanged files. `impact(direction: "upstream")`
and `context()` would go on omitting the very callers #2708 is about, with no
warning, until something unrelated forced a full re-analyze. The fix would
have shipped without reaching anyone who already had an index.

Precedent is unbroken across the recent resolution PRs: #2723 → v22,
#2699 → v21, #2695 → v20, #2563 → v14, each with its own rationale paragraph.
This adds v23 in the same form.

The pinned assertion in call-summary-schema-version.test.ts moves with it, as
that test documents it is designed to.

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

* chore(bench): re-baseline the fixture-corpus fingerprints for #2708

Both bench harnesses fingerprint an entire fixture corpus by directory prefix
(`bench/python-scope/measure.mjs:38`, `bench/scope-capture/measure.mjs:76`), so
every fixture directory this series adds moves a committed baseline. Neither
script writes the baseline itself — running without `--check` only prints, and
the file is edited deliberately, which is what its own comment asks for.

Regenerated, last in the series so the fixture set was final:

  bench/python-scope/baseline-fingerprint.txt   36e29abc… -> f120df92…
  bench/scope-capture/baselines.json  ruby       070e4e11… -> fea3edf8…
                                      typescript 281e9548… -> cad25be9…
                                      csharp     e05dc274… -> 05a85bae…

CI only ever reported the python drift, because the benchmarks job runs the
python step first and aborts there; the cross-language step never ran. Both
were verified locally after the update:

  [measure --check] PASS (capture fingerprint + scaling)
  [import-target-fingerprint --check] PASS (resolver fingerprint)
  [scope-capture --check] PASS (15 languages)

The `csharp` and `ruby` entries moved because of the fixtures added earlier in
this series, not the original ones — a reminder that this baseline moves with
any fixture addition, not just the one that first triggered it.

Captures goldens regenerated alongside (csharp, ruby); both additive only, no
existing digest changed. The python golden did not move: no `python-*` fixture
was added after its last regeneration.

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

* Update tests for passesReuseGate function

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:19:53 +01:00
Gergő Magyar
df06529950
fix(rust): resolve module-qualified calls against the module tree (#2730) (#2741)
* fix(rust): resolve module-qualified calls against the module tree (#2730)

A Rust call written with a path (`tools::dispatch(..)`) was captured with only
its tail identifier, making it indistinguishable from a bare `dispatch(..)`.
The scope-chain walk then resolved the bare name lexically and bound it to
whatever `dispatch` was nearest — which, for the common wrapper idiom

    fn dispatch(..) -> ToolOutcome { tools::dispatch(..) }

is the wrapper itself. The graph gained a self-loop, the real cross-module edge
never existed, and `impact` reported the callee as unreached: the issue's
repository showed its central tool dispatcher as `risk: LOW` with 0 affected
processes and both "callers" being `#[cfg(test)]` functions, while still
labelling the result `epistemic: "exact"`.

Resolve paths the way rustc does, over the module tree rather than the
filesystem:

  - `mod_item` now emits `@declaration.namespace`, so a Rust module is a named
    definition rather than an anonymous scope region. This mirrors the existing
    C++ `namespace_definition` capture and lets the shared `tagNamespacePrefixes`
    pass stamp members with their enclosing module path — that pass needed no
    changes to start working for Rust.
  - `module-path.ts` reconstructs the other half of the tree: crate roots are
    directories holding `main.rs`/`lib.rs`, and a file's module path is its
    location below that root. A definition's module is its file's module plus
    any enclosing `mod` blocks.
  - `crate::`, `self::` and `super::` are prefix transforms on the calling
    module, not reasons to stop resolving.
  - The final path segment is looked up as a member of the resolved module,
    including members it only re-exports. A `pub use` creates no binding on the
    re-exporting module's own scope, so re-exports are followed through that
    module's import edges.

Resolution runs ahead of the implicit-`this` and scope-chain tiers, so an
explicit path outranks a lexical shadow, and returns undefined on an unknown
module, a missing member or a tie — leaving the existing chain untouched. The
new `ScopeResolver.resolveQualifiedFreeCall` hook is optional and unset for
every other language, so this is additive.

Fixes the reported case (direct callers 2 -> 3, impacted 2 -> 6, the Agent
module now visible) plus multi-segment paths, `super::` paths and `pub use`
facades, each of which previously produced a wrong edge.

Known limitation, pre-existing and unchanged by this commit: an inline
`mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same file
collapse to one graph node, because node identity is `<file>:<qualifiedName>`
and does not carry the module path. That is a separate defect requiring
module-path-qualified node ids and an incremental-schema migration.

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

* test(rust): rebaseline the scope-capture fingerprint for the module-tree captures

`mod_item` now emits `@declaration.namespace` and scoped call sites carry
`@reference.qualified-name`. Both are additive, so every bench fixture holding a
`mod` block or a `Foo::bar()` call gains capture groups, and the corpus grew by
the three `rust-2730-*` fixtures.

Only the Rust fingerprint moves. The other 14 languages are byte-identical,
which is the intended blast radius for a language-local capture change.
Scaling stays linear at 1.043, well inside the 1.5 budget.

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

* fix(rust): carry crate identity in qualified module paths (#2741 review H1)

A module was identified by its path segments below a crate root, so
`crates/alpha/src/tools.rs` and `crates/beta/src/tools.rs` were the same module.
A cargo workspace routinely gives several members the same internal module name
— `util`, `error`, `config`, `types` are near-universal — and that made
qualified resolution do one of two wrong things:

  - where only one member defined the called name, the call bound ACROSS crates;
  - where both defined it, the lookup saw two candidates, refused, and handed the
    site back to the lexical walk that emits the same-name self-loop. The fix for
    #2730 therefore switched itself off in exactly the workspace layouts it was
    written for, and #2730's own reported reproduction repository is multi-crate.

A module is now `{ crateRoot, segments }` and `sameModule` compares both. Rust
has no implicit cross-crate paths — reaching another crate requires naming it —
so two modules in different crates are never the same module. Anchored paths
(`crate::`, `self::`, `super::`) resolve inside the caller's own crate and
inherit its root.

Covered by a two-member workspace fixture where both crates define
`tools::dispatch` behind a same-name wrapper, plus unit tests for the path
arithmetic itself, including the branches no fixture reaches (a file under no
crate root, a `super::` chain walking above the crate root).

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

* fix(rust): count only module members when resolving a qualified call (#2741 review H3)

Module membership was inferred from the file path alone, so any callable in the
right file counted as a member of the module. A `fn` nested inside another `fn`
has the same `filePath`, the same bare `qualifiedName` and no owner, making it
indistinguishable from a module-level item:

    pub fn dispatch() -> usize { 3 }              // the real member
    pub fn wrapper() -> usize {
        fn dispatch() -> usize { 99 }             // counted as a second member
        dispatch()
    }

Two candidates tie, the lookup refuses, and the call falls back to the lexical
walk that emits the same-name self-loop — so an unrelated local helper anywhere
in a module silently reinstated #2730 for every qualified call into it.

The scope model already draws the line exactly: a module-level item is bound
with `origin: 'local'` in its module's own scope, a function-local item binds in
the enclosing Block, and an `impl`/trait method binds in the Class scope.
Membership is now that binding lookup rather than a path comparison.

Inline-`mod` members bind in their Namespace scope rather than the file's Module
scope, and reaching it would mean walking every child scope — faulting them back
in from disk on the out-of-core path. They keep being identified by the
`namespacePrefix` the shared tagging pass stamps on them, which a file-module
member never carries. The documented residual is a `fn` nested inside a `fn`
inside an inline `mod`, which inherits that prefix; that is strictly smaller than
before and costs a refusal, never a wrong edge.

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

* fix(rust): require a use-binding to name a module, not a type (#2741 review H2)

Import resolution deliberately strips a trailing symbol segment when probing for
a file — "the last segment might be a symbol (function, struct, etc.), not a
module. Strip it and try again" (import-resolvers/rust.ts). So
`use crate::client::ClientBuilder;` also resolves to `client/mod.rs`.

The qualified-call resolver took that at face value and treated the imported
TYPE as the module `client`. Rust impl methods carry a bare `qualifiedName`, so
`ClientBuilder::new()` was then looked up among `client`'s module members and
bound to an unrelated module-level `new` — turning an unresolved site into a
false edge, which the module's own contract calls the worse outcome.

A binding now has to name the module it resolved to. The edge's
`targetExportedName` is the tail of the written path, so comparing it against the
resolved module's own tail separates the cases exactly:

    use crate::tools;                 tail `tools`         module ['tools']    accept
    use crate:🅰️:b as tools;         tail `b`             module ['a','b']    accept
    use crate::tools::{self, Ctx};    tail `tools`         module ['tools']    accept
    use crate::client::ClientBuilder; tail `ClientBuilder` module ['client']   reject

Covered by a fixture where `client/mod.rs` deliberately holds both
`impl ClientBuilder { fn new }` and a module-level `fn new`, so a regression
re-binds to the wrong one, plus a control asserting a genuine `client::new()`
module qualifier still resolves.

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

* fix(rust): give src/bin targets their own crate root (#2741 review)

Cargo auto-discovers a binary target for every `src/bin/<name>.rs`. Each is a
separate crate with its own `crate::` root, and its submodules live under
`src/bin/<name>/`.

Only `main.rs` and `lib.rs` established a crate root, so those entry files were
folded into the surrounding library and given the invented module path
`bin::<name>`. That made `crate::helper()` inside a binary resolve into the
LIBRARY's `helper` — and unlike the other findings in this review, this one
downgraded an edge the lexical walk had previously resolved correctly, so it
made existing output worse rather than merely failing to improve it.

`src/bin/<name>.rs` is now its own crate root (as is the `src/bin/<name>/main.rs`
directory form), so a binary's modules and the library's modules of the same name
are no longer the same module.

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

* fix(rust): only try a submodule candidate the caller actually declares (#2741 review)

The first candidate module was `callerModule ++ qualifier`, yielded before the
`use` channel and never checked against anything. That let file layout outrank a
real import: with `use crate::b;` in `src/a/mod.rs` and an undeclared — or
`cfg`-gated — `src/a/b.rs` present on disk, `b::f()` bound to the sibling file,
where rustc resolves it to `crate::b`.

A `mod` declaration, inline or file-backed, emits a `Namespace` def bound locally
in the declaring scope, so the candidate is now gated on that binding rather than
assumed. When the caller does not declare the submodule the candidate is skipped
and the `use` and crate-root channels still run, so this only removes guesses.

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

* fix(rust): follow only real re-exports, and refuse on an ambiguous one (#2741 review)

Two problems in the re-export channel.

A private `use` was followed as though it re-exported. `use crate::tools::helper;`
makes `helper` visible INSIDE the module; it does not put it on the module's
public surface, so `facade::helper()` does not compile. Only `pub use` does, and
finalize already distinguishes them — `reexport` for `pub use`, `named` for a
private one. The `alias` kind is now accepted alongside `reexport`, because
`pub use x::y as name` is a re-export that was previously ignored entirely.

The lookup also took the first matching edge in file-iteration order, which is
parse-pool order. Two `cfg`-exclusive facades re-exporting the same name are
indistinguishable at this layer, so picking one baked a coin flip into the graph.
It now refuses on a genuine tie, consistent with how member lookup already
behaves.

The pre-existing limitation that only FILE modules are reachable — a `pub use`
inside an inline `mod facade { … }` has no `moduleScopeByFile` entry — is now
stated in the code. Reaching those would mean walking every child scope and
faulting the scope tree back in from disk, which is the cost that index exists to
avoid; a miss falls through to the unchanged chain rather than guessing.

The regression test deliberately makes the re-exported name globally ambiguous.
Without that, the pre-existing unique-global free-call fallback resolves the call
on its own and the assertion passes whatever this channel does.

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

* perf(rust): stop type-qualified calls paying for module resolution (#2741 review)

The capture carrying `rawQualifiedName` matches every `scoped_identifier`
callee, so this hook was reached by `Vec::new()`, `String::from()`,
`Self::method()` and every other type-qualified call — the overwhelming majority
of `::` calls in real Rust, none of which name a module. Each one ran the full
candidate search before returning undefined, and every candidate that missed then
walked all of `workspaceIndex.moduleScopeByFile`. Total cost grew as
`qualified-call-sites x files`; two independent measurements put per-site cost at
0.117 -> 0.428 ms across 301 -> 1201 files, i.e. linear in workspace size.

Two changes:

  - The module index now carries a flat set of every module segment name in the
    workspace, and a qualifier whose head matches none of them is rejected before
    any candidate work. Measured at 0.02 us per rejected call and flat in file
    count (500 -> 8000 files), against a previously linear per-site cost.

  - Module scopes are indexed by module identity once per pass rather than
    rediscovered by scanning every file per candidate. On the out-of-core scope
    index that scan was worse than CPU: `moduleScopeByFile` fetches through
    `scopeTree.getScope`, so a full sweep could fault every module scope back in
    from disk — the pattern `workspace-index.ts` added `exportedCallableByName`
    to avoid. Given the #2649 and #1871 history this mattered before merge.

The captures golden is regenerated for the fixture files added earlier in this
series; `emitRustScopeCaptures` itself is unchanged by this commit.

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

* fix(storage): bump schema versions so the #2730 fix reaches existing indexes

Neither invalidation constant was bumped, so the fix did not reach the users who
reported the bug.

`INCREMENTAL_SCHEMA_VERSION` 22 -> 23. The incremental write set only covers
CHANGED files, so a top-up against a pre-v23 index keeps the wrong self-loop —
and keeps reporting the callee as unreached — for every unchanged Rust file. The
constant's own doc block states this rule, and the precedent is exact: v11 is the
same file (`rust/query.ts`) gaining a capture that changes CALLS edges, with the
same "force a full re-analyze" contract, and v12 is a second Rust instance.

`SCHEMA_BUMP` 30 -> 31. `@declaration.namespace` and `@reference.qualified-name`
are parse-time captures, so a warm parse cache replays the old capture set
verbatim: `rawQualifiedName` comes back undefined and no Namespace def exists to
hang a module prefix on, turning the entire resolution tier into a no-op on
unchanged files. `PARSE_CACHE_VERSION` folds in the package version, so a tagged
release would have invalidated eventually — but source, dev and CI builds at the
same version would not, and the v29 note already warns that relying on someone
else's bump is how a change ships with no invalidation at all. Re-checked against
origin/main at commit time, as that note instructs.

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

* fix(scope-resolution): let a language opt out of the already-namespaced guard (#2741 review)

`tagNamespacePrefixes` skips a def whose `qualifiedName` already equals, or is
prefixed by, its enclosing namespace path. That is right for C++ and C#, where
the qualified name genuinely carries the namespace.

Rust qualified names never do, so the guard fired on a coincidence: in
`mod a { pub fn a() }` the member's name equals its module's name, the prefix was
skipped, and `moduleOfDef` then reported the member as belonging to the PARENT
module. `crate:🅰️:a()` refused, and the def became indistinguishable from a
crate-root `fn a` for the module matcher.

The guard is now conditional on a `qualifiedNamesCarryNamespace` option that
defaults to the existing behaviour, and Rust opts out. The shared pass stays
language-neutral — the decision lives with the provider that knows what its own
qualified names contain.

C++ and C# resolver suites pass unchanged alongside the Rust ones (600 tests).

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

* fix(rust): refuse a leading :: path instead of reading it as relative (#2741 review)

A leading `::` anchors at the extern prelude: `::tools::dispatch()` names the
CRATE `tools`, not a module of the current one. The path split filtered the empty
leading segment away, which silently reinterpreted the path as relative and let
it resolve against a local module that happens to share the name.

Extern crates are outside the workspace module tree, so the qualified tier now
refuses and leaves the site to the unchanged chain.

The regression test asserts the tier does not bind into the local `tools` module,
rather than asserting no edge at all: the lexical tier still resolves the bare
tail on its own, and that behaviour is not what this change governs. Asserting an
empty edge list would have been testing a different tier.

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

* refactor(rust): reuse the canonical callable predicate and drop dead re-exports (#2741 review)

`CALLABLE_TYPES` was a local copy of the set behind `isOverloadableCallable` in
`utils/callable-labels.ts`. Two copies of the same set drift: extending the
canonical one with a new callable kind would silently leave qualified calls of
that kind unresolved here, with nothing to catch it. Use the shared predicate.

The trailing `export { moduleOfFile, moduleOfDef }` and
`export type { ScopeResolutionIndexes }` were commented as being "for the
resolver's unit tests". No test imports them: the only importer of this module
anywhere in src or test is `rust/scope-resolver.ts`, which takes just
`resolveRustQualifiedFreeCall`. Both functions are already exported from
`module-path.ts` (where the new unit tests take them from), and
`ScopeResolutionIndexes` is canonically exported from
`model/scope-resolution-indexes.ts`. Removed rather than left as surface that
implies a contract it does not have.

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

* test(rust): rebaseline the scope-capture fingerprint with the correct prior hash

The rebaseline note added with the original fix cited
`Prior 655aed01…`, which was two rebaselines stale — it predates both #2604 and
#2714. The true pre-PR value on the base commit is `7f1240b3…`. CI could not
catch it: the gate compares the live fingerprint against the stored one and never
reads the prose, so the audit chain these notes exist to provide was broken with
nothing to flag it.

The note now carries the correct prior value, and the fingerprint is regenerated
for the fixtures this review series added. Scaling 1.061, well inside the 1.5
budget; fixture_count 196; the other 14 languages remain byte-identical.

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

* test: move the schema-version pin to 23

`call-summary-schema-version.test.ts` asserts the exact value of
`INCREMENTAL_SCHEMA_VERSION` and enumerates which stamped versions the
incremental reuse gate accepts. It moves with every bump by design — that pin is
what stops an id- or edge-changing commit shipping without invalidation.

Updated for the bump to 23, with the pre-v23 case added to the reuse-gate table:
a v22 index predates Rust module-qualified call resolution, so every unchanged
Rust file would keep the same-name self-loop and keep reporting the real callee
as unreached.

Caught by CI rather than locally, because the earlier sweeps in this series
covered `test/integration/resolvers/` and `test/unit/scope-resolution/` only —
the pin lives outside both.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:41:47 +01:00
MyShining
ff86ccf1e7
feat(spring): model profiles, conditions, and auto-configuration (#2678)
* feat(spring): model conditions and auto-configuration

* fix(spring): align auto-configuration declarations

* perf(spring): streamline auto-configuration indexing

* test(spring): move timing benchmark out of vitest

---------

Co-authored-by: Shining <xuenning@qiyi.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-28 07:05:41 +01:00
Gergő Magyar
e307286d52
fix(scope-resolution): a named receiver's member never resolves lexically, + two #2695 follow-ups (#2714)
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): a named receiver's member never resolves lexically (#2699)

`lookupCore` Step 1 walked the lexical scope chain for every lookup, including
explicit-receiver property reads. So `options.baseUrl` could bind to an
unrelated function-local `const baseUrl` in the same file, and
`config.extractVisibility(node)` to the enclosing class's own method.

This is the residual half of the defect JS/TS block scopes narrowed in #2695.
Blocks moved nested-block locals off the chain of a reference outside the
block, which removed 114 false edges; a local declared directly in the function
body stayed on it, and no amount of extra scopes reaches that case. Fixed at
the cause instead: `recv.name` names a member of whatever `recv` denotes, so a
binding of the bare tail name in an enclosing scope is never the right answer.
Steps 2 and 3 (receiver type / owner members) are the legitimate routes.

`this` and `self` are EXEMPT, and that exemption was measured, not assumed.
Skipping Step 1 for every explicit receiver removed 711 edges on a 762-file
corpus — but 2 of those were genuine: `self.srcIx` and `self.streamedAt(...)`
after `const self = this`, reaching their own class's members through the
class-body scope. For a self-receiver the members and the lexical chain
legitimately overlap; for a named receiver they never do. Exempting the self
names keeps both true edges and still removes 709 false ones, adding none.

The removals were classified by reading source at the site, not by pattern-
matching ids — an "is the target a member of the source's owner?" heuristic
labelled 43 of them plausible and every one I then read was false:

    language = config.language;          -> the class's own `language`
    dirMap.get(...) / exactMap.get(...)  -> a sibling object-literal `get`
    return config.extractVisibility(n);  -> the class's own method (self-edge)
    writer.close();                      -> GraphEmitSink.close

Residual, deliberately kept: a `this.x` read can still bind lexically to a
same-named local. That is the price of the two true self-alias edges above.

`INCREMENTAL_SCHEMA_VERSION` 19 -> 20: a v19 index holds these false
CALLS/ACCESSES on every unchanged file and would keep serving them through the
reuse gate.

Test confirmed discriminating: it fails with the guard reverted.

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

* fix(typescript,javascript): a generator expression binding is a Function node (#2693)

`const g = function* () {}` matched none of the closure-binding definition
rules — they covered `arrow_function` and `function_expression` only — so the
binding emitted a `Const` node. `buildGraphTargetIndex` admits callable nodes
only, so `g()` resolved to nothing.

Same defect shape as the `var` case #2693 already fixed: a different grammar
node for the same construct, and the resulting graph node was not callable.

Adds the four variable-binding shapes in both languages: `const`/`let` and
`var`, each plain and exported. Purely additive — no existing pattern is
reordered or rewritten, because the #2687 pre-scan dedup is order-dependent
and collapsing the value/callable pair depends on which match wins.

Deliberately NOT covered, and the query comment says so: a generator in an
object-literal pair or a HOC wrapper still falls through anonymous. Those are
rarer, and each additional pattern is another chance to disturb the dedup.

`SCHEMA_BUMP` 26 -> 27: definition captures are parse-time, so a warm parse
cache would replay the old ones verbatim — `--force` does not clear it.

Two tests confirmed discriminating (they fail with the patterns reverted), plus
a guard that the already-working generator DECLARATION form is unaffected,
since it shares the emit path these were inserted beside.

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

* fix(ingestion): keep caller attribution in lockstep with definition ids (#2699)

The definition phase appends `localIdentity` to a nested callable's own name
segment (`run.save@3:2`); `findEnclosingFunctionId` did not, so the two phases
derived different ids for the same callable. The failure mode is silent — the
caller id names a node that does not exist, so the edge is dropped rather than
reported — which is why the parse-worker docblock calls this pair a lockstep
guarantee and asks that both phases derive the prefix from one place.

The condition is now byte-identical to the definition phase's
(`nestedPrefix !== undefined`), so the two cannot diverge again.

Scope of the claim, stated plainly: no reproducing case was found, and this
changes nothing measurable on a 762-file TypeScript corpus. TS/JS resolve
callers through `resolveCallerGraphId` in the graph bridge, not this path;
`findEnclosingFunctionId` serves the `callExtractor` languages, and the
corpus does not exercise a nested callable there. The review that raised it
(P3) observed zero dangling edges, and "zero dangling" is also what silently
dropped edges look like — so this closes a documented contract rather than a
demonstrated bug, and carries no test of its own.

Rides the `SCHEMA_BUMP` 26 -> 27 in the preceding commit: caller attribution
runs in the worker, so a warm parse cache would replay the old ids.

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

* docs(test): correct the block-scope header that this PR made false (#2699)

Review finding (MEDIUM). The file header still described `lookupCore` Step 1 as
walking the lexical chain for EVERY lookup, and called the function-body-local
case "unchanged and still mis-resolves ... pre-existing and tracked
separately". Commit 59b892ca in this same PR falsified both, and the describe
block added ~80 lines lower in this same file asserts the opposite — a reader
scoping future work from the header would have concluded the case was still
open.

Rewritten to state what the code does: Step 1 is skipped for a NAMED explicit
receiver, the function-body case is fixed here, and the surviving residual is
that a `this`/`self` read can still bind lexically to a same-named local —
with the reason those two names are exempt (they keep the genuine
`const self = this; self.member` reads that Step 1 resolves correctly).

Also corrects a PRE-EXISTING staleness inherited from #2695 in the same
paragraph block: "the genuine bare read of that same local must still emit its
edge" describes a test that no longer exists, because TypeScript emits no
`@reference.read` for bare identifiers at all. Fixed here rather than left
adjacent to a freshly corrected sentence.

Comments only — `detect_changes` reports 0 changed symbols across 1 file.

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

* refactor(ingestion): give the nested-callable id rule one definition (#2699)

Review finding (LOW): the lockstep change in this PR shipped without a test.
The plan called for a unit test asserting the two id-derivation phases agree.
Two things changed that plan during execution, both recorded here.

FIRST — there are THREE phases, not two. Re-verifying the plan's assumption
(`grep -n localIdentity`) found a third call site: the worker-path node-id
derivation in `processFileGroup` (parse-worker.ts:2316), whose own comment
already acknowledged the coupling. `impact` on `localIdentity` corroborates:
three direct dependents, all in the Workers module. So the invariant three
phases must agree on is now ONE function, `nestedCallableQualifiedName`, and
divergence requires deleting a call rather than editing a duplicated
expression.

SECOND — the planned `_forTest` alias seam does not work for this module.
`parse-worker.ts` posts a `ready` message to `parentPort` at module scope, so
value-importing it from a unit test throws before any test runs; the existing
unit tests that reference it use `import type` only, which erases. The rules
therefore move to a new pure module, `workers/callable-id.ts`. That is what
makes them testable at all, rather than merely commented.

Pure refactor — no id changes. Verified by the suites that assert exact node
ids (`Function:svc.ts:run.save@7:2`, `Function:c.php:run.$save@3:2`): 74/74
green, and `detect_changes` reports only the three expected symbols and the
two `processFileGroup` flows `impact` predicted.

The test pins both halves: the rule's contract, and a structural assertion
that no site has re-inlined `${prefix}.${localIdentity(...)}` — the unit
assertions alone would still pass if a fourth phase spelled the rule out by
hand, which is exactly how the divergence arose.

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

* fix(scope-resolution): give PHP's `$this` the same self-receiver exemption (#2699)

Review finding (LOW). The Step-1 skip added in this PR exempts `this`/`self`,
but the receiver name arrives as the reference node's RAW SOURCE TEXT —
`extractExplicitReceiver` returns `cap.text` verbatim — so PHP's `$this->x`
presents as the string "$this" and matched neither entry. PHP was the one
supported language whose self-receiver got no exemption at all.

Measured, and the measurement is why this is framed as consistency rather
than a bug fix:

  - Corpus delta ZERO. 762-file TypeScript corpus, CALLS+ACCESSES set diff:
    13179 -> 13179, added 0, removed 0. So no INCREMENTAL_SCHEMA_VERSION bump
    (stays 20), per the plan's decision rule.
  - No PHP shape found that DISCRIMINATES. Both the simple `$this->prop` /
    `$this->helper()` shapes and a closure reading `$this->…` inside a method
    that also declares a same-named local produce byte-identical edge sets
    with `$this` present and absent — Step 2 resolves the receiver's type
    first. The added test is therefore labelled a COMPANION INVARIANT, exactly
    as the `this.baseUrl` case beside it is, and does not claim to prove the
    fix.

It is still worth making: the exemption is protective, and the 709-removed /
0-true-lost measurement that justified the narrow guard was TypeScript-only,
so PHP's safety was never established by evidence. This closes that by
construction.

Two corrections to what the plan assumed, both found by checking:

  - The plan (and my first draft of this comment) claimed the codebase had no
    precedent for handling a sigil'd receiver name. FALSE: `THIS_RECEIVERS` in
    `core/ingestion/type-env.ts:244` has always listed `$this`, and it is the
    ingestion-side twin of this very list. The precedent does not merely
    exist, it validates the approach chosen here — list the spelling as data,
    do not strip sigils.
  - That twin also lists `Me`. Deliberately NOT mirrored: no entry in
    `SupportedLanguages` is Visual Basic, so it could only ever exempt a
    variable that happens to be called `Me`.

The two lists are otherwise the same set with nothing enforcing it — a fifth
instance of the twin-list drift class this PR keeps meeting. A drift guard is
the right fix and is out of scope here; noted for follow-up.

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

* fix(rust): resolve `Self` in scope-resolution type bindings (#2699)

CI regression, caught by `tests / ubuntu / coverage` on 13d5e738 and traced to
the named-receiver Step-1 skip earlier in this PR (59b892ca), not to the three
commits above it — verified by reverting those three and reproducing the
failure unchanged.

`test/integration/resolvers/rust.test.ts > resolves fresh.validate() inside
impl User via Self {} inference` failed: 192/192 on main, 191/192 on this
branch. The fixture calls `fresh.validate()` where `let fresh = Self { .. }`
inside `impl User` — a genuine call to `User::validate`, and a TRUE edge that
the skip deleted.

Root cause is a twin-channel disagreement, not the skip:

  - `type-extractors/rust.ts:142` substitutes `Self` -> the enclosing impl
    type into the TYPE-ENV channel via `findEnclosingImplType`.
  - `languages/rust/interpret.ts` recorded `@type-binding.type` verbatim, so
    the SCOPE-RESOLUTION channel bound `fresh: Self` — a type that does not
    exist, leaving the receiver's type unknown and Step 2 unable to resolve.

`main` passed only because Step 1 still walked the lexical chain for named
receivers: the impl scope binds `validate` by name, so the call resolved BY
ACCIDENT. Stopping that walk turned a latent gap into a lost edge. The fix
closes the gap rather than restoring the accident — `Self` is now substituted
at capture-emit time in `languages/rust/captures.ts`, where the impl node is
reachable, reusing the `findEnclosingImpl` + `syntheticCapture` idiom already
in that file.

CORRECTION to this PR's central claim. "709 removed / 0 added / 0 true edges
lost" was measured on a 762-file TYPESCRIPT corpus and stated without that
qualifier. Rust lost one true edge. The measurement stands for TypeScript; it
did not generalise, and the PR body is being updated to say so.

Scope of the breakage, measured rather than assumed: 1 failure in 2927 tests
across all 51 resolver files. Every other language — Go, Java, C#, Kotlin,
Swift, Python, PHP, Ruby, Dart, C++ — passes, which is why this is a targeted
fix and not a revert of the skip.

Re-baselined `bench/scope-capture` for RUST ONLY (655aed01 -> 7f1240b3); the
other 14 language fingerprints are byte-identical. The drift is the intended
output change and the reason is recorded in the baseline entry, per that
file's own "explain, never re-baseline to make CI green" rule.

Verified: rust resolvers 192/192; all 51 resolver files 2926 passed / 1
skipped / 0 failed; the 8 targeted suites 96/96; all 8 CI bench gates PASS;
`tsc --noEmit` clean; `detect_changes` reports one touched symbol
(`emitRustScopeCaptures`) and no affected flows.

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

* test(golden): refresh the Rust capture golden and the C# PDG snapshot (#2699)

The two committed artifacts CI flagged after 5f55fe46. They drifted for
OPPOSITE reasons, so each was inspected before regenerating rather than
refreshed on sight.

RUST GOLDEN — drifted because 5f55fe46 CORRECTS the output. A `Self` type
binding now records the enclosing impl's type instead of the literal `Self`,
in both the `let x = Self { .. }` and `fn new() -> Self` forms. Blast radius
verified exact: 5 fixtures drifted, all 5 contain `Self`, and every
`Self`-bearing rust fixture is among them (rust-self-struct-literal,
rust-constructor-type-inference, rust-default-constructor,
rust-method-enrichment, rust-scoped-multi-file).

C# PDG SNAPSHOT — drifted because the named-receiver Step-1 skip (59b892ca)
REMOVED A FALSE EDGE. CALLS 7 -> 6, and the edge that went is:

    Demo.Resolve.Parse@142:12#1 -> Demo.Resolve.Parse@142:12#1

a self-call, from `int Parse(string v) => int.Parse(v);`. `int.Parse(v)` is
System.Int32.Parse; the lexical chain was binding it to the enclosing local
function that happens to also be called `Parse`. Same defect class as
`writer.close()` -> GraphEmitSink.close. The snapshot's own comment says it
exists so "a future refactor that silently rewires the C-family graph trips
this gate" — it tripped correctly, and the rewiring is an improvement.

Both failures were PRE-EXISTING on this PR from 59b892ca, not from the three
commits above it — verified by reverting those and reproducing unchanged. They
went unseen because this PR's CI was never watched after its first push.

Verified after regeneration, WITHOUT update flags so they must genuinely pass:
rust-captures-golden 9/9; pipeline-pdg 31/31. The snapshot diff is 3 lines,
all inside the C# entry — no other language's snapshot moved. `detect_changes`
reports 0 changed symbols (test artifacts only).

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:56:38 +01:00
Gergő Magyar
4906daf27b
fix(scope-resolution): resolve calls through a closure-valued binding across languages (#2693) (#2695)
* fix(scope-resolution): resolve calls through a closure-valued binding (#2693)

`val f = { }; f()` emitted no CALLS edge in Kotlin or Swift, so `impact` on
such a symbol under-reported to zero — the same false all-clear as #2687.

The cause was not, as first suspected, that these languages fail to feed
`callable-value-flow`. They do: `synthesizeCallableFlowCaptures` is called
from 15 language capture modules, and Kotlin already resolves reassignment
through the pass (`var f = ::a; if (c) f = ::b; f(1)` reaches both targets).
Their captures are already exactly right — the seed names the binding as its
own callable, per the anonymous-callable convention in
callable-flow-captures.ts.

They died one layer later, at the `buildGraphTargetIndex` gate:

    if (!isCallable(def) && providerTarget?.(def) !== true) continue;

`isCallable` is Function/Method/Constructor, but the scope-resolution layer
declares a closure binding with its VALUE label (Kotlin/Swift `Property`),
and `isCallableValueTarget` is implemented by exactly one provider — COBOL.
So the binding never entered `graphTargets`; `lexicalCallableLookup` then
returned `shadowed: true` with no targets, which also suppressed the
workspace-wide fallback, and the seed resolved to nothing.

Only the graph knows a value binding holds a callable — since #2687 it emits
a single `Function` node for one. So value bindings now resolve their graph
id first and are admitted on the label of the node they actually reach.

This is self-limiting: a genuine constant keeps its own Const/Property node,
so `resolveDefGraphId`'s qualified key hits before the label-agnostic
`simpleKey` fallback can reach a same-named callable. Only a binding whose
own value node was replaced by a callable one gets through.

No scope kind changes — Kotlin's `lambda_literal` stays `@scope.block`, so
#1757 smart-cast semantics are untouched by construction. The fix is
language-neutral: it discriminates on the graph node label, never on a
language name.

Dart is fixed separately; its root cause is independent.

* fix(dart): resolve calls through a closure-valued binding (#2693)

Dart needed more than the shared gate fix: neither of its closure-binding
forms could resolve, for two different reasons, and the plan's one-line
diagnosis turned out to be incomplete.

TOP-LEVEL `var f = (x) => x;`
  A graph Function node already existed (#2687), but no `@declaration.*`
  matched the binding, so scope resolution had no SymbolDefinition to attach
  a flow seed to. Adding the declaration exposed a second problem: Dart's
  `initialized_identifier` is FIELDLESS, so the shared field-based assignment
  fallback (`left`/`name`/`value`/…) decomposed nothing and the binding still
  emitted no flow captures at all. Kotlin's fieldless `assignment` node hit
  exactly this and took the same remedy — a provider `extractAssignment`.

FUNCTION-LOCAL `void m() { var f = (x) => x; }`
  Locals parse as `initialized_variable_definition`, which the top-level
  graph-node rules are deliberately anchored under (program) to avoid, so a
  local closure had no graph node at all — nothing for the widened
  `buildGraphTargetIndex` gate to admit.

Both new rules are restricted to a `function_expression` value. Declaring
every Dart variable would mint defs and nodes repo-wide for no resolution
benefit; ordinary locals stay unindexed exactly as before. The top-level
declaration reuses the (program) anchor the graph-node query already relies
on, so class-body fields — which share `initialized_identifier_list` and are
already `@declaration.property` — are never matched twice.

Also drops the now-false note in tree-sitter-queries.ts claiming `f()` does
not resolve for Dart. That node is now the evidence that makes it resolve.

* docs(scope-resolution): document the callable-flow capture contract (#2693)

The module is 1200+ lines behind a nine-line docblock, and the only worked
example was C. Both root causes fixed in this series were "the contract was
discoverable only by reading the emitter":

  - the anonymous-callable convention (a seed whose source is a closure takes
    its DESTINATION's name) is what makes closure bindings resolvable at all,
    and is the reason the widened target gate is correct;
  - a fieldless binding node silently decomposes to nothing under the shared
    assignment fallback, which cost Kotlin one debugging cycle in #2522 and
    Dart another here;
  - captures alone are never enough — the bound name also needs a
    `@declaration.*` or there is no cell to key the seed on.

Records the cell/site model, both traps, and points at the fullest and
smallest worked examples.

Bumps INCREMENTAL_SCHEMA_VERSION 15 → 16 and the parse-cache SCHEMA_BUMP
22 → 23: this series emits NEW CALLS edges and new Dart Function nodes, and
the incremental write set only covers changed files, so an existing index
would keep reporting a zero blast radius for exactly the symbols the fix is
about.

* perf(scope-resolution): pre-filter value bindings in the callable target index (#2693)

Widening the `buildGraphTargetIndex` gate to consider VALUE bindings put the
hot loop on a much larger def population — value bindings outnumber callables
in real source — and the naive version paid full price per binding. Measured
on a synthetic 800-file corpus (8 value bindings per file, 1 of them a closure
binding), the widening cost 2.50-2.82x the pre-#2693 callable-only build.

Two wastes, both provable rather than guessed:

1. `definitionAnchorKey` ran for every def, including value bindings. The
   anchor index is keyed by callable LABEL and the key is built from
   `def.type`, so a value def can never hit it — and the key costs a regex
   per def.

2. Every value binding paid the whole `resolveDefGraphId` key chain only to be
   rejected. It need not: every qualified key that function tries embeds
   `def.type`, so for a VALUE def those can only ever reach a value-labelled
   node. Its one route to a callable is the label-agnostic
   `simpleKey(filePath, simpleName)` fallback, which by construction requires
   a callable node with the SAME file and simple name. So a value binding with
   no such node cannot resolve to a callable, and one Set lookup decides it.

That set is derived in the graph walk the anchor index already performs, so it
costs no extra pass.

  large_ms            7.79-8.37  ->  4.90-5.02   (1.61x faster)
  widening_overhead   2.50-2.82  ->  1.45-1.50

The resolved target-set fingerprint is byte-identical across both, which is
the point: this is a cost change, not a behaviour change.

Adds bench/callable-value-flow/ (fingerprint + scaling + widening-overhead
gates) and wires it into ci-tests.yml beside the other build-free benches. The
overhead budget of 1.9 sits between the measured with-filter and without-filter
bands, so it cannot be met if the pre-filter is removed. Timings use the MIN of
15 warmed reps, not the median: the same build reported 1.65 idle and 2.03
under load, and a median-based gate would have to be loosened past the point of
detecting the regression it exists to catch.

`buildGraphTargetIndex` is exported for the bench; it is pure and not part of
the pass's public contract.

* test(scope-resolution): assert the declaration route does not double-emit (#2693)

Go, Python, C++ and TS/JS already resolved a closure-binding call through
their `@declaration.function` capture. The widened `buildGraphTargetIndex`
gate gives the same call a SECOND possible route, so each must still produce
exactly one edge.

`tryEmitEdge` dedups by key, but a collapsed key and a site-anchored key are
DIFFERENT keys — a real double-emit would show up as two ids for one call
site, not be silently collapsed. Asserting on edge ids rather than target ids
is what makes that visible.

* fix(scope-resolution): join value bindings to their callable node by POSITION (#2693)

Review found the first cut of this series minted FALSE CALLS edges. Admitting a
value binding whose *resolved* graph node is callable let `resolveDefGraphId`
fall through to its label-agnostic, first-write-wins
`simpleKey(filePath, simpleName)` and bind the name to ANY same-named callable
in the file.

The safety argument in the previous commit — "a genuine constant keeps its own
Const/Property node, so the qualified key hits first" — silently assumed
`def.type === node.label`. It does not hold:

  - TypeScript declares `const` as `Variable` but emits a `Const` NODE, so the
    qualified key misses even though the value node exists;
  - Rust `let` bindings get no graph node at all, so the fallback is the only
    route.

Reproduced, all previously emitting a fabricated caller:

  const save = (x: number) => x * 2;   // next to an unrelated Svc.save
      -> Method:svc.ts:Svc.save#1      // Svc never instantiated
  const handler = other;               // shadowing a top-level handler
      -> Function:app.ts:handler       // unreachable from here
  let handler = cb;                    // Rust
      -> Function:main.rs:handler

Worse in Dart, where the same collision INVERTED the feature: the only edge went
to the class method and the closure's own node got none. The result was also
declaration-order dependent — two files differing only in declaration order got
different CALLS sets — and it propagated through argument-to-formal binding into
functions whose source never mentions the name.

A closure binding IS its callable node: same file, same line, same name. An
aliasing local is not. So the join is positional now — a file/line/name index
built in the graph walk `byAnchor` already performs — and value bindings never
run the key chain at all. That is both correct and cheaper:

  large_ms            4.90-5.02  ->  4.37-4.63
  widening_overhead   1.45-1.50  ->  1.43-1.58   (name-match design: 2.50-2.82)

with a byte-identical target-set fingerprint on the bench corpus.

Also from review:

  - `Static` dropped from VALUE_BINDING_DEF_TYPES: `normalizeNodeLabel` has no
    `static` case, so no def can carry that type — it was an entry no fixture
    could ever exercise. The remaining set now documents why it deliberately
    does NOT reuse `isOwnableValueLabel`, which is contracted to a different
    consumer.
  - Dart `final`/`const` top-level closures (static_final_declaration_list) and
    every declarator after the first in a multi-name local now resolve; both
    parse into shapes the earlier rules never reached.
  - The bench source carried a literal NUL byte, so git recorded it as BINARY
    and the only artifact pinning the target set was unreviewable in the PR
    diff. It is written as an escape now. Its corpus also modelled `startLine`
    as 1-based where graph nodes are 0-based, which would have stopped it
    exercising the value-binding path at all.
  - `call-summary-schema-version.test.ts` asserted `passesReuseGate(15)` is
    true; the 15 to 16 bump made that false and the test RED. It now pins 16 as
    current and 15 as rejected, matching the pattern every prior bump followed.
  - The v23 parse-cache comment is at the top of the list, not mid-list.

Tests: the five collision cases above are new regression tests, each confirmed
failing against the previous commit. Also added Kotlin class-body closures (the
only case exercising the Method arm), Dart top-level `final`, Dart multi-name
locals, and a warm-parse-cache replay for Kotlin and Dart — the #2693 captures
are replayed verbatim, so a serialization change would surface only on a SECOND
analyze and every other test here runs cold. The previous negative tests were
vacuous: they paired names that did not collide (`maxSize` vs `size`), so the
pre-filter rejected them before the guard they were named after could run.

* docs(storage): fix the schema-version changelog blocks (#2693)

Two problems, one mine and one not.

MINE: the `INCREMENTAL_SCHEMA_VERSION` block is ASCENDING (v2 … v15), and I
inserted v16 above v15 rather than at the end — I had just moved the parse-cache
entry to the top of ITS block, which is descending, and applied the same habit
to a list ordered the other way. Moved to the end; both blocks are now
internally consistent.

NOT MINE: the parse-cache block carries TWO v21 entries, with v20 wedged between
them. Tracing it: #2632 (Spring DI facts) bumped 20 -> 21 and merged first;
#2653 (Java JLS local-class identities) had branched at 20, also bumped to 21,
and merged second — so it shipped with NO invalidation of its own. An index
already stamped 21 by the first change was treated as current by the second and
kept serving stale local-class identities from the warm cache.

Numbers left alone: both genuinely shipped as 21, and renumbering them now would
misstate what users' indexes actually contain. Instead the entry says so
explicitly, and points at the process fix — re-check the constant against
origin/main immediately before merging, not just when the branch is cut. The
identical collision hit INCREMENTAL_SCHEMA_VERSION in #2653/#2654, so this is a
recurring failure mode of concurrent PRs, not a one-off typo.

Comment-only; no constant changes value.

* feat(scope-resolution): resolve closure bindings in Ruby, Java, C#, PHP and JS/TS var (#2693)

Ruby, Java, C# and PHP already emitted correct callable-flow seeds and invokes.
What they lacked was the #2687 piece — a CALLABLE graph node at the binding,
which is what buildGraphTargetIndex joins to by position. PHP additionally had
no scope declaration for the bound name, so the flow pass had nothing to attach
its seed to.

  ruby    handler = ->(x) { x }        handler.call(1)   -> Function:a.rb:handler
  java    Function<..> handler = x->x  handler.apply(1)  -> Function:A.java:A.handler
  csharp  Func<int,int> handler = ...  handler(1)        -> Function:A.cs:A.handler
  php     $handler = fn($x) => $x      $handler(1)       -> Function:a.php:handler

Ruby and Java invoke through the callable-object protocol; C# and PHP call the
binding directly. Locals work in all four, and a binding whose name collides
with a same-named method resolves to the CLOSURE, not the method.

Two things the sweep caught:

JAVA TWIN. Anchoring the rule on the inner variable_declarator produced BOTH a
Function and a Property node — the exact double-indexing #2687 removed. The
parse-worker dedup keys on (definition node, name), and Java's value rule
anchors on field_declaration, so the keys never matched. Re-anchored on
field_declaration / local_variable_declaration.

JS/TS `var`. `var f = (x) => x` kept a Variable label while const/let got
Function, because `var` is a different grammar node (variable_declaration vs
lexical_declaration) that no closure rule covered. A call through the binding
still resolved via the declaration route, so the CALLS edge pointed at a
NON-callable node. Now consistent across const/let/var.

That last one flipped an existing assertion in const-function-twin.test.ts,
which expected `Variable` for a var-bound function-expression. Its comment
explained why — "var has no matching @definition.function pattern, so nothing
claims the name" — i.e. it documented the gap rather than defending it. The
property it was really protecting (an UNCLAIMED value node survives) now has
its own case with a non-function initializer, and the var-closure case asserts
the collapse to one node, which is also the twin guard for the new rule.

Known limits, both pre-existing and both failing safe:

  - A PHP local closure whose name collides with a top-level function gets no
    edge: both want id Function:<file>:<name>, so the closure never gets its own
    node. This is the file-scoped node-identity convention — TypeScript, Python
    and Dart collapse identically at base.
  - TS/JS class-field arrows stay Property (Kotlin's equivalent emits Method).
    They already resolve; changing the label risks the HAS_PROPERTY ownership
    regression #2687 hit once.

The invalidation constants already bumped in this PR (INCREMENTAL_SCHEMA_VERSION
16, SCHEMA_BUMP 23) cover these additional languages; their notes now say so.

Tests: one case per newly-resolving language plus the PHP anonymous-function
form and the JS var form, in closure-binding-labels.test.ts. The file now spins
a worker pool per test across a dozen languages, so its timeout is raised
file-wide — a case that takes ~7s alone was exceeding the 30s default under
that contention.

* fix(ingestion): class-field closures are callable members in TS/JS (#2693)

A CALLS edge must target a callable node. `class A { handler = (x) => x }` emitted
a Property, so calling it produced `CALLS -> Property:A.ts:A.handler` — an edge
pointing at something the graph says is not callable. Same defect class as the
JS/TS `var` binding fixed in the previous commit, and the last place a closure
binding still carried a value label.

Kotlin already models its class-body closure as Method + HAS_METHOD; TS/JS now
match, so all three agree:

  class-field closure   -> Method   + HAS_METHOD    (CALLS target is callable)
  plain class field     -> Property + HAS_PROPERTY  (unchanged, no CALLS)

Anchored on public_field_definition / field_definition — the same nodes the
property rules use — so the parse-worker dedup collapses the pair rather than
leaving a Method/Property twin, the failure the Java rule hit in the previous
commit.

ON MATCHING THE COMPILERS. This deliberately diverges from tsc and SCIP. The
TypeScript compiler classes `handler = () => {}` as a PropertyDeclaration
("a property declaration independently from what it's assigned to"), and SCIP
gives it a `.` term descriptor, the same suffix as any field — both call it a
property, and Kotlin's compiler likewise treats `val f = { }` as a property with
a function type. The divergence is intentional: GitNexus's Function/Method label
does not mean "tsc SymbolFlags", it means "this node can be the target of a
CALLS edge", which is the convention #2687 set for closure bindings in every
language. Modelling it the compiler's way would mean either dropping call
resolution for these members or emitting a separate node for the lambda and
flowing the property to it — the two-node shape #2687 removed. Recorded here so
the next reader does not "fix" it back.

Tests: TS and JS class-field arrows resolve to their Method node, plus a guard
that a NON-closure class field stays a Property — the closure rule must key on
the initializer, not on the field syntax.

* fix(php): keep the $ sigil on closure-binding nodes so locals stop colliding (#2693)

A PHP local closure whose name matched a file-level function got NO edge at all:

    function save($x) { return $x; }
    function run() {
      $save = fn($x) => $x * 2;
      return $save(1);              // no CALLS edge
    }

Both minted the id Function:<file>:save, so the closure's node was swallowed by
the function's and the positional join found nothing at the binding's line.

The fix is PHP's own semantics rather than a change to node identity across the
graph. PHP holds variables and functions in SEPARATE namespaces — $save and
save() cannot collide in the language — and the sigil is what separates them.
Dropping it was the bug. The node rule now captures the whole variable_name, so
the closure is Function:<file>:$save and the function stays Function:<file>:save.
languages/php/query.ts already keeps the sigil on property declarations for the
same reason, so this makes the two consistent.

The positional join normalises a leading $/@ on both sides, matching what the
scope layer and the callable-flow synthesizer already do, so the binding still
matches its own declaration while its NODE stays distinct.

    local closure + same-named function -> Function:c.php:$save   (the closure)
    calling the real function           -> Function:f.php:save    (unchanged)
    plain $max = 10                     -> no node, no edge       (unchanged)

WHAT THIS DOES NOT FIX. The general problem is wider than PHP: GitNexus node ids
are file-scoped, so a function-local symbol and a file-level one with the same
name collapse in TypeScript, Python and Dart too, and Java/C# only escape by
qualifying on the enclosing CLASS (so two same-named locals in different methods
still collide). SCIP solves it with a separate `local <id>` keyspace that is
document-scoped and never globally addressable. That is issue #2699 — it changes
persisted ids for every function-local symbol and needs its own invalidation, so
it is not bundled here. PHP is fixed on its own merits: the sigil belongs in the
identity regardless of how locals are eventually scoped.

* test(scope-resolution): pin the closure-binding caller-attribution limit (#2693)

Review of this PR found the new callable nodes are call TARGETS but never call
SOURCES: a call made INSIDE a closure binding is attributed to the enclosing
scope, so `impact(handler, direction:"downstream")` reports nothing even though
the closure calls out. Consistent across Kotlin, Dart, Ruby and PHP; TS/JS free
bindings are the exception because their arrow carries a @scope.function whose
range matches.

Not fixed here — pinned, so the boundary is visible instead of surprising, and
so a change in EITHER direction fails a test.

The cause is precise: `pickCallerCallableDef` (graph-bridge/ids.ts) finds the
caller by walking CHILD scopes whose range contains the call site, gated on
`child.kind === 'Function'`. A closure literal is a BLOCK scope in these
languages (Kotlin deliberately, #1757 smart casts), AND the binding's def is
owned by the enclosing scope rather than by the closure's scope — so neither
half of the link exists. Fixing it needs "callable boundary" decoupled from
scope `kind` plus an association between the closure scope and its binding.
That is a change to the caller anchor used by every call in the repo, which is
not something to land at the tail of this PR.

Also adds a unit suite for `buildGraphTargetIndex` itself, covering what the
integration tier cannot isolate: a binding is admitted only on POSITIONAL
evidence, a name-only match is rejected, a non-callable node at that position is
rejected, an ambiguous position claimed by two callables is rejected, and the
PHP dollar sigil normalises across the join while still not matching a
same-named function on another line. That last one closes the review's LOW —
the node/declaration name asymmetry now has an executable contract rather than
resting on a comment.

* docs(test): correct the per-language cause of the attribution limit (#2693)

The comment on the pinned attribution tests claimed "a closure literal is a
BLOCK scope in these languages". That is true for Kotlin (lambda_literal
@scope.block, #1757) and Ruby (do_block/block @scope.block) and FALSE for PHP:
anonymous_function and arrow_function are already @scope.function
(php/query.ts:61-62). Dart is a third case again — it has no scope over a
closure literal at all.

So the four languages fail at three different points, not one:

  Kotlin, Ruby  fail the `child.kind === 'Function'` gate
  PHP           passes that gate; its closure scope owns no callable def,
                because the binding's def belongs to the enclosing scope
  Dart          has no child scope for the walk to consider

Worth correcting carefully rather than tidying: a follow-up plan re-stated this
comment instead of re-deriving it, and inherited the misdiagnosis — it proposed
"relax the kind gate" as required for all four, which is a no-op for PHP and
unreachable for Dart. A review caught it. The comment now states each language's
actual blocker and says why the distinction matters.

Comment-only; the three pinned tests are unchanged and still pass.

* fix(scope-resolution): an ordinary JS/TS `function` binds its own `this` (#2701)

`this.m()` inside a nested `function` resolved to the lexically enclosing
class, so it emitted a CALLS edge that does not exist at runtime — including
the exact `forEach(function () { this.m(); })` shape arrow functions were
introduced to avoid:

    class D {
      m() {}
      build() { const h = function () { this.m(); }; return h; }
    }
    // CALLS: Function:D.ts:D.h -> Method:D.ts:D.m#0      FALSE

ECMA-262 gives an arrow `[[ThisMode]] = lexical`: it has no `this` binding in
its environment record, so the lookup passes through to the enclosing
environment. Every other function form binds `this` at call time. `tsc` draws
the same line by resolving `this` through `getThisContainer` with
`includeArrowFunctions = false`. That one rule is the whole fix.

Languages declare it; shared code never learns a language. The query files —
the one place that already names grammar nodes — tag every non-arrow function
form with `@receiver-owner.this`, which becomes `Scope.ownsReceivers`. A
receiver walk that reaches such a scope without finding the name stops there
instead of borrowing an enclosing scope's binding. Every other language leaves
the field unset and is bit-for-bit unchanged; a Kotlin lambda, which DOES
capture the enclosing `this`, still resolves (pinned as a test).

THREE GATES, ALL LOAD-BEARING. The false edge survived each one alone, which
is why the tests assert on the emitted edge rather than any single walk:

  1. `Scope.ownsReceivers` stops BOTH receiver-type walks — `findReceiver
     TypeBinding` here and its twin `lookupReceiverType` in gitnexus-shared's
     `lookup-core`, which was resolving the receiver independently.
  2. `LanguageTypeConfig.thisBoundaryNodeTypes` stops the type-env AST walk
     that infers a receiver's type during capture.
  3. `isReceiverOwnedButUnbound` makes `receiver-bound-calls` SUPPRESS the
     site. Without it the member still resolved by NAME through `lookupCore`'s
     lexical chain — the class-body scope binds `m` two scopes up — merely at
     lower confidence. An owned-but-unbound receiver is a definitive negative,
     not a miss, so it must not reach a receiver-blind fallback.

Also fixed: `function*(){}` as an expression was not a `@scope.function` at
all, so `this` inside one read as the enclosing method's.

WHAT THIS GIVES UP. The fix REMOVES edges, and some were correct:
`.bind(this)`, `.call(this)` and `forEach(fn, thisArg)` do make `this` the
instance at runtime. Their correctness is fixed at the CALL SITE, which no
scope-level rule can see, so the choice is between losing them and keeping
every detached-callback false positive. All three are pinned as tests
asserting the empty result, so changing the trade later is deliberate.
`this` in a static method also stops resolving to the INSTANCE member — that
edge was wrong in the other direction.

INVALIDATION. Both constants move, and the parse-cache one is not optional:
`ownsReceivers` lives on the cached `Scope`, and a warm cache replays scopes
without it — verified by probe that `--force` alone does NOT re-derive it, so
the fix silently did nothing until SCHEMA_BUMP moved. INCREMENTAL_SCHEMA_
VERSION 16 -> 17 (the incremental write set covers only changed files, so
unchanged TS/JS files would keep their fabricated `this` edges);
SCHEMA_BUMP 23 -> 24.

Verified against a built index, not by reading: all three false edges from the
issue gone, every correct edge kept, same result in JavaScript through its
separate grammar. 64 tests green across the new suite plus the closure-binding
and schema-version suites. The full suite's 36 failures are pre-existing
load-flakes — confirmed by A/B: `skip-git-cli` fails FOUR tests on a clean
HEAD versus three with this change, and `pipeline-pdg-streaming` passes in
isolation either way.

Refs #2701

* fix(ingestion): give function-local callables their own identity (#2699)

Graph node ids were file-scoped, so a local callable and a same-named
file-level one collapsed onto ONE node. That is a wrong answer, not a missing
one — the local call was attributed to the file-level symbol:

    export function save(x) { return x; }
    export function run()   { const save = x => x * 2; return save(1); }
    export function other() { const save = x => x * 3; return save(2); }

    // ONE node Function:a.ts:save, and BOTH run and other pointed at it, so
    // `impact` on the top-level save reported two callers that never call it.

A local's identity is now its enclosing-callable chain plus its own position —
`run.save@2:2`. The chain is for humans reading `impact`; the position is what
makes it correct. Names alone cannot express what ECMAScript actually
specifies, and the gap is the language's, not the grammar's: an environment
record is created per function AND per block, so an anonymous function has no
name to contribute and sibling blocks hold distinct bindings under the same
name. One positional rule settles both, with no conditionals and no
"disambiguate only when it looks ambiguous" heuristic — the ambiguity-flag
class of bug that bit #2514. SCIP reaches the same place with its
document-scoped `local <id>` keyspace.

Top-level functions and class methods are NOT locals and keep their ids
byte-for-byte. That is the bound on the churn: this touches only symbols that
are unreachable from outside their own document anyway.

RESOLUTION JOINS BY POSITION, NOT BY NAME. `resolveDefGraphId` matches a def
to its node on (file, label, line, simple name). A def and its node are the
same construct, so this needs no scope chain at all — which is the point:
re-deriving the chain in the resolver would be a second implementation that
could silently disagree with the first. A genuine tie (two callables on one
line) stores an AMBIGUOUS_POSITION tombstone and falls through to the existing
name keys rather than picking by source order. Without this the node ids were
already correct and calls STILL resolved to the file-level symbol — the fix is
only half a fix without it.

JS/TS GAIN BLOCK SCOPES. They emitted no `@scope.block` at all, so the
resolver could not tell two `const pick` in sibling branches apart. Giving
them distinct ids made that visible as DUPLICATE edges — each call resolving
to BOTH — which is worse than the collapse it replaced. `(statement_block)
@scope.block` supplies the missing environment record. The other half of the
ECMAScript rule was already implemented and waiting: `tsBindingScopeFor`
hoists `var` past blocks to the enclosing Function/Module while `let`/`const`
bind innermost, and its docblock already claimed "the innermost default covers
these" for block scopes that did not exist. All 82 scope-resolution test files
pass with blocks on.

Verified by probe, per case: two locals in different functions, a local inside
an ANONYMOUS function (`outer.fn@1:9.save@2:4`), sibling blocks resolving to
their own binding, `var` still hoisting out of its block, a nested named
`function` vs a file-level one, PHP composing with the `$` sigil from #2693,
and Python. Top-level/method ids unchanged, asserted directly.

Every assertion is on the EDGE, not on node existence. Ids are built twice and
independently — definition phase and caller attribution — and a one-character
disagreement makes the caller attach to a node that does not exist and the
edge vanish, with nothing thrown and no test failing. An edge assertion can
only pass if both phases agree.

INVALIDATION. INCREMENTAL_SCHEMA_VERSION 17 -> 18 and SCHEMA_BUMP 24 -> 25:
persisted node ids change for every function-local callable, and the cached
scope tree lacks block scopes. A top-up would leave unchanged files on the old
ids while changed files emit the new ones, splitting each symbol in two.

Bench fingerprint unchanged and both timing budgets pass. The one full-suite
failure (incremental-orchestration) passes in isolation — its log shows stale
init locks and WAL reclaim, i.e. LadybugDB contention under the parallel run.

Refs #2699

* perf(ingestion): emit block scopes only where they bind something (#2699)

Block scopes make `let`/`const` in sibling blocks distinct bindings, which is
what stopped a call in one branch resolving to both. Emitted naively — one
scope per `statement_block` — they also cost ~10% of analyze wall time, because
every scope-chain walk in every function then steps through levels that bind
nothing.

Two emit-side filters keep the semantics and drop the waste:

  1. A block that IS a function body duplicates the enclosing Function scope.
     Nothing can be declared between a function and its own body, so a binding
     in either resolves identically — the inner scope is pure depth.
  2. A block that declares no `let`/`const`/`class`/`function` binds nothing,
     so it is transparent: a lookup finds nothing in it and walks to the
     parent. `var` is deliberately excluded from that list — it hoists past the
     block to the function, so a block containing only `var` still binds
     nothing.

MEASURED, on a 762-file / 228k-line TypeScript corpus (gitnexus/src), min of 6
warmed reps with the cold first rep discarded:

    block scopes emitted   19,389  ->  5,331     (-72%)
    total scopes           35,942  ->  21,884    (-39%)
    analyze wall time      +9.8%   ->  +1.6-2.5% vs pre-#2699
    peak RSS (whole tree)  2398MB  ->  2434MB    (+1.5%, inside run-to-run noise)

The filters themselves are free: scope emission over the same corpus measured
12.6s naive vs 12.5s filtered.

Wall-clock on a shared runner has a ±10% spread run to run, which is wider than
the effect being optimised, so the durable gate added here counts scopes
instead. `bench/scope-emission/measure.mjs --check` asserts an EXACT scope set
over a synthetic corpus that mixes the shapes the filters discriminate between
— function/method/arrow bodies, non-declaring if/else/for/while/try, blocks
that declare `const`, and a `var`-only block. Baseline is 2 block scopes per
module: only the two `if`/`else` branches that declare `const chosen`. If the
filters regress that number jumps immediately, in a way wall-clock CI could
never resolve from noise. Wired into the existing benchmarks job.

Behaviour is unchanged: 86 scope-resolution and identity test files, 1371
tests, all green — including the sibling-block case this could plausibly have
broken — and the callable-value-flow fingerprint is untouched.

Refs #2699

* test(bench): re-baseline the TS/JS scope-capture fingerprints for #2701

`bench/scope-capture` fingerprints the full capture set per language, and
#2701 added a `@receiver-owner.this` marker to every non-arrow function form
so a scope that BINDS its own `this` can terminate the receiver walk. That is
a capture-set change, so the TypeScript and JavaScript fingerprints moved and
the benchmarks job has been failing since that commit — I pushed it without
checking CI.

A fingerprint is a correctness gate, so this does not simply adopt the new
value. Verified first by diffing the capture-name HISTOGRAM over the same
fixture corpus against 1d308817 (the commit before #2701), which says what a
fingerprint cannot: WHICH names moved.

    typescript   @receiver-owner.this   0 -> 143
    javascript   @receiver-owner.this   0 -> 32

Nothing else. Every other capture count is byte-identical, so no existing
capture shifted and the drift is entirely the intended marker. Both languages'
scaling ratios stay well inside their 1.5 budgets (0.976 / 1.025).

Note `@scope.block` does not appear in the delta: the #2699 filters suppress a
block that is a function body or that declares no binding, and no fixture in
this corpus has a block that binds. Block-scope emission is guarded separately
by `bench/scope-emission`, whose synthetic corpus exercises exactly those
shapes.

Refs #2701

* fix(ingestion): stop the callable-prefix walk at class bodies, not only declarations (#2699)

An anonymous class owns its members, but `CLASS_CONTAINER_TYPES` lists only class
DECLARATION nodes — and a Java anonymous class has none. It is

    object_creation_expression > class_body > method_declaration

so `enclosingCallablePrefix` sailed straight through the anonymous body, reached the
enclosing method, and re-keyed the member as a function-local of that method:

    Method:src/Worker.java:Worker$1.run#0
    -> Method:src/Worker.java:Worker.makeHandler.run@7:12#0

That destroys the javac-compatible JLS identity #2550/#2555/#2562 exist to provide, and
broke four existing Java tests that this PR never touched — anonymous-class instance
identity, local-type identity, and enum-constant-body chaining.

The design was right; the boundary was blind. `CALLABLE_PREFIX_BOUNDARY_TYPES` adds the
body and anonymous-construction forms (`class_body`, `interface_body`,
`annotation_type_body`, `enum_body`, `enum_body_declarations`, `enum_constant`,
`object_creation_expression`, `object_literal`,
`anonymous_object_creation_expression`). Over-inclusion is the SAFE direction here: an
extra boundary only suppresses the nesting prefix, falling back to the pre-#2699 class
qualification.

This also falsifies the claim in the #2699 commit that "top-level functions and class
methods keep their ids byte-for-byte" — an anonymous-class method IS a class method, and
its id did change. The claim was true only for the shapes that were tested.

Also removes the dead `NO_QUALIFIED_NAME` constant, which contained a literal NUL byte.
That byte made `file(1)` report the source as `data` and made plain `grep` return zero
matches for ANY pattern in the whole 2,928-line file — which is why several greps during
development came back mysteriously empty. Two other files carry NULs; they are
pre-existing and out of scope here.

INVALIDATION. INCREMENTAL_SCHEMA_VERSION 18 -> 19 and SCHEMA_BUMP 25 -> 26. This is not
defensive: an index stamped v18 holds the WRONG Java ids, and without the bump it passes
the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate and keeps them on every unchanged file.

Found by the PR #2695 tri-review (review 4782134453) — independently by a Claude
adversarial AST probe, by Codex's swarm, and by CI (`tests / ubuntu / coverage 2/3`).
Verified: `resolvers/java.test.ts` 247/247 (was 243/247), plus this-boundary,
function-local-identity and the schema-version suites.

Refs #2699

* fix(scope-resolution): fail closed when a function-local shadows a same-named callable (#2699)

The #2699 positional join failed OPEN. On a position miss `resolveDefGraphId` fell
through to the label-agnostic, first-write-wins `simpleKey(filePath, simpleName)`, which
aliases a def onto whichever same-named callable was registered first — the exact
fabricated-caller mechanism this PR's own #2693 work already shipped once as a P0.

It misses because the two id phases anchor on different nodes BY DESIGN:
`tree-sitter-queries.ts` anchors the graph node on the outer `lexical_declaration`, while
`languages/typescript/query.ts` anchors the scope def on the inner `arrow_function` so
`anchor.range` lines up with `@scope.function` for auto-hoist. Split the declaration
across lines and those land on different LINES:

    export function run()   { const pick =
        (x) => x * 2; return pick(1); }
    export function other() { const pick =
        (x) => x * 3; return pick(2); }

    before:  run   -> run.pick@1:2      correct
             other -> other.pick@6:2    correct
             other -> run.pick@1:2      FABRICATED — other() never calls run's pick

Every fixture in function-local-identity.test.ts kept the declaration and its initializer
on ONE line, where the anchors coincide. That is why the suite stayed green while the bug
shipped, and the new test deliberately splits them.

WHY NOT A BLANKET FAIL-CLOSED. A position miss is not always a collision: it also happens
where the anchors legitimately differ, e.g. a Vue SFC, whose graph nodes carry
`+ lineOffset` while scope extraction does not. Failing closed on every miss would delete
correct edges there. So the guard is keyed on evidence that the collision is REAL —
`localNameKey` records that a function-local of this simple name exists in the file
(local-identity nodes are recognisable by the `@<row>:<col>` on their last name segment).
Only then is a miss treated as ambiguity. Files with no such local keep their previous
fallback behaviour byte-for-byte.

A missing edge is the correct failure direction here: `impact` can recover from an absent
caller, but a fabricated one silently corrupts the answer.

WHY NOT UNIFY THE ANCHORS. Considered and rejected: the split is deliberate and
load-bearing for auto-hoist across every language (the `rangesEqual(anchor.range,
innermost.range)` rule), so unifying it would fight that discipline far outside this fix.

The regression test was verified to DISCRIMINATE: with the guard disabled it fails on
exactly the fabricated edge (`+ "Function:m.ts:other -> Function:m.ts:run.pick@1:2"`).

impact(resolveDefGraphId, upstream) is CRITICAL — 62 impacted, 23 direct, 6 flows — which
is precisely why the guard is gated rather than broad. detect_changes: HIGH, 8 affected
processes, all in EmitReceiverBoundCalls / EmitRubyMixinEdges. Verified: 85 test files /
1364 tests green, including every scope-resolution unit.

Found by the PR #2695 tri-review (review 4782134453): raised by Codex's adversarial leg,
mechanism source-confirmed during synthesis, then reproduced end-to-end.

Refs #2699

* fix(typescript): stop the enclosing-type walk at nodes that rebind `this` (#2701)

`findEnclosingType` walked `node.parent` to the top of the file with no boundary, so it
happily synthesized a `this` binding from a type that does not own the member:

    class A { outer() { const o = { inner() { return this.x; } }; return o; } }

`this` inside `o.inner` is `o`, never `A` — but the walk reached `A` and bound to it, so
every `this.…` in such a method resolved against the wrong type. Only the module-level
object literal escaped, because there was no enclosing class to reach. Applies to
JavaScript too: `languages/javascript/captures.ts` calls the same function.

Boundary set: object literals and the function forms that rebind `this` at call time.
Arrows are deliberately absent — they inherit `this` lexically, which is what makes a
class-field arrow `m = () => this.x` resolve.

WHY THE MARKER WAS NOT ALSO REMOVED FROM METHOD FORMS.

The review argued `@receiver-owner.this` over-suppresses: `synthesizeTsReceiverBinding`
returns null for static members, object-literal methods and anonymous class expressions,
so those scopes are "owned but unbound" and get suppressed, losing edges the base
resolved. Removing the marker from the method forms was tried and MEASURED, and the
result does not support shipping it:

    marker removed, probe of all five shapes:
      static -> static            RESTORED (true)
      object literal (module)     RESTORED (true)
      anonymous class expression  RESTORED (true)
      static -> INSTANCE          FALSE EDGE returned
      object literal in a class   FALSE EDGE (Nested.outer.inner -> Nested.x)

The last one is the point: this fix stops the false *synthesis*, but removing the marker
re-enables receiver-blind *name* resolution in `lookupCore`'s lexical chain, which
recreates the same wrong edge by another route. The restored edges and the false ones
come from the SAME mechanism — a name walk — so they cannot be separated by toggling the
marker. The real trade is 2 genuinely-new true edges for 2 false ones, not the 3-for-1
the plan assumed.

Corpus evidence (762 real TypeScript files, edge SETS not counts, cold cache both arms):

    baseline vs marker-removed:  net 0, REMOVED 0, ADDED 0

Neither the gains nor the losses occur in production code. Given a 1:1 true/false ratio
on synthetic shapes and zero effect on real ones, the marker stays: for a graph feeding
`impact`, a fabricated caller is worse than an absent one — the same principle applied in
the fail-closed positional join. The three shapes remain UNRESOLVED rather than wrongly
resolved; resolving them properly needs a typed binding for object literals, anonymous
classes and static contexts, which is a feature, not this fix.

Measured with an edge-SET diff harness, after both ce-doc-review passes established that
an edge COUNT cannot decide this (it conflates edges gained with edges lost, so a
near-zero net reads as "no regression"). The harness also had to wipe the index each arm
— a warm parse cache initially reported an unchanged edge set across a real behavioural
change, the same trap documented in the v24 SCHEMA_BUMP note.

detect_changes: low risk, 3 symbols, no affected processes. 85 files / 1365 tests green.

Refs #2701

* docs(test): correct the false "three load-bearing gates" claim (#2701)

The header of `this-boundary.test.ts` asserted that all three gates were
independently load-bearing because "the false edge survived removing any one of
them alone". That was true DURING development, measured incrementally, and was
carried into the shipped comment without being re-tested against the finished
code. It is false: gate 3 (`isReceiverOwnedButUnbound` in `receiver-bound-calls`)
runs FIRST and marks the site in `handledSites`, which `emitReferencesViaLookup`
then skips — so for an explicit `this` receiver it subsumes gate 1. Removing
gate 1's `ownsReceivers` check in `gitnexus-shared/.../lookup-core.ts` leaves all
10 tests in the file passing; verified by experiment.

The gate is RETAINED, and the review's recommendation to delete it as "dead" is
rejected on evidence. `receiver-bound-calls` only suppresses EXPLICIT receivers
(`if (site.explicitReceiver === undefined) continue;`), whereas `lookup-core`'s
gate is also reached for IMPLICIT ones through `IMPLICIT_RECEIVERS` in
`resolveReceiverOwner` — a bare `m()` inside a nested `function` inside a method
goes down that path. The experiment shows the gate is UNTESTED, not unreachable;
those are different claims and only the first is supported. Deleting it on the
strength of a green test run would have removed live code, which is the same
reasoning error the corrected comment is about.

This is a documentation-only change: no behaviour, no test expectations. The
correction is recorded in place rather than silently rewritten, because the way
the claim came to be wrong — measured on an intermediate tree, then asserted
about the final one — is the reusable lesson.

Refs #2701

* test(scope-resolution): pin the block-scope ACCESSES delta as false-edge removal (#2699)

The tri-review flagged that enabling `(statement_block) @scope.block` for
JS/TS drops 114 `ACCESSES -> Const` edges corpus-wide with `added: 0`,
undocumented and untested. That was recorded as a suspected regression.

It is not one. All 274 emitting reference sites behind those 114 edges were
classified by re-reading the source at the site: 269 are member reads, the 5
others are classifier artifacts (the name recurs earlier on the line, as in
`a.b.declLine` for `b`) and are member reads too. No edge was
bare-identifier-only. Every dropped edge was a property read
(`options.baseUrl`) mis-resolving to an unrelated function-local `const` of
the same name in the same file.

The cause is not block-specific: `lookupCore` Step 1 walks the lexical chain
for every lookup, including explicit-receiver property reads. Block scopes do
not fix that, they narrow it, by moving the local off the chain of any
reference outside its block. A local declared directly in the function body
still hijacks the read; that is pre-existing and left alone here.

Two tests. The first discriminates: it fails with the block capture removed
(the false edge reappears) and passes with it. The second is a companion
invariant, identical in both arms, so that "the edge went away" cannot be
satisfied by a change that dropped Block-kind bindings outright.

Fixture notes, both of which defeated earlier attempts at this edge class:
`pruneLocalSymbols` deletes ~94% of function-local value symbols, so the
`const` under test must be kept via `keepLocalValueSymbols`; and the member
read must sit outside the block, since inside it the block is on the
reference's own chain and the false edge appears in both arms.

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

* docs(ingestion): correct the SCIP citation on the function-local id (#2699)

The comment justified the positional, name-bearing local id (`fn@12:9`) as
"same reasoning as SCIP's document-scoped `local <id>` keyspace". SCIP is the
wrong citation for this key shape: its `local <id>` is a per-document counter,
and the spec states that locals do not encode the name.

SCIP remains prior art for the document-scoped keyspace itself, which is the
part the argument actually leans on, so the reference is corrected rather than
dropped. clang's USR for a function-local (`name@offset`) and Kythe's C++
indexer are the accurate citations for a positional, name-bearing key.

Comment only, no behavior change.

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

* fix(typescript,javascript): sync the function node-type lists, and test it (#2701)

Four hand-maintained lists answer "which node types are function-like":

  1. `query.ts` — the `@scope.function` / `@receiver-owner.this` patterns
  2. `captures.ts` — `FUNCTION_NODE_TYPES` (callable-flow synthesis + the
     body-block filter)
  3. `receiver-binding.ts` — `THIS_REBINDING_BOUNDARY_TYPES`
  4. `type-extractors/typescript.ts` — `THIS_BOUNDARY_NODE_TYPES`, whose
     docstring already claimed it was "kept in sync with `@receiver-owner.this`"
     with nothing enforcing it

`generator_function` (the EXPRESSION form, `const g = function* () {}`) was
added to both queries for #2701 and is present in lists 3 and 4, but was
missing from both `FUNCTION_NODE_TYPES`. Added.

That gap changes no graph output today, and the commit does not claim
otherwise. Measured on `const g = function* (x) { yield x; }; g(1)`: node and
edge sets are byte-identical with and without the entry. The `this` boundary
was already correct via the query marker — `this-boundary.test.ts` has a
passing generator case. A generator-expression binding still emits a `Const`
node rather than a `Function` one, so its call resolves to nothing either way;
that label comes from the definition rules, and closing it is a separate change
NOT made here.

So the entry is list consistency and the test is the real deliverable. It
asserts lists 1 and 2 EQUAL, and lists 3 and 4 as subsets of the query markers
with an explicit allowlist — the method forms bind their own `this` but the
class is their `this`-owner, so neither walk may stop there. Verified
discriminating: removing the `generator_function` entry fails both equality
assertions.

The lists are exported for the test; no other production surface changes.

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

* test(bench): gate scope emission per language, not TypeScript-only (#2699)

The scope-emission gate ran the TypeScript emitter only, so a JavaScript-only
regression shipped green. The two filters it guards are implemented twice —
`FUNCTION_BODY_OWNER_TYPES` in `typescript/captures.ts` and
`JS_FUNCTION_BODY_OWNER_TYPES` in `javascript/captures.ts`, each with its own
`blockDeclaresBinding` and `BLOCK_BINDING_CHILD_TYPES` — so covering one said
nothing about the other.

Adds a structurally parallel JavaScript corpus (the same shapes with the
TS-only syntax removed) and splits `baselines.json` per language. `--check`
now also fails when a baselined language is not measured, which is how a gate
goes quietly green.

Verified the new arm bites: disabling the JS body-block filter alone takes
JavaScript from 400 to 600 block scopes and fails `--check`, while TypeScript
stays green — the exact regression the old gate would have passed.

The two languages happen to agree exactly on this corpus (2 blocks per module,
2200 scopes). That is recorded as a measured result, not an invariant: each
language is still gated against its own baseline. TypeScript's numbers are
unchanged.

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

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:52:18 +01:00
Copilot
d3d4fa31bb
fix(scope-resolution): gate C#/Kotlin free calls by instance ownership (#2563) (#2654)
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
* Initial plan

* fix(scope-resolution): gate C# and Kotlin free calls

* fix(scope-resolution): keep Kotlin ownership gate safe

* Apply remaining changes

* perf(scope-resolution): benchmark and cache ownership gates

* test(scope-resolution): simplify benchmark scaling loop

* refactor(scope-resolution): encapsulate ownership cache

* test(scope-resolution): enforce subquadratic ownership scaling

* fix(scope-resolution): address ownership review findings

* test(csharp): regenerate capture golden for #2563 fixtures

The committed expected-captures.json was missing the new
NamespaceOwnerCollision.cs entry and carried a stale SameFileCases.cs
digest/count (56 → 67), so csharp-captures-golden.test.ts was the sole
red check on the PR. Regenerate with UPDATE_GOLDEN=1 to match the
fixtures the bench fingerprint already reflects.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:31:56 +01:00
Copilot
450cebc268
fix(java): JLS binary-name identities for local classes, enums, records & interfaces (#2562) (#2653)
* Initial plan

* docs(plans): add Java local class naming plan

* fix(java): model local class binary names

* docs(java): clarify local class naming guards

* fix(java): recognize local classes in compact constructors

* chore: remove Java naming plan

* fix(java): harden local type identities and scope

* perf(java): linearize local type ordinal allocation

* fix(java): harden ordinal benchmark follow-up

* docs(java): clarify ordinal benchmark invariants

* test(java): cover local type ownership paths

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-24 11:58:53 +01:00
Gergő Magyar
170805647c
fix(rust): keep duplicate type names ambiguous in range binding (#2514) (#2652)
* fix(rust): latch duplicate type-name ambiguity in range binding (#2514)

The range-binding prepass tracked cross-file return and field types in two
maps and used map presence itself as the ambiguity flag: the second definition
of a name deleted it, but a third definition found it absent and re-inserted
the last-scanned file's type. Odd duplicate counts (3, 5, ...) therefore
resolved a genuinely ambiguous name to whichever file was scanned last, while
even counts stayed ambiguous.

Latch ambiguity in a dedicated Set per registry (ambiguousReturnTypes,
ambiguousFieldTypes): once a name has two or more workspace definitions it
never resolves again, regardless of duplicate count or file order.

Adds integration coverage for two/three-duplicate functions and structs,
permuted file order, and a unique-name over-suppression guard.

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

* fix(rust): bump INCREMENTAL_SCHEMA_VERSION to 12 for the #2514 range-binding fix

The duplicate-name ambiguity latch changes which cross-file Rust CALLS edges
the range-binding prepass emits. The incremental writeback persists only
changed-file nodes, so an incremental top-up against a pre-v12 index would keep
the old spurious edges on every unchanged Rust file. Bump the schema version to
force a one-time full re-analyze, matching the v7/v11 contract for
edge-affecting resolver changes.

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

* feat(rust): resolve import-disambiguated duplicate types in for-loops & destructuring

Follow-up to the #2514 ambiguity latch. When several modules define the same
function/struct name and a call site disambiguates it with a `use` import
(including aliases and `use x::*` globs), range-binding now resolves the
for-loop element type and the destructured field type to that specific imported
definition, instead of leaving it unresolved.

The bare-name return/field maps are (correctly) ambiguous for duplicates, but
the call site's import pins a definition. range-binding records the full,
untruncated return/field type per defining file, and resolveImportedDef()
resolves a name to the single in-scope definition, mirroring Rust name
resolution:

  - tier 1: explicit `use`/re-export imports and local defs (lookupBindingsAt);
    these shadow globs, so if any exist we decide within them alone;
  - tier 2: glob imports, consulted only when tier 1 is empty; a
    `wildcard-expanded` ImportEdge names the target module, so we resolve only
    when exactly one glob-target file actually defines the name.

Two or more visible definitions stay unresolved, preserving the #2514 latch.
normalizeRustReturnType is untouched (its Vec<T> -> Vec truncation is
load-bearing for receiver resolution), so the full generic is read from the
per-file map instead.

Covered by integration tests: explicit / aliased / single-glob imports resolve
to the imported definition; two globs that both export the name stay ambiguous;
a local definition shadows a glob; no-import duplicates stay unresolved (#2514).

INCREMENTAL_SCHEMA_VERSION stays at 12 (bumped by the #2514 commit in this PR);
its note now also covers these added resolution edges.

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

* perf(rust): parse each file once in range-binding when the workspace fits a budget

populateRustRangeBindings makes two passes over every file and, because the
shared treeCache is empty in the analyze flow, re-parsed each file in both — a
workspace of N files paid 2N parses. It now parses each file once and reuses the
tree across both passes via an in-function store, gated by a source-byte budget:
workspaces up to 16 MiB of Rust source (essentially every real repo) reuse
trees; larger ones fall back to per-pass re-parsing so peak RSS stays bounded on
huge repos (the memory-sensitive case keeps its current profile).

Also collapses the parse+timeout boilerplate that was copy-pasted in both loops
into one getOrParseTree helper, and adds a PROF-gated `rangeBind=` segment to
the scope-resolution profiler for phase-level observability.

Measured on a 500-file synthetic Rust workspace (PROF_SCOPE_RESOLUTION=1): the
range-binding phase drops ~370ms -> ~320ms (~14%), parses 1000 -> 500. Behavior
is unchanged (199 rust + range-binding-order + parse-timeout tests green); repos
above the budget are unaffected.

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

* test(rust): update schema-version gate to v12; regenerate golden + bench baseline for new fixtures

CI surfaced three deterministic-artifact failures, all from this PR's own additions:

- call-summary-schema-version.test.ts hardcoded INCREMENTAL_SCHEMA_VERSION === 11
  (the #2604 window); #2514 bumped it to 12. Update the gate and extend the
  reuse-gate version history so a v11 stamp now forces a full re-analyze.
- rust-captures-golden expected-captures.json drifted (130 -> 174 entries) because
  the new rust-import-* / rust-dup-* fixtures joined the rust-* corpus. Regenerated
  (UPDATE_GOLDEN=1): additions only, no existing captures changed — emitRustScopeCaptures
  is untouched.
- bench/scope-capture/baselines.json rust fingerprint drifted for the same reason.
  Rebaselined with a provenance note; scaling 1.06 < 1.5 budget.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:43:24 +01:00
Abhigyan Patwari
0eeecb37f3
fix(python): resolve calls through constructor-injected fields (#2628)
* fix(python): resolve calls through injected fields

* fix(ci): update python capture benchmark fingerprint

* fix(python): make constructor field inference conservative

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-07-22 16:32:53 +01:00
Gergo Magyar
00141d0da2 test(bench): rebaseline rust scope-capture fingerprint for #2604
RUST_SCOPE_QUERY gained a function_signature_item capture, shifting the
capture fingerprint for every bench fixture with a required trait method.
Verified: node --import tsx bench/scope-capture/measure.mjs --check now
passes across all 14 languages (rust scaling 1.036 < 1.5 budget).
2026-07-21 15:32:11 +00:00
Claude
70e0a7766c fix(java): address #2561 review — inherited-dispatch test + bodied fail-safe
Two gitnexus-review-agent findings on PR #2602:

- MEDIUM: the bodied-constant MRO-to-host-enum path (a qualified call to an
  inherited, non-overridden enum method) was claimed in a comment but never
  tested. Add EnumConst.A.log() -> EnumConst.log#0, exercising E$N's
  @reference.inherits MRO arm end to end.

- LOW: `bodiedName ?? hostEnum` conflated "body-less" with "name synthesis
  failed on a bodied constant" (reachable only on malformed/error-recovery
  trees), silently binding an overriding constant's receiver to the host
  enum — a wrong edge instead of no edge. Switch to `isBodied ? bodiedName :
  hostEnum` so a bodied constant binds ONLY to its E$N class, mirroring the
  object_creation_expression branch's skip-on-synthesis-failure. Verified
  output-neutral on the well-formed bench corpus.

Rebaseline the java scope-capture fingerprint (a822cef9 -> d04298a9): the
bench corpus IS test/fixtures/lang-resolution, so the new dispatchInherited
fixture method shifts it (+6 capture groups); the logic change contributes
nothing (confirmed by isolating the fixture-only fingerprint). java.test.ts
242 passed; measure.mjs --check PASS (14 languages); tsc/prettier/eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:52:27 +00:00
Claude
d9437e6d74 test(bench): rebaseline java scope-capture fingerprint for #2561
The enum-constant receiver-dispatch fix adds one @type-binding.* capture
per enum constant, so the java scope-capture fingerprint shifts
(85fc7af9 -> a822cef9). Pure capture-additive drift; no bench fixtures
added; scaling 1.024 < 1.5 budget. Verified `measure.mjs --check` passes
for all 14 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:19:51 +00:00
Gergo Magyar
5099e8ff1e test(bench): rebaseline java scope-capture fingerprint for record support (#2564)
CI caught this: adding the record_declaration capture legitimately
changes the pinned java capture fingerprint, same as every prior
capture-behavior change to this language (#2550, #2555). Rebaselined
following the established _rebaselined_* precedent; scaling ratio
1.059 stays well within the 1.5 budget.
2026-07-21 07:18:38 +00:00
FAll
2cfbc4a259
feat(spring): build bean candidate inventory (#2494)
* feat(java): inventory Spring bean candidates

* fix(java): fail closed on Spring annotation shadowing

* fix(java): resolve Spring beans after imports

* fix(java): remove stale bean extraction path

* style: satisfy locked Prettier version

* fix(spring): address PR review findings

* feat(spring): share bean inventory across Java and Kotlin

* fix(spring): gate bean inventory analysis completeness

* fix(kotlin): avoid reloading cached scope source

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

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-20 09:28:23 +01:00
Gergő Magyar
12600000e3
feat(java): model enum constant bodies as first-class instances; JLS 13.1 anonymous naming (#2558)
Some checks are pending
Scorecard / Scorecard analysis (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 / Build & Push RC Docker images (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(java): JLS 13.1 immediate-host naming for anonymous bodies + v9 schema window (#2555, step 1)

`synthesizeJavaAnonymousClassName` generalizes to both anonymous-body
shapes (`object_creation_expression` with a `class_body`; `enum_constant`
with a `body:` field) and switches from topmost-host naming to JLS 13.1
binary names: the `$`-joined chain of enclosing host types
(`EnumWrap$Mode$1`), numbered per IMMEDIATE host in source order across
both shapes (javac's shared counter). Every existing fixture's immediate
host is its top-level type, so existing names are unchanged — proven by
the 11 #2550 tests passing untouched, not assumed. The owner walk's
anonymous branch also fires on `enum_constant` now (the synthesis returns
undefined for body-less constants, so the walk continues to
`enum_declaration` as before).

Identity window: INCREMENTAL_SCHEMA_VERSION 8→9, parse-cache SCHEMA_BUMP
18→19, U-C5 pin extended with the v8-stamp rejection (enum-constant
methods re-key `E.hook`→`E$1.hook`; nested-host anons re-key
`EnumWrap$1`→`EnumWrap$Mode$1`).

Enum-constant Class-node emission and scope-side ownership land in the
next commits per
docs/plans/2026-07-18-gitnexus-plan-enum-constant-bodies.md (plan is
local — docs/ gitignored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(java): model enum constant bodies as first-class instances (#2555, steps 2-4)

`enum E { A { void hook(){} } }` — javac's other anonymous-class shape —
joins the #2550 instance model:

- Structure: `(enum_constant body: (class_body)) @definition.class` in
  JAVA_QUERIES; `enum_constant` in javaClassConfig.typeDeclarationNodes
  with extractName synthesis. The shouldSkipClassCapture guard now also
  covers enum_constant — without it, extract()'s name fallback would
  fabricate a Class node from the constant's own identifier (`A`).
- Scope: `(enum_constant body: (class_body) @scope.class)` + synthesized
  `@declaration.class`/`@declaration.name` anchored on the body, so the
  constant's methods are owned (`ownerId`) and re-keyed
  (`Method:...:EnumConst$1.hook#0`).
- Inheritance: a body-anchored `@reference.inherits` naming the HOST
  ENUM (javac semantics: E$N extends E) — `mroFor(E$N) ∋ E`, so bare
  calls from the body to enum helpers pass the ownership gate's MRO arm
  while the same-file bare-call leak for constant-body method names is
  closed (discrimination evidence: the #2549 review's archived S1b probe
  showed the identical shape resolving `local-call` pre-fix).
- Nested-host JLS naming verified end-to-end: `EnumWrap$Mode$1` (not
  `EnumWrap$1`).
- Bench: java scope-capture fingerprint rebaselined (new captures + two
  fixtures), `measure.mjs --check` PASS across all 14 languages.

Verified: full java.test.ts 230/230 twice sequentially; TS 254 + JS/
Kotlin 289 (shared-file spot set); schema/scope/owner unit suites 90.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(java): exempt $-chain anonymous class defs from nested-class qualification (#2555 review)

Review lens probe caught a HIGH collapse: same-named methods across
sibling enum constant bodies attributed to the FIRST body's Method node
(`M3$1.hook -> M3.log` where the log() call lives in C's body; the
same-target sibling edge vanished entirely under dedup).

Root cause: `populateClassOwnedMembers`'s qualifier chains a
constant-body class def to `M3.M3$2` — its Class scope's parent is the
enum's Class scope, unlike OCE anons whose parent is a Function scope —
and its methods to `M3.M3$2.hook`. The structure-phase node id encodes
`M3$2.hook`, so the graph-bridge's qualified key misses and falls to
the file-wide simple-name lookup: first-write-wins.

Fix: `qualify()` now skips CLASS-LIKE defs whose name already carries a
`$` chain — a synthesized anonymous binary name is complete by
construction (JLS 13.1). Narrowly scoped: `$`-named MEMBERS (legal and
real in JS/TS) still qualify against their class, and named nested
classes (`Outer.Inner`, #1978) are untouched.

Discriminating regression test: same-name/distinct-target sibling
bodies must each own their edge, and the misattributed cross-edge must
not exist.

Verified: full java.test.ts 231/231; Python+Kotlin 459 (heaviest
populateClassOwnedMembers consumers) — zero assertion failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ci): prettier formatting + java bench rebaseline at the final corpus (#2555)

Two CI reds from the review-fix commit landing AFTER the bench
rebaseline: (1) prettier reformat of the new java.test.ts describe;
(2) the java scope-capture fingerprint drifted again because the
review fix added the java-enum-constant-same-name fixture to the
corpus — rebaselined at the true final corpus (196 fixtures,
ce104a76…, scaling 1.05 < 1.5), local `measure.mjs --check` PASS
across all 14 languages. Lesson honored going forward: the bench
rebaseline is the LAST artifact step — any post-review fixture
addition reopens it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(java): strict JLS 13.1 chaining through anonymous enclosing types (#2555)

Per review discussion: anonymous enclosing types now chain into the
binary name instead of flattening to the nearest NAMED host — the
immediately enclosing type per JLS 13.1 may itself be anonymous:

- anon inside an anon:            NestHost$1$1   (was NestHost$2)
- anon inside an enum constant:   N$1$1          (was N$2)
- named nested hosts (unchanged): EnumWrap$Mode$1

`nearestJavaAnonHost` becomes `nearestJavaEnclosingType` (named hosts OR
anonymous bodies); an anonymous enclosing type's prefix is its own
synthesized name (memo-bounded recursion); numbering is per immediately
enclosing type in source order. Top-level-hosted names are untouched —
the full existing suite passes unchanged.

New coverage: anon-in-anon chain, anon-in-constant-body chain (with
ownership), and a bodied constant in a NESTED enum (EnumWrap2$Mode$1 —
the one host combination previously untested). Rides the unreleased v9
identity window (doc wording tightened); java bench fingerprint
rebaselined at the final corpus, `--check` PASS across 14 languages;
prettier clean.

Verified: full java.test.ts 234/234 (one worker-crash flake rerun green
in isolation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 23:11:45 +01:00
azizur100389
196095b7d1
fix(dart): extract extension type symbols (#2539)
* fix(dart): extract extension type symbols

* test(dart): update extension type benchmark baseline

* fix(dart): emit extension type implements heritage

* fix(dart): handle generic extension type implements

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-18 22:57:26 +01:00
Gergő Magyar
1abcac9c16
fix(scope-resolution): stop platform builtins resolving to unrelated same-file symbols (#2549)
* fix(scope-resolution): stop platform builtins resolving to unrelated same-file symbols (#2545)

An unqualified call to a platform/language builtin (e.g. TypeScript's
global fetch()) could resolve to an unrelated same-file declaration
sharing that name, most visibly a Cloudflare Worker's
`export default { async fetch(req) {...} }` handler. Two contributing
gaps, both fixed:

- Object literals had no scope boundary in the TS/JS grammar queries,
  so a method's/property-arrow's name auto-hoisted past the literal
  into whatever lexically enclosed it (scope-extractor.ts's auto-hoist
  logic had nowhere to stop). Give object literals a Block scope, like
  6 other languages already do for lexical blocks.

- Independently, finalize's per-file bindings bucket
  (materializeBindings in gitnexus-shared) flattens every local
  declaration in a file onto its module scope for cross-file import
  resolution, regardless of true nesting -- so free-call-fallback's
  scope-chain walk could still hit the leaked binding at module scope.
  Guard free-call resolution: when a match for a known builtin name
  (LanguageProvider.isBuiltInName, already populated for TS/JS but
  never consulted by this pass) has no binding reachable via the true
  lexical scope chain, leave the call unresolved instead of emitting a
  false CALLS edge.

Verified against the full TS/JS resolver suites plus every other
language populating builtInNames (Python, Go, C/C++, C#, Dart, Kotlin,
PHP, Ruby, Rust, Swift, Vue) -- 2333 tests, no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope-resolution): extend the #2545 scope-leak fix to Kotlin and Java

Anonymous object-expressions (Kotlin `object { ... }`) and anonymous
class bodies (Java `new Runnable() { ... }`) have the same missing
scope-boundary gap that caused #2545 in TypeScript/JavaScript: a method
declared inside has no scope of its own to stop the auto-hoist at, so
its name leaks past the container into the enclosing scope.

- Kotlin: `(object_literal) @scope.class` (distinct from the already-
  scoped named `object_declaration`/`companion_object`). Kotlin already
  populates `builtInNames`, so free-call-fallback's isBuiltInName guard
  (added for #2545) fully closes the equivalent leak here too --
  verified with a `println`-shadowing regression test.

- Java: `(object_creation_expression (class_body) @scope.class)`,
  matching PHP's existing `anonymous_class` handling. Java has no
  `builtInNames` list, so the isBuiltInName guard doesn't engage --
  the scope-tree fix is still correct and necessary (the anonymous
  class's own methods are now owned by the right scope), but an
  unqualified call to an unrelated same-file method sharing the
  anonymous class's method name can still resolve via finalize's
  per-file module-scope bucket (materializeBindings, shared/
  language-agnostic, intentionally not touched by this PR). Documented
  in the test as a known residual gap, same as TS/JS/Kotlin's own
  non-builtin-name collisions.

Audited every other language for the same shape (a value/container
node with no @scope.* capture hosting a would-be-auto-hoisted named
declaration): PHP and Vue already handle it correctly (PHP scopes
anonymous_class; Vue's <script> delegates to the now-fixed TS/JS
query). Ruby, Python, Dart, C#, Swift, Go, Rust, and C/C++ have no
query pattern that treats a literal/container value position as a
named declaration in the first place, so the bug shape can't occur
there.

Verified: full Kotlin + Java resolver suites, 468 tests, no
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope-resolution): dedicated Object scope kind for object literals (#2545, #2551)

Review of the #2545 fix surfaced two defects, both fixed here:

1. The isBuiltInName guard suppressed genuine cross-file imports whose
   name matches a builtin (`import { fetch } from './fetch-polyfill'`
   silently stopped resolving -- verified regression vs. main). The
   leak the guard targets is inherently same-file (finalize's flat
   bucket is per-file), so the guard now also requires
   `fnDef.filePath === parsed.filePath`. New regression test covers
   the polyfill-import shape.

2. The sibling-property case of the reported bug was still broken and
   masked by a tautological assertion (`c.reason` -- a property that
   doesn't exist; the real path is `c.rel.reason` -- so the test
   passed regardless of behavior). In
   `export default { fetch() {...}, handler: () => fetch(...) }`,
   `handler`'s bare `fetch()` still resolved to its sibling. Reusing
   the `Block` scope kind was the root cause: correct for a real
   lexical block (a nested closure legitimately sees a sibling
   `let`/`const` from an enclosing `if`/`for`), wrong for object
   literals, whose members are reachable only via property access --
   never as bare identifiers, not even by sibling property bodies.

   Fix: a dedicated `Object` ScopeKind (gitnexus-shared) -- a hoist
   boundary whose own bindings scope-chain walkers never consult while
   still traversing past it to the parent. TS/JS object literals now
   emit `@scope.object`; the four chain walkers in
   scope-resolution/scope/walkers.ts (walkScopeChain,
   findAllCallableBindingsInScope, findCallableBindingsAndAdlBlocker,
   findExportedDefByName) and free-call-fallback's
   hasGenuineLexicalBinding skip Object scopes' bindings. Kotlin's
   anonymous `object {}` keeps `@scope.class` -- unlike JS object
   literals it has real implicit-this sibling dispatch.

Verified with the full resolver matrix run sequentially (TS 254, JS/
Kotlin/Java/Python/Go + TS variants 960, C/C++/C#/Dart/PHP/Ruby 1049,
Rust/Swift/Vue/Cobol + route/flow/unit suites 828, scope-extractor/
scope-tree units 51). Worker-pool crashes under parallel suite load
reproduced on unrelated files and pass in isolation (known flake, not
caused by this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

* feat(java): model anonymous class bodies as first-class Class nodes (#2550, step 1)

`new Runnable() { public void run() {} }` now emits a synthesized
javac-style `Class` node (`Worker$1`, `$N` = source order within the
top-level class) and owns its methods: the enclosing-owner walk
attributes `run` to `Worker$1` (re-keyed `Method:...:Worker$1.run#0`,
HAS_METHOD from the anonymous class) instead of the lexically enclosing
named class.

- `synthesizeJavaAnonymousClassName` (ast-helpers): single naming
  authority for every layer that keys the anonymous class; returns
  undefined for `object_creation_expression` without a `class_body`
  child, which also keeps it a no-op for C#'s same-named node type.
- `findEnclosingClassInfo`: anonymous-body branch before the generic
  container walk.
- JAVA_QUERIES: `(object_creation_expression (class_body))
  @definition.class` (no @name); `getLabelFromCaptures` now lets a
  nameless `definition.class` through — the parse-worker's existing
  `!nameNode && !extractedClassSymbol` gate still drops any nameless
  class the extractor cannot name, so other languages are unaffected.
- `javaClassConfig.extractName` synthesizes the name on the extractor
  path (worker node emission).
- Node identities move on unchanged files: INCREMENTAL_SCHEMA_VERSION
  7→8 and parse-cache SCHEMA_BUMP 17→18 (the v5 Route-identity
  precedent) force full re-analyze / cache invalidation.

Verified: new #2550 identity tests + resolve-enclosing-owner and
has-method suites (53 tests) green.

Prep for step 2/3 (scope-side ownership + receiver typeBinding) and the
free-call instance-ownership gate per
docs/plans/2026-07-18-gitnexus-plan-java-instance-scoped-freecalls.md
(plan file is local — docs/ is gitignored by repo policy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(java): instance-scoped free-call resolution for anonymous-class methods (#2550, steps 2-4)

Completes the #2550 instance model on top of the Worker$N identity
commit:

- Scope-side ownership (java/captures.ts): synthesize
  `@declaration.class` + `@declaration.name` (`Worker$N`) anchored on
  the anonymous `class_body` — same range as its `@scope.class`, so the
  def lands in that Class scope's ownedDefs, `populateClassOwnedMembers`
  stamps `ownerId` on the anonymous class's methods, and the name
  auto-hoists exactly like a named class declaration.

- Receiver typeBinding (java/captures.ts + type-extractors/jvm.ts):
  `Runnable handler = new Runnable() { ... }` binds `handler` to the
  ANONYMOUS class (`Worker$1`), not the declared JDK interface — in both
  the scope-side TypeRef channel (receiver-bound Case 4) and the worker
  typeEnv. `handler.run()` now resolves through the receiver path
  (reason 'global', target `Worker$1.run#0`) instead of depending on
  the free-call finalize-bucket leak — which is why the prior gate
  attempt broke it (the #2550 landmine, now explained and structurally
  removed).

- Instance-ownership gate (free-call-fallback.ts + contract + run.ts +
  java opt-in): with `ScopeResolver.freeCallsRequireInstanceOwnership`,
  a free call may resolve to a `Method` only when the caller's
  enclosing class chain (self + MRO via `scopes.methodDispatch.mroFor`)
  contains the method's owner. Same-file matches only — the
  `materializeBindings` leak is per-file; cross-file Method matches come
  through genuine import channels (suppressing them broke the
  arity-narrowing parity suite, verified). Suppressions recorded as
  `'free-call-instance-ownership'` outcomes. Java opts in; every other
  language is byte-identical (flag off).

Result on the #2545 fixture: `process()`'s bare `run()` emits NO edge
to the unrelated anonymous method (the #2550 bug, closed), while
`handler.run()`, same-class implicit-this dispatch, and bare inherited
calls (MRO arm) all keep resolving.

Verified: full java.test.ts 223/223 twice sequentially (landmine gate);
cross-language matrix (TS/JS/Kotlin/Python/Go/C/C++/C#/Dart/PHP/Ruby/
Rust/Swift/Vue/Cobol + callable-value-flow + java-class-impact + core
units) — zero assertion failures; worker-crash flakes re-verified green
in single-file isolation.

Known deferral (documented): EXTENDS/IMPLEMENTS edges from the
anonymous class to its constructed type are not yet emitted, so a
same-file inherited-but-not-overridden member called ON the anonymous
instance does not resolve through the anon MRO; tracked as the
follow-up in #2550.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

* fix(java): anonymous-class inheritance, host coverage, and phantom-node guard (#2550 review)

Self-review of the instance model (gitnexus-review with empirical lens
probes) surfaced three defects, all fixed:

1. HIGH — the ownership gate suppressed TRUE bare calls to inherited
   methods inside an anonymous body extending a same-file class
   (`new Base() { void extra() { work(); } }` lost `extra -> work`):
   the anon class had no inheritance edge, so `mroFor(Worker$N)` was
   empty and the MRO arm could never pass. The synthesis now emits an
   `@reference.inherits` for the constructed type, anchored on the
   `class_body` so the reference's enclosing class resolves to the
   SYNTHESIZED def (anchoring on the type node would sit outside the
   anonymous scope and attribute the edge to the wrong class). Anon
   classes now get real EXTENDS/IMPLEMENTS edges and inherited bare
   calls pass the gate.

2. MEDIUM — hostless anonymous bodies materialized a phantom Class
   node named after the CONSTRUCTED type (`Class:...:Runnable`) via
   extract()'s extractTypeNameFromNode fallback. New
   `shouldSkipClassCapture` in javaClassConfig drops the capture when
   no name can be synthesized.

3. MEDIUM — enum/interface/record-hosted anonymous bodies silently
   fell back to the pre-#2550 model (mis-attribution + open leak).
   The topmost-host walk now accepts all four host type declarations
   (JAVA_ANON_HOST_TYPES), so `EnumHost$1` etc. are modeled; the
   phantom-node shape disappears for those hosts as a side effect.

Also: per-parse-tree WeakMap memo for the `$N` numbering — the helper
is called from four independent layers per anonymous body and each call
re-scanned the host subtree (`descendantsOfType`), quadratic on
anon-heavy files (old-style listener-per-widget Java); and the
scope-capture bench fingerprints rebaselined for java/typescript/
javascript/kotlin (`measure.mjs --check` now passes all 14 languages —
it failed for every scope query this PR touched; drift notes added per
the file's convention).

Verified: full java.test.ts 225/225; all 11 #2550 tests including the
new anon-extends-base and enum-host scenarios; bench --check PASS.

Known remaining (documented, unchanged-old behavior): enum CONSTANT
bodies (`A { ... }`) stay unmodeled; nested-host naming is top-level-
anchored (`EnumWrap$1`, not javac's `EnumWrap$Mode$1`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

* test(storage): update the INCREMENTAL_SCHEMA_VERSION pin to v8 (#2550)

The U-C5 reuse-gate test deliberately pins the exact schema version so
a bump cannot land without consciously extending the gate expectations.
Extend for v8 (Java anonymous-class node identities, #2550): a v7 stamp
now fails the strict-equality reuse gate — a pre-v8 index would strand
old `Worker.run`-keyed Method nodes alongside the re-keyed
`Worker$N.run` ones on unchanged files — and v8 passes.

Caught by CI (tests/ubuntu coverage shard 2/3 on PR #2549); the local
matrix had not included this unit file. All 7 schema-referencing unit
suites verified green (109 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-18 14:30:37 +01:00