Commit graph

12 commits

Author SHA1 Message Date
Gergő Magyar
e06c2dd13e
fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354) (#3373)
* fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354)

#3354 reports `impact` returning a byte-identical 1037/CRITICAL/`exact`
result for three unrelated targets, with only `target.name` differing.
The reporter's `target` had no `id` and no `filePath`, which the traversal
path always emits, and a synthetic reproduction of their monorepo (pnpm,
Cloudflare worker-configuration.d.ts in five packages, Hono, a Durable
Object) resolves every target correctly on main. So the identical result
was not reproduced. The repro did surface two real gaps and one hardening
point:

- `_runImpactBFS` now throws when the target has no node id. Every
  caller already catches, so impact reports `impactedCount: null,
  risk: UNKNOWN` instead of a normal-looking blast radius that cannot be
  about this symbol.
- Callable-value flow followed only a single designator on the RHS, so
  `const sweep = env.__sweep ?? runSweep; await sweep(env)` produced no
  flow and `scheduled` was missing as a caller of `runSweep`, while the
  result still claimed `epistemic: exact`. Each branch of `??`, `||`,
  `or`, and `?:` now flows into the binding (language-neutral: operator
  and field names, no language checks). Parse cache bumped 104 -> 112
  (105-111 are claimed by open PR #3326).
- A whitespace-only `target_uid` (strict adapters materialize omitted
  optional strings) is treated as omitted and falls back to the name.

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

* fix(mcp): treat a non-string uid as omitted instead of throwing (#3354)

Review follow-up on #3373. `query.uid?.trim()` called `.trim()` on a
client-supplied value, and the MCP envelope is not type-validated, so
`context({uid: 42})` threw a TypeError that `context()` does not catch.
Before #3373 the same input ended as a structured not_found.

A non-string uid now counts as omitted, which matches how
normalizeToolParams already treats a non-string `target_uid`. The impact
and trace not-found messages print a trimmed uid only when it is a
non-blank string, so a strict adapter sending " " sees the name it
searched for instead of `' '`.

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

* fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354)

Review follow-up on #3373. The shared value-alternatives rule keys on
tree-sitter field names, and several grammars spell the same construct
differently, so the expansion never fired for them:
- Kotlin `elvis_expression` has no fields.
- Swift uses `value`/`if_nil` and `if_true`/`if_false`.
- Dart uses `first`/`second`, and its conditional has no `condition` field.
- Python `a if c else b` has no fields.
The `'?:'` operator entry was dead, since no bundled grammar emits it.

Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions,
consulted before the shared rule, and implement it in the Kotlin, Swift,
Dart, Python and Ruby providers. Shared code still names no language.

Ruby's statement-bodied `if`/`unless`/`elsif` also carries
`condition`/`consequence`/`alternative`, so the shared ternary rule dug
an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and
produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement
branch as one opaque source, as before #3373.

Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby
(including a Ruby negative), and TS chain, parenthesized, callable-left
and `&&` negative cases. Captures goldens gain one entry each for the
new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR).

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

* fix(ingestion): keep long ||/??/?: chains linear in callable-flow capture (#3354)

Expanding value-selecting sources into per-branch flows made long chains
super-linear and, past a few thousand operands, a stack overflow. A
generated `w === "k0" || w === "k1" || ...` keyword table took 6.7 s at
1000 operands and 48 s at 2000, and threw RangeError at 8000.

Two causes, both fixed without changing any emitted capture:

- valueAlternatives recursed once per operator level and spread the
  partial results at every level. It is now an explicit stack that
  writes to one output array. The left-to-right order, the paren
  unwrapping, and the provider-hook contract (`[node]` means opaque) are
  unchanged.
- Every alternative ran its visibility walks from its own leaf, which
  can be as deep as the chain is long, all the way to the root. Also,
  tree-sitter's `parent` re-descends from the root, so each step costs
  the node's depth. The walks now jump between "anchor" nodes, the only
  nodes any check can match: region ids and formal owners. The nearest
  anchor is memoized per node across the file, and parents come from a
  map recorded by the one DFS the synthesizer already does.

After the fix: 0.34 s / 0.23 s / 1.6 s at 1000 / 2000 / 8000 operands.
That is within about 1.2x of main without the expansion; what remains
is tree-sitter query time. Capture fingerprints are byte-identical
before and after the fix for all 16 scope-capture bench languages and
for the Python harness.

A `typescript-deep-chain` case added to the scope-capture bench guards
the scaling: 1.11-1.22 now, 7.5-8.0 before. A unit test pins a
10000-operand chain: it must still yield the seed for a callable
operand, the copy for a formal at the deepest leaf, and the invoke.

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

* fix(kotlin): see through braced if-branches in callable alternatives (#3354)

tree-sitter-kotlin wraps a braced branch as
`control_structure_body > statements > <expr>`, so for any non-empty
block kotlinValueAlternatives saw exactly one named child (`statements`)
and pushed the wrapper itself as the branch value. operandSyntax emits
nothing for a `statements` node, so
`val run = if (c) { ::f } else { ::g }; run()` produced no flow edges,
and the "multi-statement block stays opaque" guard could never fire.

Descend one level through `statements` and require exactly one
non-comment expression there. Empty blocks (`{}` has no named children),
multi-statement blocks, and `if` without `else` still return the whole
`if` as one opaque source. The doc comment now describes that.

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

* fix(ruby): skip only the multi-statement branch in callable alternatives (#3354)

rubyValueAlternatives returned `[node]` (opaque) as soon as one branch
held more than one statement. Two things went wrong because of that:

- At the top level, `run = if c then g = h; 0 else method(:f) end`
  dropped the single-statement `else`, so `f` got no flow edge.
- In an elsif chain, the outer `if` pushed the `elsif` node as a branch.
  The shared loop called the hook on it again, got `[elsif]` back, and
  used the whole elsif subtree as one source. So in
  `if a then method(:run_a) elsif b then g = h; 0 else method(:run_b) end`
  the `run_b` edge was lost.

The hook now walks the elsif chain itself and skips each multi-statement
branch, while every single-statement branch still becomes an
alternative. This cannot add a wrong edge: each emitted alternative is a
value the conditional really evaluates to, and nothing is taken from the
skipped branch. Before, that branch did not contribute a resolvable
value either. A whole conditional used as a source becomes a
qualified-name seed, which resolves to nothing when it has more than one
identifier leaf. With a single identifier leaf, it could even seed the
condition variable. When no branch is a single statement, the
conditional still stays one opaque source, as before.

Ruby captures golden: ruby-callable-alternatives/app.rb goes from 33 to
58 capture groups. 23 of them come from the new fixture functions
(checked against the old source). The other 2 are the new branch
alternatives, `statement_if -> run_sweep` and `elsif_chain -> run_b`.

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

* fix(ingestion): flow the right operand of && / and into callable bindings (#3354)

`a && b` / `a and b` yields `a` when it is falsy and `b` otherwise. A
falsy value is never a callable, so the right operand is the only one
that can be invoked later. The capture left `&&` unexpanded, and the
compound source became a qualified seed that resolves to nothing:
`const run = x && f; run()` gained no edge to `f` while impact claimed
`exact`. Python `x and f or g` reached only `g`.

The shared expansion now maps `&&` / `and` to the right branch only.
It recurses, so `x and f or g` reaches both `f` and `g`. Where `&&`
yields a boolean (Java, C#, Go, Rust, C, C++, PHP, Zig), the
destination cannot be invoked, so the flow never meets a call. A
before/after CALLS diff over all 83 lang-resolution fixtures that
contain `&&` or `and` shows exactly one new edge,
logicalAnd -> runAndRight. PHP and Ruby bind low-precedence `and`
looser than `=`, so `$g = $x and $y` never reaches this rule.

The Python golden digest changes only for the extended
python-callable-alternatives fixture.

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

* fix(ingestion): keep operator branches of a value-selecting source opaque (#3354)

The fan-out sent every branch of `??` / `||` / `or` / `?:` to
emitAssignmentFact on its own, including branches that compute a value.
`Handlers.fallback === run || fb` then emitted the comparison as a seed
whose qualified text sliced to receiver `Handlers`, member `run`, and
resolveBoundMemberCandidates minted a CALLS edge to `Handlers.run`. The
same happened for `this.state !== run ?? this.fallback`,
`this.state + run || this.fallback`, and Python
`self.state != run or self.fallback`. Before the fan-out, the whole
compound source was one opaque seed.

A branch that is a binary operator expression now contributes nothing.
The check uses the field vocabulary valueBranches already reads (a
`left`/`right` pair, or an `operator`/`operators`/`op` token after the
expression start), not grammar type names. Member accesses that field
their `.`/`->` as `operator` (Ruby `call`, C/C++ `field_expression`) also
field a member name through the list memberParts uses, now shared as
memberNameNode, so they stay designators. Unary `&f`/`*fp` lead with
their operator and stay designators. Call results were already dropped
by emitAssignmentFact, and lambdas and callable references are unchanged.

CALLS-edge diff over 87 lang-resolution fixtures (505 -> 501 edges):
only the four false edges above were removed, and none were added.

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

* test(ingestion): pin the LEFT operand of ?? / or / elvis per provider (#3354)

The Python, Kotlin, Swift and Dart "override ?? fn" cases put an
unresolvable parameter on the left, so a fan-out that kept only the
last operand still passed them. Each fixture now adds a callableLeft
case with a real function on the left and a parameter on the right
(`run_left or fallback`, `::runLeft ?: fallback`, `runLeft ?? fallback`),
and each language asserts `callableLeft → runLeft`.

Mutation check: dropping the left branch from the shared `??`/`||`/`or`
rule and making the four provider hooks return only their last operand
fails all four new tests, while the existing right-operand tests still
pass.

Capture goldens were regenerated with UPDATE_GOLDEN=1:
- python app.py: 35 -> 85 groups. 35 -> 75 is stale drift from
  8ad0d8db7, which added the comparison cases without regenerating.
  75 -> 85 is the new case: 2 declarations, 2 scopes, the variable,
  the `run()` reference, and 4 callable-flow captures (seed -> run_left,
  copy <- fallback, formal, invoke).
- swift App.swift: 25 -> 36 groups for the same new case, plus
  Swift's type-binding for the fallback parameter.

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

* test(bench): re-baseline scope-capture fingerprints for #3354 callable alternatives

This PR makes a callable chosen by a value-selecting source flow every
branch it can yield (??, ||, or, && right operand, ternary, statement
if, elvis). It also keeps an operator branch opaque. Both change
@callable-flow captures, and the PR adds *-callable-alternatives
regression fixtures that these benches glob.

To verify, the BASE (merge-base 233ca2849) and HEAD emitters were run
over the same HEAD fixture corpus plus the synthetic source. Every
added or removed match is an @callable-flow.* match on one of those
sources. The pre-existing corpus is byte-identical for all 16
scope-capture languages and for Python. Emitter delta, then corpus
growth:

- ruby:       +5/-0   app.rb (+1 file, +58 groups)
- swift:      +6/-3   App.swift (+1 file, +36 groups)
- dart:       +6/-3   app.dart (+1 file, +33 groups)
- kotlin:     +8/-2   App.kt (+1 file, +71 groups)
- typescript: +18/-14 4 files (+288 groups)
- python:     +11/-7  app.py (+1 file, +85 groups)

The removed matches are whole-expression seeds and copies that carried
a qualified name of the compound source. They are replaced by one
seed or copy per operand. Synthetic scaling counts are unchanged and
every scaling ratio stays under budget. Per-language notes are in
baselines.json under _rebaselined_3354_callable_alternatives.

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

* refactor(mcp): share one blank-uid check across impact, trace and symbol lookup (#3354)

The "trimmed uid, or omitted when blank/non-string" rule was inlined four
times, three of them trimming twice. nonBlankUid() owns it now; behaviour is
unchanged. The whitespace and empty target_uid tests collapse into one it.each.

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

* docs(cfg): correct the Kotlin and Python grammar-field notes (#3354)

The Kotlin CFG visitor claimed no control-flow node has fields; a parse of
the vendored grammar shows if_expression fields condition/consequence/
alternative (when/for/while/do/try/elvis are fieldless). The Python harvest
note listed conditional_expression's children like field names; the node is
fieldless and they are positional. Both notes were misleading reviewers of
the #3354 value-alternatives hooks. Comment-only.

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

* test(ingestion): skip optional-grammar suites in callable-alternatives providers test (#3354)

Kotlin, Swift and Dart grammars are optional installs. Guard their describe
blocks with isLanguageAvailable, as swift.test.ts and dart.test.ts do, so an
install without one of them skips instead of failing.

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

* test(ingestion): probe the Dart parser before enabling its providers suite (#3354)

isLanguageAvailable only proves the module loaded; tree-sitter-dart can still
fail on setLanguage. Probe loadParser/loadLanguage and skip on failure, the
same guard dart.test.ts uses.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:43:24 +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
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
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
ed8ab1c246
fix(scope-resolution): resolve callable reference flows (#2437) (#2522)
Some checks are pending
CodeQL / Analyze (python) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (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
* docs(plans): add provider-hook value-refs plan (#2437)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plans): deepen #2437 plan to USES + property-dispatch design

Design revised after prior-art research (Kythe ref vs ref/call, Joern
METHOD_REF, Feldthaus field-based call graphs, CodeQL impliedReceiverStep):
registration sites emit reference-class USES, invocation is recovered by a
field-based property-dispatch pass synthesizing CALLS at member-call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scope-resolution): model provider-hook value references (#2437)

Functions referenced as object-literal property values (provider hooks like
emitScopeCaptures: emitCppScopeCaptures) previously produced no edge at all,
so impact/context reported a false-safe 0 upstream dependents.

Two coordinated halves, per prior art (Kythe ref vs ref/call, Joern
METHOD_REF, Feldthaus ICSE'13 field-based call graphs, CodeQL
impliedReceiverStep):

- Registration -> USES: new ReferenceKind 'value-ref'; TS/JS queries capture
  pair values and shorthand properties (with @reference.property-key);
  emitted as a reference-class USES edge, reason 'scope-resolution:
  value-ref'. Resolution is callable-gated so plain values emit nothing.
- Dispatch -> CALLS: new shared pass emitPropertyDispatchCalls synthesizes
  CALLS (reason 'property-dispatch', confidence 0.7, per-key fan-out cap 32
  calibrated on this repo's 16-provider hook tables) from member-call sites
  to every function registered under the same property key.

Deviation from plan: the pass owns value-ref resolution entirely via the
post-finalize findCallableBindingInScope walker — the shared registries only
see pre-finalize local bindings, so imported hooks (the c-cpp.ts case) were
unresolvable through lookupForSite; Reference.propertyKey passthrough
dropped as unnecessary.

SCHEMA_BUMP 13 -> 14: ParsedFile gains value-ref sites + propertyKey.

Verified end-to-end: impact(emitCppScopeCaptures, upstream) now reports 8
impacted / HIGH with extractParsedFile (true dispatch caller) at d=1 via
property-dispatch and the c-cpp.ts registration via USES.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(scope-resolution): cover value-ref registration and property dispatch (#2437)

Integration: same-file/cross-file/aliased/shorthand registrations emit USES;
non-callable and destructuring values emit nothing; dispatch sites gain
property-dispatch CALLS (incl. JS twins and per-language partitioning);
fan-out-capped keys are dropped entirely; factory-call values unchanged.
Unit: capture-shape pins for @reference.value-ref + @reference.property-key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): surface dropped property-dispatch keys in stats (#2437)

Review finding: skippedKeys was returned but discarded — a hook table
larger than the fan-out cap silently reopened the #2437 gap for those
keys. Log dropped keys and fold value-ref USES + dispatch CALLS into
referenceEdgesEmitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plans): add callable reference-flow implementation plan

* fix(scope-resolution): close property-dispatch review gaps

* feat(scope-resolution): add callable flow facts

* feat(scope-resolution): resolve callable value flow

* feat(scope-resolution): resolve callable references across providers

* fix: harden callable reference flow resolution

* fix(scope-resolution): preserve callable binding semantics

* docs(plans): add pr-2522-review-fixes plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): bump INCREMENTAL_SCHEMA_VERSION for callable-value-flow edges

Callable-value-flow CALLS/USES edges (#2437) can connect two files whose
content did not change, but the incremental write set only covers changed
files — a top-up against a pre-v7 index would silently omit the new edges
for every unchanged file pair, indefinitely. Force the one-time full
re-analyze (review finding 1, #2522).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): sanitize callable-flow sites per-site at load, log drops

The load-time validator rejected the WHOLE ParsedFile when one site was
malformed or over-bound, with no logging — and C++ legitimately emits
empty-string parameterTypes entries ('' = unknown, the
ReferenceSite.argumentTypes convention) for cv-only/ERROR-recovered types,
so real repos fell into a permanent, silent warm-cache-miss reparse loop
through the #1983-sensitive main-thread path (review finding 7, #2522).

Now: '' entries are valid in type arrays; a malformed/over-bound site drops
only itself (counted, warned once per load); only non-array garbage —
evidence the serialization itself is untrustworthy — rejects the file.
Deviation from plan §6 wording: validator-side tolerance replaces emit-side
clamps — smaller diff, same asymmetry closed at the single chokepoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): keep declarations in the union for reassigned callable cells

The binding-lookup suppression for fact-constrained cells was wholesale:
reassigning a declared function through its own name (greet = other;
greet()) deferred the call to the solver, which then refused the lexical
lookup that resolves the declaration — an unresolvable RHS yielded zero
CALLS for a call that resolved pre-flow (review finding 8, #2522).

Suppression now applies only to cells bound by FORMAL facts — its actual
purpose (a parameter whose grammar emits no declaration binding must not
adopt a same-named outer function). Copy/alias/store/load destinations keep
their declaration as an inclusion seed (Andersen-style union).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): count forfeited deferred sites in the budget-bailout warning

On work-budget exhaustion the deferred invoke sites end the run with zero
CALLS — free-call fallback and reference emission already skipped them —
but the warning said 'ordinary graph emission remains untouched', which is
false for exactly those sites. The warning context now carries the
unresolved deferred-site count and the comment states the real cost
(review finding: budget-bailout honesty, #2522).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scope-resolution): surface dropped property-dispatch keys in stats and warn payload

The over-cap warning carried only a count; the dropped key NAMES were
discarded and RunScopeResolutionStats had no field, so the PR-body claim
'includes them in resolver statistics' was unimplemented (review finding,
#2522; reviewer ask on the fan-out cap). The warn payload now names up to
20 dropped keys and the stats carry propertyDispatchSkippedKeys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(scope-resolution): drop producer-less ownerQualifiedName from formal sites

No capture emitter anywhere produces @callable-flow.owner-qualified-name —
the solver branch consuming it was unreachable in production, yet the field
was typed, parsed, validated, and unit-tested with hand-built input (review
finding 16, #2522; YAGNI). Re-add with a real producer if C++ qualified
member declarators ever need it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(scope-resolution): drop dead callable-flow knobs

CallableFlowPassingMode 'callable-object' had no producer and no consumer
distinguishing it, and CallableFlowCaptureOptions.extractCallArguments had
no language providing it (unlike its live sibling extractCallCallee) —
review finding 17, #2522 (YAGNI). The invocation-kind 'callable-object'
is a different, live concept and stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): bind subscripted callable cells to the container, not the index

terminalIdentifier iterates children in reverse, so tbl[i] = handler seeded
the INDEX variable's cell (polluting a same-named formal) and tbl[i](7)
looked up the callee under i in a different scope — no join, no CALLS edge
for the classic function-pointer-array dispatch (review finding 12, #2522).
Subscript nodes now recurse into their container field only, in both
bindingIdentifier and terminalIdentifier, across the fielded grammars
(C/C++/JS/TS/Python/Go/Java).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): make cross-function file-scope callable bindings resolvable

Two stacked gaps killed the canonical C callback-registration pattern
(fp assigned in init(), called in run()) — the exact #2437 false-safe this
PR exists to fix (review finding H1, #2522):

1. isVisibleValueBinding only consulted assignment regions and formals, so
   a call in a function OTHER than the assigning one emitted no invoke
   fact. A declared callable-typed binding is now a value binding wherever
   its declaration is visible (visibleCallableSignature).
2. The C scope query had no @declaration.variable pattern for function-
   pointer declarators — void (*fp)(int); created no scope-tree binding,
   so the seed (init) and invoke (run) cells canonicalized to different
   keys and never joined. Both bare and initialized forms now bind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(c): detect variadic parameters via the named variadic_parameter node

tree-sitter-c materializes '...' as a named variadic_parameter node; the
anonymous-token checks never matched, so variadic function-pointer
signatures were emitted with a wrong fixed arity and no '...' sentinel
(review finding, #2522). C++ is unaffected ('...' stays an anonymous token
there); the token checks remain for such grammars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): emit invoke facts for field-stored callable member calls

The C ops-vtable pattern (o->run = handler; o->run(1)) captured the store
but never the call — the member path in emitCallFacts bailed for languages
without protocol methods, and the value-binding index recorded the member
store under the OBJECT's name ('o'), not the member's ('run') (review
finding 11/M3, #2522). Member destinations now also record their terminal
member name, and a member call whose name-cell has a visible store emits an
indirect invoke — gated on the store so plain accessor calls (map.get)
stay inert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): disambiguate (obj->*ptr)() ERROR recovery by token order

tree-sitter-cpp groups the recovered '->*' two ways depending on
error-recovery cost (identifier lengths): [identifier, ERROR '->*m'] or
[ERROR 'obj->*', identifier]. The recovery assumed the first shape, so the
second silently swapped receiver/member and dropped the call site — the
committed test passed only by name luck (review finding H2, #2522). The
identifier's position relative to '->*' inside the ERROR now decides roles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): class members are never file-local in hasFileLocalCallableLinkage

The name-keyed file-local set is populated from every static declaration,
so an in-class 'static void make();' (external linkage — in-class static
means no-instance) and any member sharing a name with a static free
function were over-marked, refusing legitimate cross-file
declaration/definition joins (review finding 13/M2, #2522). Method and
Constructor defs now bypass the name-set, per the hook's own linkage-only
contract.

Deviation from plan step 13: the regression is a unit-level contract pin
rather than an end-to-end join test — C++ merges out-of-line member
definitions onto the member node by qualified identity, so the graph shape
cannot discriminate the join refusal for members.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): classify parameter passing mode from the declarator chain only

A whole-subtree scan for reference_declarator inverted copy vs alias:
void reg(void (*cb)(int& out)) marked the by-value pointer cb as
'reference' because of the NESTED parameter's int&, making the solver
back-propagate formal targets into every caller's argument cell — alias
semantics for a copy (review finding 14/M5, #2522). The chain walk never
descends into nested parameter lists; a reference anywhere ON the chain
(int& x, void (*&cb)(int)) still aliases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ruby): bare identifiers are calls, not callable references

Ruby parses a receiver-less zero-arg method call identically to a variable
read, so 'action = process' — which CALLS process and stores its return —
seeded action with the callable and minted a wrong CALLS edge from any
dispatch through it, confirmed end-to-end (review finding 15/HIGH, #2522).
New provider knob bareNamesAreCalls: a bare name that is not a provably
local value binding and not an explicit reference form (method(:x),
lambda/proc) emits no flow fact, on both the assignment and argument paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(go): pair multi-value := positionally instead of cross-wiring

The shared field fallback took the FIRST LHS identifier and the LAST RHS
identifier of Go's expression_list pair, cross-wiring 'a, b := f, g' and
synthesizing a garbage comma-joined qualified name — the real relationships
were silently dropped (review finding 16, #2522). extractAssignment may now
return multiple pairs; Go pairs list entries positionally and emits nothing
for a length mismatch (multi-return call RHS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(java): drop get/test from callableProtocolMethods

'get' and 'test' collide with ubiquitous non-functional-interface APIs
(Map/List/Optional/Future.get), so every ordinary container access emitted
a spurious callable-object invoke fact — high-volume misleading graph facts
with a cross-wiring risk on receiver-name reuse (review finding 17, #2522).
Supplier.get/Predicate.test dispatch is deliberately traded away until the
check can gate on the receiver's declared type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rust): pin the qualified-name no-degrade guard as a hard invariant

Rust's scoped_identifier callable-reference capture over-includes unit enum
variants and associated constants (Shape::Square seeds as if callable);
they stay edge-free only because resolveSeedCandidates refuses to degrade
an unresolved qualified name to a simple-name lookup (review finding 18,
#2522). Capture-side type filtering would false-negative on tuple-variant
constructors, so the guard IS the contract: documented as a hard invariant
(Go's mis-shaped multi-value forms also rely on it) and pinned end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(php): remove nonexistent optional_parameter node type

tree-sitter-php has no 'optional_parameter' — defaults ride on
simple_parameter — so the entry was dead weight the #1920 literal gate
does not cover for capture-option Sets (review finding 19, #2522).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cobol): detect procedure pointers on fixed-format sources

Two stacked defects made the feature a no-op on classic sequence-numbered
fixed format (review finding 20/H3, #2522):
1. parseDataItemClauses' USAGE alternation knew POINTER but not
   PROCEDURE-POINTER/FUNCTION-POINTER, so the dataItems filter was dead.
2. The raw-line fallback scanned UNCLEANED text, where the sequence number
   satisfied the leading digits and the LEVEL NUMBER got captured as the
   pointer name. It now scans preprocessed lines and requires a letter-
   initial name (COBOL data names must contain a letter).
161 COBOL preprocessor/copy-expander tests stay green; free-format matrix
case unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cobol): skip comment lines in SET seed/copy scans

A commented-out SET (indicator-column '*'/'/' or free-format '*>')
produced a live seed and a false CALLS edge from dead code (review
finding 21/M1, #2522). The scan now skips indicator-column comment lines
and strips inline '*>' tails before matching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(architecture): document callable-flow-only mode and skipped-key reporting

The Callable-value flow section omitted scopeResolutionEdgeMode:
'callable-flow-only' — a real emit-pipeline branch that suppresses all
ordinary emission for standalone providers (review finding 22, #2522) —
and predated the skipped-key names/stats surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(scope-resolution): correct value-ref resolution attribution and stale pdg-gating comments

The value-ref contract comment claimed MethodRegistry resolution — the
mechanism is the post-finalize findCallableBindingInScope walker owned by
emitPropertyDispatchCalls (resolveReferenceSites skips these sites). Three
'only under --pdg' calleeIdSink comments were falsified by the #2437 gating
change (callee-id-sink.ts's header was updated; these copies were missed).
Review finding 23, #2522.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ingestion): direct unit coverage for synthesizeCallableFlowCaptures

The 1,100-line shared synthesizer had no test naming it — only downstream
consumers were covered (review finding 24, #2522). Pins seed/invoke/
formal/argument emission, subscript container binding, store-gated member
invokes, produced-value guards, and the bareNamesAreCalls knob over a
minimal options object so assertions target the synthesizer's own
semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(resolvers): deepen shallow-language coverage; fix Kotlin/Swift reassignment gaps it exposed

Adds the COBOL SET x TO y copy-branch scenario and conditional-assignment
scenarios for Kotlin, C#, Swift, and Dart (10 languages previously had one
generic case each — review finding 25, #2522). The new scenarios exposed
two real capture gaps, fixed here:
- tree-sitter-kotlin's 'assignment' node is fieldless, so nested
  reassignments (chosen = ::target inside a block) produced no flow facts;
  Kotlin's extractAssignment now decomposes it positionally.
- tree-sitter-swift fields its assignment as target:/result:, neither in
  the shared fallback's field lists; both added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(infra): literal-validation gate for callable-capture option Sets

The #1920 gate validates query literals and exported configs but not the
module-private *_CALLABLE_CAPTURE_OPTIONS Sets consumed by the shared
synthesizer — a typo'd node type silently captures nothing (PHP shipped a
dead 'optional_parameter'; review finding 26, #2522). Every <key>NodeTypes
Set literal is now validated against its language's grammar; name-carrying
sets (callableProtocolMethods, memberPointerOperators) are deliberately
outside the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(storage): centralize corrupt-fixture casts into makeStoreEntry

The callable-flow store tests scattered 'as unknown as' double-casts per
fixture (review finding 27, #2522; standing no-as-any rule). One typed
helper now owns the single controlled escape hatch for building malformed
serialization-boundary payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(bench): refresh capture fingerprints after review fixes

python-scope: the committed baseline (8d5c3699) never matched this
branch's code — CI's benchmarks arm was red on the PR head (review
finding 2/HIGH, #2522); regenerated (a99e69ab), scaling 1.04 in budget.
scope-capture: ruby/cpp/swift/java/kotlin drifted from the review-fix
commits (bare-name suppression, passing modes + ->* recovery, assignment
fields, protocol narrowing, positional assignment); all 14 languages
re-verified PASS with ratios <= 1.18 against the 1.5 budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(docs): untrack docs/plans working documents

docs/ is gitignored (local working docs); the plan files were force-added
past the ignore. Untracked from the index only — they stay on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(golden): regenerate captures goldens after callable-flow review fixes

The per-language digest guards (csharp/go/php/python/ruby/rust/swift)
locked the pre-fix capture output; the review-fix series intentionally
changed it — store-gated member invokes, subscript container binding,
Ruby bare-name suppression, Swift assignment fields, positional pairing.
Regenerated with UPDATE_GOLDEN=1; clean verification run 59/59; all other
parity/golden guards (pipeline-graph, spring-route, python parity) pass
untouched at 33/33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): prototypes are callees, not callable value cells

The cross-function visibility fix indexed EVERY signature-bearing
declaration as a value binding — including plain function/method
prototypes (void f(int);). Every call to a declared function then became
an indirect invoke, and with emitCanonicalInvokeReference (C/C++) minted a
free-call reference that resolved through the registry, bypassing the
precise passes' two-phase/ambiguity/subobject suppression — eight phantom
CALLS edges in the cpp resolver suite on CI.

Only declarations whose binding identifier sits under a pointer/
parenthesized declarator (callable-typed variables like void (*fp)(int);)
create value cells now. cpp resolver suite 331/331; callable-value-flow +
C/C++ suites 181/181 (the cross-function fp regression still passes); cpp
fingerprint rebaselined, both bench gates PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:20:02 +01:00
Gergő Magyar
560291ad6e
fix(ingestion): qualify Ruby same-tail nested mixin modules + route IMPLEMENTS by scope (#1991) (#2006)
* fix(ingestion): qualify Ruby same-tail nested mixin modules + route IMPLEMENTS by scope (#1991)

A Ruby `module` maps to the Trait label but is not a typeDeclaration, so the structure phase never qualified its node id: two same-tail nested mixin modules (App::Loggable / Web::Loggable) collapsed onto one Trait:f.rb:Loggable node and the bare-name `include Loggable` cross-wired IMPLEMENTS (first-wins tail).

Structure phase: expose buildQualifiedName as a `qualifyScopeName` ClassExtractor hook and thread it for Trait nodes in parsing-processor + parse-worker (lockstep), so a module node keys by its qualified scope path (App.Loggable). Not Option A — `Trait` is not in CLASS_LIKE_LABELS and the qualified-id selection gates it out; qualifyScopeName bypasses the typeDeclaration gate that makes extractQualifiedName bail on modules. getQualifiedOwnerName also falls back to qualifyScopeName so methods inside a nested module own through the same qualified Trait id (no dangling HAS_METHOD).

Resolution: emitRubyMixinEdges resolves a bare mixin reference lexically by the including class's enclosing scope (`App::S` + `Loggable` -> `App::Loggable`), and the simple-tail fallback is now delete-on-collision (refuse to guess on a same-tail tie) instead of first-wins.

New single-file fixture + tests: two distinct Trait nodes, S IMPLEMENTS App.Loggable only, T IMPLEMENTS Web.Loggable only, no dangling HAS_METHOD; both resolver legs + worker path. Module->Trait preserved; Trait NOT added to CLASS_LIKE_LABELS. ruby-captures-golden regenerated additively.

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

* refactor(ingestion): single-source the Ruby Trait scope-label predicate; regen ruby bench baseline (#1991)

F5 follow-up to #1991: replace the four hardcoded `nodeLabel === 'Trait'` checks
(two each in the sequential parsing-processor.ts and worker parse-worker.ts
definition paths) with a single isQualifiableScopeLabel() in ast-helpers.ts so the
lockstep paths can't drift. Value-identical predicate — no behavior change.

Also regenerate the ruby scope-capture bench baseline: #1991 added the
ruby-nested-mixin-tail-collision fixture (and updated the ruby captures-golden),
but the bench baseline was never regenerated, so the order-independent fingerprint
drifts (bf6b13a -> f0d9b4c6, fixture_count 85 -> 86). Pure fixture-corpus drift.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:38:41 +01:00
Gergő Magyar
083aedbc41
refactor(ingestion): delete legacy call-resolution DAG + heritage processor (RING4-1, #942) (#2023)
* refactor(ingestion): delete legacy call-resolution DAG + heritage processor (#942)

RING4-1: all 16 production languages (incl. Vue #940) are registry-primary, so
the legacy resolution legs only ran under the now-removed CI parity gate. Calls
and inheritance now resolve exclusively through scope-resolution
(Registry.lookup, preEmitInheritanceEdges, emitHeritageEdges, buildMro →
MethodDispatchIndex).

Removed:
- Call-resolution DAG: call-processor.ts legacy body (processCalls,
  processCallsFromExtracted, resolveCallTarget + all resolver/dispatch/chain
  helpers), model/resolve.ts MRO-via-HeritageMap, model/heritage-map.ts,
  type-env DAG types; inferImplicitReceiver/selectDispatch LanguageProvider
  hooks + Ruby impls; DispatchDecision/ImplicitReceiverOverride/ReceiverEnriched.
- Legacy heritage path: heritage-processor.ts, heritage-types.ts,
  heritage-extractors/, @heritage.* tree-sitter queries, heritageExtractor/
  heritageDefaultEdge/interfaceNamePattern wiring, worker + parse-impl heritage
  passes (parse-worker/parsing-processor lockstep), cross-file-impl DAG pass.
- Scope-parity infrastructure entirely (no legacy↔registry parity left to run):
  scripts/run-parity.ts, scripts/ci-list-migrated-languages.ts,
  ci-scope-parity.yml, test:parity, and the scope-parity ci.yml gate. Resolver
  integration tests still run via the normal tests job.

Kept (shared infra, NOT call-DAG-only): type-env.ts buildTypeEnv (field
extraction / structure phase / embeddings), model/resolve.ts c3Linearize +
gatherAncestors (mro-processor mroPhase), route/fetch/exported-type-map helpers
in call-processor.ts, preEmitInheritanceEdges (legacy-edge dedup simplified).

Acceptance: grep for resolveCallTarget/inferImplicitReceiver/selectDispatch/
buildHeritageMap/HeritageMap/processHeritage/heritageExtractor/@heritage. is zero
across src + test. tsc clean (both packages); resolver integration suite green
(bit-compatible EXTENDS/IMPLEMENTS/CALLS); scope-capture fingerprints unchanged
(python re-baselined: removed redundant ignored captures). ARCHITECTURE.md
updated to scope-resolution-only.

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

* fix(review): apply autofix feedback (#942)

ce-code-review autofix pass on the RING4-1 deletion:
- parse-cache.ts: bump SCHEMA_BUMP 2→3 — ParseWorkerResult lost its `heritage`
  field, so stale on-disk caches must invalidate (prevents a rollback replaying
  a heritage-less cache into legacy code) [api-contract P2].
- parse-impl.ts: drop 3 now-unused type imports (ExtractedCall,
  ExtractedAssignment, FileConstructorBindings) left by the deferred-block
  removal — would fail the eslint CI gate [correctness+maintainability P1].
- AGENTS.md / CLAUDE.md / scope-resolver.ts contract doc: fix stale pointers to
  the deleted "§ Call-Resolution DAG" section + removed hooks; preserve the
  language-neutrality rule [project-standards P1].
- registry-primary-flag.ts / cross-file.ts / parse-impl.ts: refresh stale
  comments referencing deleted symbols (legacy DAG, runCrossFileBindingPropagation).

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

* refactor(ingestion): remove the vestigial isRegistryPrimary flag (#942)

With the legacy call-resolution DAG deleted, the per-language
`REGISTRY_PRIMARY_<LANG>` / `isRegistryPrimary` / `MIGRATED_LANGUAGES` flag had
only one meaningful state — every production language resolves via
scope-resolution — and an explicit `=0` override could only *disable*
resolution with no fallback (a footgun the review flagged). Removing it.

- Delete `registry-primary-flag.ts` and the now-dead `shadow-harness.ts`
  (legacy↔registry shadow-parity tool) + its test.
- Collapse the three flag gates to their behavior-preserving outcome
  (`SCOPE_RESOLVERS == MIGRATED_LANGUAGES`, so this is a no-op):
  - scope-resolution phase now runs for every registered `SCOPE_RESOLVERS`
    entry (was `∩ MIGRATED_LANGUAGES`).
  - import-processor `addImportGraphEdge` + parse-impl `shouldAccumulate`:
    the legacy emit/accumulate paths were already inert for migrated
    languages (scope-resolution owns IMPORTS via the imports-to-edges bridge);
    drop the flag term.
- Collapse flag-branching tests to the scope-resolution path and delete the
  csharp legacy-`=0`-leg describe blocks; remove the ruby/rust-scope env-forcing
  hooks (no-ops now).
- Refresh docs/comments (ARCHITECTURE.md "one registration", scope-resolver
  cookbook, phase deps) — adding a language is now a single `SCOPE_RESOLVERS`
  registration.

Verified: tsc clean (both packages); resolver integration tests green
(747 assertions across cobol/csharp/ruby/rust/typescript/go, IMPORTS edges
intact); grep for the flag symbols is zero across src + test.

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

* style(format): prettier formatting on #942 changes

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

* fix(ci): drop legacy heritage-capture tests + re-baseline scope-capture fingerprints (#942)

Two CI failures from the #942 cleanup, surfaced by the tri-review + CI:

- tree-sitter-languages.test.ts: two tests asserted `@heritage.*` captures
  (Rust trait-impl, Dart extends/implements/with) that this PR removed. The
  acceptance grep used `@heritage\.` (with `@`); these reference the runtime
  capture name `heritage.trait` (no `@`), so they slipped the earlier sweep.
  Inheritance is now covered by the resolver integration suite. (fixed macos-latest)

- Re-baselined the scope-capture bench fingerprints for csharp/rust/ruby/java/
  javascript/kotlin (baselines.json) + python (python-scope/baseline-fingerprint.txt).
  The earlier test-cleanup reworded comments inside the lang-resolution fixture
  files (Shapes.cs, child.rs, derived.rb, IA.java/Plain.java, Service.js, F.kt,
  app.py) to scrub deleted-symbol references for the acceptance grep; those are
  the bench corpus, so capture node positions shifted. Capture LOGIC is
  unchanged — verified `--check` passes for all 14 langs + python. (fixed benchmarks)

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

* docs/chore: scrub remaining REGISTRY_PRIMARY + deleted-symbol references (#942)

Tri-review P3 follow-ups (verified):
- TESTING.md: rewrite the "Scope-resolution parity" section — the legacy
  dual-leg (REGISTRY_PRIMARY_<LANG>=0/1) and `npm run test:parity` no longer
  exist; resolver tests run once on the sole scope-resolution path in the
  normal tests job.
- scripts/bench-scope-resolution.ts: drop the inert `REGISTRY_PRIMARY_PYTHON=1`
  env set + usage hint (the flag is gone).
- ruby/scope-resolver.ts, php/captures.ts: re-point doc-comments off the
  deleted heritage-map.ts / heritage-processor.ts to the current behavior.

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

* fix(ci): prettier format + regenerate scope-capture goldens (#942)

Two more CI failures, same root cause as the bench re-baseline (the
test-cleanup reworded comments in lang-resolution bench/golden-corpus fixtures):

- quality/format: prettier on tree-sitter-languages.test.ts (blank line left by
  the deleted heritage-capture tests) + TESTING.md (the rewritten section).
- tests/ubuntu/coverage: `csharp-captures-golden` (and python/ruby/rust) drifted
  because the edited fixtures feed the per-language capture-golden snapshots too
  (not just the bench). Regenerated via UPDATE_GOLDEN=1. Verified safe: only the
  edited-fixture entries changed; csharp `captureGroups` unchanged (38) — digest
  shifted from comment-position only; capture LOGIC untouched. 1168 scope-
  resolution tests pass.

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

* test(resolvers): drop createResolverParityIt wrapper, use vitest it directly

The parity-aware `it` wrapper became a no-op when #942 removed the legacy
call-resolution DAG (it just returned vitest's `it`). Remove it entirely so
the resolver tests call vitest's `it` directly instead of shadowing it with a
local `const it` (or `pit`/`rustParityIt`):

- helpers.ts: delete createResolverParityIt + its now-unused vitestIt import
  and VitestIt type.
- 16 files: drop `const it = createResolverParityIt('x')` and import `it`
  from vitest instead.
- ruby.test.ts (pit) + rust.test.ts (rustParityIt): rename calls to `it`.
- Scrub every comment that described the removed wrapper / dual-mode parity
  skip / legacy_skip gate (vue-scope, js/ts/dart/php/python headers, rust x2,
  cpp, swift x4, rust-coverage). Genuine test rationale is kept; only the
  vestigial two-leg framing is dropped. Accurate "legacy DAG (removed in
  #942)" historical notes are retained.

No fixtures touched (no bench/golden re-baseline). tsc clean; rust+ruby
resolver suites green (323 tests, incl. #1992 worker-path parity after a
local dist build).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:07:37 +01:00
Gergő Magyar
c60ad9f7ab
fix(ingestion): fully-qualified nested-type identity for C++/Ruby — structure (#1978) + resolution (#1982) (#1981)
* fix(ingestion): qualify nested-type node identity for C++/Ruby (#1978)

Nested types sharing a tail name in one file — C++ `Outer::Inner` vs
`Other::Inner`, Ruby `Outer::Inner` vs `Other::Inner` modules — silently merged
into a single graph node keyed by the simple tail (`Struct:file:Inner`),
cross-wiring their methods/properties onto one owner.

Key class-like type nodes (Class/Struct/Interface/Enum/Record) by their
normalized fully-qualified path (`Struct:file:Outer.Inner`) instead of the
simple name. Gated per-language by a new `qualifiedNodeId` config flag
(default false → byte-identical for every other language); enabled here for
C++ and Ruby.

- class-types.ts / generic.ts: `qualifiedNodeId` flag on ClassExtractor + config
- ast-helpers.ts: findEnclosingClassInfo gains an optional getQualifiedOwnerName
  hook + EnclosingClassInfo.qualifiedClassId, so member-owner edges resolve to
  the qualified class node id (owner id == node id by construction)
- parsing-processor.ts + parse-worker.ts: flag-gated qualified node-id + owner
  edges on both the sequential and worker parse paths (incl. routed properties)
- call-processor.ts: same qualifier in the routed-property pre-pass (lockstep
  with the worker `kind === 'properties'` block)
- configs/c-cpp.ts, configs/ruby.ts: qualifiedNodeId: true

Method/Property node ids stay simple-qualified; only type nodes get the
qualified id.

Deferred to a resolution-side follow-up: Ruby SAME-TAIL routed-property/mixin
owner identity under registry-primary (`emitRubyMixinEdges` keys owners by the
simple tail name, last-wins); and Rust inherent-impl methods (impl_item is not
a typeDeclaration — its #1978 test is describe.skip).

Tests: same-tail collision fixtures + #1978 resolver tests for C++/Ruby
(positive owner identity, R7), a worker-path parity block, and an unambiguous
nested attr_accessor case; the C++ #1975 out-of-line test updated to assert
qualified-id distinctness (forward-decl + out-of-line now unify). Verified
green on both parity legs, the worker path, and tsc.

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

* test(ingestion): scope #1978 resolver tests to registry-primary leg; fix lint

- helpers.ts: exclude the new #1978 C++/Ruby resolver tests from the legacy
  parity leg (LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES). They PASS on legacy
  too — the fix lives in the SHARED structure phase, not the legacy resolution
  path — so this is a deliberate registry-primary-only scoping (not a legacy
  gap), keeping the legacy path untouched and uncoupled from the new
  node-identity behavior.
- rust.test.ts: drop the `eslint-disable vitest/no-disabled-tests` directive.
  That rule isn't configured in this repo, so eslint errored "Definition for
  rule 'vitest/no-disabled-tests' was not found" and failed `quality / lint`.
  The describe.skip needs no disable directive.

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

* fix(test): satisfy CI for the new #1978 fixtures (format + golden + fingerprint)

Adding the {cpp,ruby,rust}-nested-tail-collision fixtures changed the
lang-resolution corpus, which the scope-capture golden snapshots and the
fingerprint baselines gate on. These are pure fixture-corpus additions —
#1978 does not touch the scope-capture phase (captures.ts / emit*ScopeCaptures
are unchanged). Verified: the regenerated ruby/rust golden diffs are
additive-only (no existing fixture's capture digest changed), so the cpp/ruby/
rust fingerprint drift is solely the new fixtures.

- prettier --write test/integration/resolvers/{ruby,rust}.test.ts
- regenerate ruby/rust captures-golden snapshots (UPDATE_GOLDEN=1; +1 fixture each)
- rebaseline cpp/ruby/rust scope-capture fingerprints (bench/scope-capture/baselines.json)

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

* refactor(ingestion): extract shared qualified-name normalizer (#1982)

Move normalizeQualifiedName/splitQualifiedName out of class-extractors/
generic.ts into utils/qualified-name.ts so the structure-phase
buildQualifiedName, the scope-resolution inheritance resolver, and the
per-language capture emitters can all key against ONE normalizer. A raw
'::' qualifier must normalize to the exact '.'-joined key the
QualifiedNameIndex already holds, or the qualified lookup silently misses
(the #1982 resolution-side foundation). Pure relocation — byte-identical
function bodies; tsc clean; existing C++ nested-collision tests green.

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

* fix(ingestion): resolve same-tail C++ nested-type heritage to the correct qualified node (#1982)

Registry-primary C++ inheritance (preEmitInheritanceEdges -> resolveInheritanceBaseInScope)
resolved a same-tail nested base by its SIMPLE TAIL with first-wins, so
`struct DerivedB : Other::Inner` mis-resolved EXTENDS to Outer.Inner (the wrong
sibling; 0 dangling, so undetected). The namespace qualifier was discarded at the
C++ inheritance capture.

Fix (additive, qualified-first):
- ReferenceSite gains an optional `rawQualifiedName`; the C++ inheritance capture
  emits `@reference.qualified-name` (qualifier-preserving, template-stripped:
  Other::Inner, ns::Base<T> -> ns::Base) only when the base is qualified, registered
  as a sub-tag so it can't shadow the `@reference.inherits` anchor.
- resolveInheritanceBaseInScope resolves the qualifier against the full-path
  QualifiedNameIndex FIRST (which already carries Outer.Inner / Other.Inner keys from
  the structure phase), with progressive-prefix lookup for relative bases and
  refuse-on-tie, falling through to the existing simple-tail walk on miss — so
  unqualified bases and the single-candidate cross-file case are unchanged.

Registry-primary cpp.test.ts 278/278 (incl. worker-path: rawQualifiedName survives
worker serialization). Legacy leg unaffected (207 pass / 71 skip) — the new
resolution-side assertions are registry-primary-only via helpers.ts. tsc clean.

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

* fix(ingestion): resolve same-tail Ruby mixin/attr_accessor owners to the correct qualified node (#1982)

emitRubyMixinEdges keyed its owner map by the SIMPLE tail (def.qualifiedName
split-popped) with last-wins, and the __heritage__/__property__ markers carried
only the immediate owner name — so `module Outer; class Inner` and
`module Other; class Inner` collapsed onto one `Inner` key and cross-wired their
include/attr_accessor edges onto whichever Inner was processed last.

Fix (lockstep, full-qualified):
- ruby/captures.ts: build the marker owner from the FULL enclosing class/module
  chain (buildEnclosingQualifiedName walks all ancestors, normalizing the compact
  `class Outer::Inner` scope_resolution form via the shared splitQualifiedName) so
  the marker owner byte-matches the resolution def's qualifiedName.
- ruby/scope-resolver.ts: key graphIdByName by the full def.qualifiedName instead
  of the simple tail. Top-level owners/mixins are unchanged (full == simple).

Registry-primary ruby.test.ts 142/142 incl. a new worker-path block (the deferred
note's duplicate-edge concern: markers survive worker serialization, exactly one
HAS_PROPERTY per attr). Legacy leg unaffected (136 pass / 6 skip) — new assertions
registry-primary-only via helpers.ts. tsc clean.

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

* test(ingestion): rebaseline #1982 golden/fingerprint + lint/format sweep

Cross-cutting verification artifacts for the #1982 same-tail resolution fix:
- ruby capture golden regenerated: ONLY the ruby-nested-tail-collision fixture
  drifts (+10 capture groups from its new include/attr_accessor + the now
  full-qualified __heritage__/__property__ marker owner). All other ruby fixtures
  byte-identical (proves the owner-qualification is localized to nested owners).
- bench/scope-capture/baselines.json: rebaseline cpp + ruby fingerprints (the only
  two that drift; 12 other languages byte-identical). cpp = additive
  @reference.qualified-name capture; ruby = the localized owner change. Provenance
  notes record both. scaling linear (~1.0), 14/14 PASS.
- generic.ts: drop the now-unused normalizeQualifiedName import (lint error).
- walkers.ts / ruby.test.ts: prettier formatting.

Verified: cpp 278/278 + ruby 142/142 (registry-primary), both legacy legs clean
(skips registry-primary-only assertions), go/java/csharp 542 (cross-language
regression — the qualified-first branch is gated on rawQualifiedName, set only by
C++, so non-C++ inheritance resolution is unchanged). tsc + eslint(0 errors) + prettier clean.

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

* fix(ingestion): resolve nested Ruby mixin included by short name (#1982)

emitRubyMixinEdges keyed graphIdByName by the full def.qualifiedName on the
owner side, but the __heritage__ marker carries the mixin target as the bare
written name (arg.text). A nested mixin module included by its short name
(include Loggable where it is App::Loggable) missed the full-qn map and its
IMPLEMENTS edge was silently dropped (0 dangling, undetectable). The shipped
same-tail fixture used only top-level mixin modules, so CI stayed green.

Add a secondary simple-tail fallback map consulted only when the full-qn mixin
lookup misses; owner lookups stay full-qn so same-tail owner disambiguation is
preserved. Characterization test + fixture (registry-primary only); golden
regenerated additively.

Addresses PR #1981 review (4417182679) P1.

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

* fix(ingestion): normalize qualified Ruby mixin arg in heritage marker (#1982)

`include Outer::Mixin` embedded the raw `Outer::Mixin` into the ':'-delimited
__heritage__ marker, so the `::` collided with the field separator and
emitRubyMixinEdges mis-split it (className became empty), dropping the IMPLEMENTS
edge. Normalize the mixin arg via splitQualifiedName(...).join('.') before emit
so the marker carries the dotted form, which both parses correctly and matches
the mixin def's qualifiedName. Simple names are unchanged (no golden drift).

Addresses PR #1981 review (4417182679) secondary R2.

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

* fix(ingestion): resolve C++ same-tail nested heritage inside a namespace (#1982)

A namespace-nested C++ type's scope-model qualifiedName carried its enclosing
CLASS chain (A.Inner) but dropped the enclosing NAMESPACE, while the
structure-phase graph node is keyed by the full path (NS.A.Inner). resolveDefGraphId's
qualifiedKey therefore missed and fell back to simpleKey('Inner'), collapsing
same-tail nested bases across sibling namespace members — DB : B::Inner pointed
at NS.A.Inner. The shipped fixture was top-level only, so it could not catch this.

Fix without disturbing the qualifiedName-keyed resolution index (an earlier
attempt that rewrote qualifiedName regressed brace-init / UDC / two-phase
namespace resolution): tagNamespacePrefixes records each namespace-nested def's
enclosing-namespace prefix on a sidecar field, and resolveDefGraphId retries the
node lookup with the namespace-prefixed key before the simpleKey fallback. The
helper is language-agnostic (acts only on Namespace scopes) and opt-in — only the
C++ provider calls it. Namespaced fixture + sequential & worker tests
(registry-primary only). All 280 cpp resolver tests pass; tsc clean.

Addresses PR #1981 review (4417182679) P2.

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

* test(ingestion): worker-path parity for Ruby mixin IMPLEMENTS + C++ DerivedA (#1982)

The Ruby worker-path parity block asserted only attr_accessor (HAS_PROPERTY);
add an IMPLEMENTS assertion so a dropped/cross-wired mixin owner on the worker
path is caught (the __heritage__ marker owner must survive serialization). The
C++ worker heritage block asserted only DerivedB; add a DerivedA assertion with
a toHaveLength(1) duplicate guard. Registry-primary only.

Addresses PR #1981 review (4417182679) test-coverage gap.

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

* fix(ingestion): distinct Rust same-tail nested-mod inherent-impl ownership (#1982)

Rust methods live in `impl Inner` blocks, and findEnclosingClassInfo keyed the
inherent-impl owner by the target's RAW tail (`Impl:lib.rs:Inner`), so two
same-tail `impl Inner` blocks under different mods (mod outer / mod other)
collapsed onto ONE Impl node and their methods cross-wired. The shipped fixture
test for this was skipped/deferred.

Qualify an UNSCOPED inherent-impl target by its enclosing `mod_item` scope
(`outer.Inner`) in BOTH the owner walk (ast-helpers.qualifyRustImplTargetByModScope)
and the Impl-node materialization (parsing-processor + parse-worker, lockstep) so
the owner edge and node id agree byte-for-byte. Gated on the Impl label +
impl_item + an unscoped type_identifier target — Rust-impl-exclusive, so C++/Ruby
and the rust captures golden are untouched; a SCOPED `impl a::Inner` keeps its
full raw text (#1975, unchanged). The previously-skipped distinct-ownership test
is now active and passing; rust 170/170, cpp+ruby+golden 437/437, tsc clean.

Done in-PR at maintainer request (was deferred as a follow-up). Addresses PR #1981
review (4417182679) test-coverage gap R7.

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

* refactor(ingestion): single qualified-name normalizer + module-scoped Ruby PROPERTY_PREFIX (#1982)

Replace cpp/captures.ts's parallel normalizeCppNamespaceQName with the shared
normalizeQualifiedName (behaviorally equivalent for C++ qualified-identifier
inputs: '::'->'.' with leading/trailing-:: handling; no interior whitespace
reaches it). Promote Ruby's PROPERTY_PREFIX to module scope alongside
HERITAGE_PREFIX (was function-local — asymmetric with no behavioral effect).
Maintainability only; cpp+ruby resolver suites 428/428, tsc clean.

Addresses PR #1981 review (4417182679) maintainability item.

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

* perf+fix(ingestion): single enclosing-class walk + root-anchored base guard (#1982)

U7 (perf): preEmitInheritanceEdges resolved the deriving class AND
resolveQualifiedInheritanceBase re-walked findEnclosingClassDef for the same
site. Resolve callerClass once and thread it into resolveInheritanceBaseInScope
-> resolveQualifiedInheritanceBase -> enclosingScopeSegments, so the enclosing
class is walked once per qualified site. Add a 'program' early-exit to
buildEnclosingQualifiedName (ruby/captures.ts). Behavior-preserving.

U8 (P3): a root-anchored C++ base ": ::A::Inner" names the GLOBAL type, but
resolveQualifiedInheritanceBase prepended the deriving class's enclosing
segments and could mis-bind to an enclosing-relative same-path type. Detect the
leading "::" on the raw qualifier and try only the root-anchored key.
Discriminating fixture + test (registry-primary only).

cpp+ruby+rust resolver suites 599/599; tsc clean. Addresses PR #1981 review
(4417182679) perf + P3 items.

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

* test(ingestion): rebaseline ruby+cpp scope-capture fingerprints for new #1982 fixtures

The four new fixtures (ruby-nested-mixin-shortname, ruby-qualified-mixin,
cpp-namespaced-collision, cpp-global-base-anchor) grow the lang-resolution
corpus, drifting the ruby and cpp order-independent capture fingerprints.
Verified purely additive: the ruby captures golden shows only the two new
fixtures added (existing byte-identical), and removing the two cpp fixtures
reverts the cpp fingerprint to the prior baseline (so the U3/U6/U8 code changes
are scope-resolution / behavior-preserving, not capture-emission). measure.mjs
--check PASS (14 languages).

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

* style(ingestion): prettier-wrap ruby resolver test call (#1982)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:23:17 +01:00
Gergő Magyar
5f0d690c60
fix(ingestion): materialize graph nodes for scoped class/module/impl declarations (#1975) (#1977)
* test(ingestion): failing target tests + graph-integrity helper for scoped-declaration nodes (U1, #1975)

Adds findDanglingEdges() and pipeline-level tests asserting that Ruby
namespaced class/module declarations materialize a Class/Trait node with
a resolving HAS_METHOD edge. Red by design on the pre-fix base (5 failing)
— the fix lands in U2 (shared core) + U3 (Ruby enablement).

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

* fix(ingestion): materialize graph nodes for Ruby namespaced class/module declarations (U2/U3, #1975)

Widen the Ruby legacy structure query so `class Foo::Bar` / `module Baz::Qux`
(name field is a scope_resolution node) match @definition.class/.module as
separate top-level patterns. The node is keyed by its full scoped name, which
matches the HAS_METHOD owner id that findEnclosingClassInfo derives from the
same name field — so the previously-dangling ownership edges now resolve, and
distinct namespaces (Foo::Bar vs Baz::Bar) stay distinct nodes (no collision).

No change to findEnclosingClassInfo (zero call-resolution blast radius) and no
scope-extractor/golden/bench impact — the fix is purely the legacy structure
query gate. Finalizes the U1 target assertions to the qualified-name identity.

Validated: 134/134 Ruby resolver tests pass on BOTH legs; tsc --noEmit clean;
dangling HAS_METHOD edges on the ruby-namespaced fixture drop from 3 to 0.

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

* fix(ingestion): resolve C++ out-of-line nested definition method ownership (U4, #1975)

For an out-of-line `struct Outer::Inner { ... }`, the container name is a
qualified_identifier, so findEnclosingClassInfo derived the owner id from the
full `Outer::Inner` text — but the type is keyed by its in-class declaration
(the nested `Inner` node), leaving the method's HAS_METHOD edge dangling.

Reduce a qualified_identifier container name to its tail segment for the owner
id/name, matching how inline nested definitions are already keyed. Node-type
scoped, so Ruby's scope_resolution names stay full (distinct-by-namespace) and
no language is named in shared code. Only out-of-line-def methods (already
dangling) change behavior — zero impact on bare classes or call resolution.

Validated: C++ 268/268 default leg, 205+63-skip legacy leg, no regression;
2 new target tests pass both legs; Ruby namespaced tests still pass; tsc clean;
scope-capture bench rebaselined (cpp +cpp-out-of-line-class fixture) — --check
PASS (13 langs). Dangling HAS_METHOD on the new fixture: 1 -> 0.

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

* fix(ingestion): resolve Rust scoped impl-target method ownership (U5, #1975)

`impl path::Type` and `impl Trait for path::Type` name the target with a
scoped_type_identifier. Two coordinated fixes:
- findEnclosingClassInfo: reduce a scoped_type_identifier impl target to its
  trailing type name (both the trait-impl `for` branch and the inherent
  branch), matching the type's own tail-keyed declaration.
- tree-sitter-queries: add a @definition.impl arm for scoped inherent impls so
  the Impl node is materialized (keyed by the same tail) instead of missing.

Together the trait-impl method owns through the real Struct node and the
inherent-impl method owns through a real Impl node — no dangling edges. Rust's
scoped_type_identifier has a name: field, so the tail extraction is exact.

Validated: Rust 163/163 on BOTH legs, no regression; new target test passes;
C++/Ruby suites unaffected; tsc clean; scope-capture bench rebaselined
(rust +rust-scoped-impl fixture) — --check PASS (13 langs). Dangling 1 -> 0.

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

* test(ingestion): cross-namespace collision test + regenerate ruby/rust captures goldens (U6, #1975)

- Add ruby-tail-collision fixture + test: Foo::Bar and Baz::Bar share the tail
  'Bar' but must stay two distinct Class nodes (locks the KTD-2 anti-collision
  guarantee from full-scoped-name keying). No dangling, no cross-wiring.
- Regenerate the ruby + rust captures goldens for the fixtures added in U3-U6
  (ruby-tail-collision, rust-scoped-impl). Both diffs are additive-only — a
  single new entry each, existing entries byte-identical (no capture-logic
  drift; the fixes are in the legacy structure query + findEnclosingClassInfo,
  not the scope-extractor).
- Re-baseline the ruby scope-capture fingerprint (81->82 fixtures).

N/A-language verification: C#/Java/PHP have no class-declaration scoped-name
gap and show no regression (606 passed; the 2 C# worker-pool failures are the
known worktree 'parse-worker.js not built' limitation, unrelated to this change).

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

* revert(ingestion): drop C++/Rust scoped-owner reduction; ship Ruby-only (#1975)

The self-tri-review of PR #1977 (review 4411683756) found — and reproduced —
that the C++/Rust tail-reduction in findEnclosingClassInfo collides same-tail
types declared in the same file (struct Outer::Inner + struct Other::Inner ->
one Struct:Inner node, methods silently mis-attributed; same-named members
merge). Root cause is pre-existing: GitNexus keys nested-type nodes by their
tail name within a file, so even plain inline same-tail nested types already
merge. A correct fix needs fully-qualified nested-type node identity — a broad
change deferred to #1978.

This reverts the C++ (qualified_identifier) and Rust (scoped_type_identifier
impl) owner reductions in ast-helpers.ts, the Rust @definition.impl scoped arm,
and the cpp/rust fixtures+tests+golden+bench entries. The Ruby fix is unaffected
(it keys the node by the full scoped text — no collision) and stays:
namespaced class/module node materialization + the cross-namespace collision test.

Validated Ruby-only: 136/136 both legs; ruby+rust captures goldens 19/19;
bench --check PASS (14 langs); tsc clean.

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

* fix(ingestion): collision-safe C++/Rust scoped-declaration node ownership (#1975)

Re-introduces the C++/Rust fix the tri-review reverted, using a collision-safe
approach instead of owner tail-reduction (which merged same-tail types in one
file). Key the scoped DECLARATION's node by its full qualified text so it
matches the owner id and stays distinct from a same-tail type elsewhere:

- C++: widen the legacy structure query to materialize a node for out-of-line
  defs (class/struct Outer::Inner — name is qualified_identifier), keyed by the
  full text. No findEnclosingClassInfo change needed — BASE already derives the
  full-text owner, which now matches. Outer::Inner and Other::Inner stay
  distinct; 3-level A::B::C resolves. (A redundant forward-decl node remains.)
- Rust: @definition.impl arm for scoped inherent impls (keyed full) +
  findEnclosingClassInfo inherent-impl branch accepts scoped_type_identifier
  with full text. impl a::Inner and impl b::Inner stay distinct.

Collision-aware fixtures + positive owner-identity assertions (per the
tri-review) replace the single-type fixtures. Deferred to #1978: Rust trait
impls on a scoped struct path (impl T for a::Inner) and the pre-existing inline
same-tail node collision — both need qualified struct-node identity.

Validated: Ruby 136/136, C++/Rust 434/434 both legs (371+63-skip legacy);
ruby+rust captures goldens 19/19 (additive); bench --check PASS (14 langs);
tsc clean.

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

* chore(format): apply prettier to scoped-declaration changes (#1975)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:47:15 +01:00
Sparsh
6643afbcda
fix(ruby): scope-resolution namespaced class/module definitions — F62 (#1933) (#1972)
* fix(ruby): namespaced class/module definition captures — F62 (#1933)

* chore(bench): regenerate Ruby golden captures after F62 scope_resolution patterns

* fix(ruby): namespaced class/module definition captures — F62 (#1933)

* chore: remove unused imports from ruby-namespaced test

* chore: add comment about capture-only scope in ruby-namespaced test

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-02 13:20:01 +01:00
Gergő Magyar
0fc0211d26
fix(ingestion): migrate all languages' inheritance to scope-resolution on the worker path (#1951) (#1956) 2026-06-01 17:04:27 +01:00
Gergő Magyar
d1d2a64d0f
perf(ingestion): linearize scope-capture across all languages + Python import resolution (O(n²)→O(n)) (#1918)
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
* bench(python-scope): build-free measure harness + baseline fingerprint for emitPythonScopeCaptures

ce-optimize scaffolding for the python-scope-capture run. Mirrors the Go
scope-capture harness (#1848): imports the .ts hotpath via tsx, times
emitPythonScopeCaptures on a synthetic DAO source at 250/800 entities, and
pins an order-independent sha256 capture fingerprint over the whole
lang-resolution/python-* corpus + a fixed 20-entity DAO as the correctness gate.

Baseline (current code) is O(n^2): 250->800 entities (3.2x) -> 10.7x time
(1062->11343ms), scaling_ratio 3.34.

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

* optimize(python-scope-capture): thread captured nodes to kill O(n^2) findNodeAtRange re-walks

emitPythonScopeCaptures re-derived each tree-sitter match's AST node via
findNodeAtRange(tree.rootNode, ...) on every match, scanning all of root's named
children per call -> O(matches x rootChildren) ~ O(n^2). The same #1848 bug Go
had (fixed in eaf0a305), mirrored in Python's captures.ts.

Thread the query-captured SyntaxNode (c.node) through a parallel tag->node map
and use it directly for all three sites (import / @scope.function /
@declaration.function). The Python scope query captures the full
statement/definition node, so the captured node IS the one the old code
re-derived by range — no ancestor walk needed (simpler than Go's import case).

Output is byte-identical: an order-independent sha256 capture fingerprint over
all 188 lang-resolution/python-* fixtures + a 20-entity DAO is unchanged.
800 entities: 11343ms -> 319ms (35.5x); 250: 1063ms -> 95ms (11.2x);
scaling_ratio 3.34 -> 1.05 (quadratic -> linear). tsc clean; 291 python
scope-resolution + resolver tests pass.

Adds a golden capture-parity test (forward-drift guard across the python-*
corpus + DAO shape) and a non-gated O(n^2) regression tripwire (400-entity
source, 346ms vs a 10s budget).

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

* optimize(python-scope-capture): index Python import resolution to kill O(imports x files) scans

resolvePythonImportTarget's fallback path scanned the entire repo file set on
every unresolved/external dotted import — once in hasRepoCandidate (package gate)
and once in resolveAbsoluteFromFiles (suffix match) — giving O(imports x files)
~ O(n^2) in the resolution phase (audit follow-up to the capture-phase #1848
mirror).

Add a per-file-set index (byBasename buckets + .py dir-prefix set + normalized
path set), memoized on the allFilePaths Set via a WeakMap so it is built once per
run and reused across every import. The two O(files) scans become O(1)/O(bucket)
lookups. The shared buildSuffixIndex is deliberately NOT reused: it keeps only a
single path per suffix (longest wins) and cannot reproduce Python's exact
fewest-segments-then-lexicographic tie-break across all candidates (see the
import-target.ts:72 rationale) — so a purpose-built index is used instead.

Output is identical: a resolver-output fingerprint over 10,021 cases (exhaustive
branch matrix — tie-breaks, gating, collisions, windows paths — plus a 400-repo
deterministic fuzz) is byte-for-byte unchanged
(e6ec1a59...). Worst-case scaling (k imports x k files): 500/1000/2000/4000 went
25/62/231/899ms -> 1.2/2.9/6.7/10.7ms (84x at 4000, quadratic -> linear).

tsc clean; 303 python scope-resolution + resolver tests pass; adds a 10-case
parity guard pinning the tie-break / gating / collision semantics the index
must preserve.

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

* fix(python): land the import-index reuse on the registry-primary path (PR #1918 P1)

The PythonFileIndex WeakMap is keyed on allFilePaths Set identity, but
pythonScopeResolver.resolveImportTarget wrapped the orchestrator's stable
run-level set in `new Set(allFilePaths)` per import, handing a fresh key to
every import — so the index rebuilt on every import and the O(imports x files)
cost this index removed persisted on the production path (PR #1918 review P1).

Thread ReadonlySet<string> through the resolver chain (PythonResolveContext,
getPythonFileIndex, the WeakMap key, resolveAbsoluteFromFiles, hasRepoCandidate,
resolvePythonImportInternal, tryResolveWithExtensions — all read-only) and drop
the per-import copy so the stable set reaches the WeakMap key. Mirrors the C#
counterpart (csharp/import-target.ts), which already keys on ReadonlySet.

Guard it deterministically: an ungated index-build counter (index-stats.ts) +
a production-path integration test that drives pythonScopeResolver over 300
imports on a stable set and asserts the index is built ONCE (was 300 pre-fix).

tsc clean; resolver-output fingerprint unchanged (e6ec1a59); 369 python
scope-resolution + resolver tests pass.

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

* perf(python): index only .py files in the import-resolution index (PR #1918 P3b)

getPythonFileIndex pushed every workspace file into byBasename (and normSet),
but Python import resolution only ever queries .py paths — module <seg>.py,
package <seg>/__init__.py, and .py directory prefixes. Non-.py files (.ts, .go,
…) could never match any lookup, so they were pure dead weight in the index on
polyglot monorepos.

Skip non-.py files at the top of the index builder. dirPrefixes was already
.py-gated; this extends the same guard to byBasename and normSet (both also
.py-only consumers), so it is behavior-preserving. Resolver fingerprint
unchanged (e6ec1a59); adds a polyglot parity case proving .ts/.go siblings
never affect resolution.

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

* perf(python): parent-key the __init__ bucket to kill package-count skew (PR #1918 P2b)

The suffix fallback's package form looked up byBasename.get('__init__.py'),
which holds every __init__.py in the repo — so every multi-segment package
import (pkg.sub) iterated all N packages to find the one ending /sub/__init__.py.

Add byInitParent: __init__.py files keyed by their last two components
(<parentDir>/__init__.py). The package lookup now targets only same-named
package dirs (typically O(1)) and confirms the full suffix, so the final
candidate set and tie-break are unchanged. __init__.py files stay in byBasename
too, so the rarer explicit "pkg.__init__" import still resolves via the module
(<lastSeg>.py) lookup.

Resolver fingerprint unchanged (e6ec1a59); adds parity cases for a nested
package (same-parent noise filtered by the suffix confirm) and an explicit
pkg.__init__ import.

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

* fix(python): reproduce old startsWith gating for absolute paths + re-baseline (PR #1918 P3a)

getPythonFileIndex built dirPrefixes by split('/')+filter(Boolean), which drops
the leading empty component of an absolute path: "/repo/svc/x.py" yielded
{repo/, repo/svc/}. The old full-scan gate compared the whole normalized path,
where "/repo/svc/x.py".startsWith("repo/svc/") is false — so the index gate
PASSED where the old gate BLOCKED, an absolute-path-only divergence (production
paths are repo-relative, so this never fired in production).

Build dirPrefixes from every slash-terminated prefix of the full path instead
(including the leading "/" for absolute paths), so dirPrefixes.has(X) matches
exactly when the old f.startsWith(X) did. For repo-relative paths the prefix set
is identical, so production behavior is unchanged.

This is NOT cosmetic. Extending the fingerprint harness with absolute-path file
sets surfaced 12 fuzz cases (out of ~4000 new absolute cases) where the pre-fix
index resolved an import the old code left unresolved — e.g. `pkg.thing` over
{/repo/pkg/__init__.py, /repo/vendor/pkg/thing.py} from /repo/app/main.py
resolved to /repo/vendor/pkg/thing.py under the buggy gate but is null (old and
fixed). The fix removes those absolute-path false positives.

Re-baseline justification: the committed resolver fingerprint moves
e6ec1a59 -> d51ea9ed because the harness now adds ~4000 absolute-path cases
(branch matrix incl. the reviewer's exact case + a 200-repo absolute fuzz). The
relative-path subset is unchanged: the original 10,021-case relative corpus
still hashes to e6ec1a59 after the dirPrefixes fix (the fix only alters
absolute-path prefixes). The new baseline encodes the old-startsWith-equivalent
(correct) behavior, verified by diffing the fixed vs. pre-fix harness output.

Adds parity cases pinning the absolute false-positive (now null) and a
repo-relative control of the same shape (still resolves). tsc clean.

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

* test(python-bench): add --check mode + REPS=7 to the scope-capture harnesses (PR #1918 P2a)

The bench harnesses were dev-only — nothing compared the committed fingerprints
or guarded the scaling, so an O(n^2) regression (or a P1-style cache miss) could
land silently.

Add a --check mode to both:
- measure.mjs: assert the capture fingerprint == baseline-fingerprint.txt AND
  scaling_ratio < 1.5 (linear), exit non-zero on either. REPS bumped 3 -> 7 to
  stabilize the median on shared CI runners.
- import-target-fingerprint.mjs: assert the resolver fingerprint ==
  baseline-import-target-fingerprint.txt, exit non-zero on drift.

Without --check both still print JSON for dev use / deliberate re-baselining.
Verified: --check passes on the current tree (capture f2b4376f / scaling 1.04;
resolver d51ea9ed) and exits 1 with a clear message on a corrupted baseline.
Wired into CI by the dedicated benchmark job (next commit).

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

* ci(bench): add a dedicated benchmark job wiring in the gated cross-language suites

The cobol/csharp/rust/php/ruby *-pipeline-benchmark.test.ts suites are gated
behind GITNEXUS_BENCH, so the main coverage job skips them — their O(n^2)
scaling guards never actually ran in CI. Add a dedicated "benchmarks" job to the
Tests reusable workflow that runs them with GITNEXUS_BENCH=1, plus the Python
scope-capture and import-resolution fingerprint + scaling guards
(measure.mjs --check, import-target-fingerprint.mjs --check) from PR #1918.

Runs with --no-file-parallelism: the suites measure wall-clock and peak heap, so
parallel forks both skew the timings and OOM the worker pool (reproduced locally:
the parallel run crashes a worker; serial passes 5/5 in ~80s). The job is part of
the Tests workflow, so it gates the existing CI Gate required check.

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

* ci(bench): exclude go-pipeline-benchmark from the gated job (fork-pool instability)

Validation surfaced that go-pipeline-benchmark.test.ts's worker-pool (#1848)
suite spins a real worker pool that exits unexpectedly under vitest's fork pool,
crashing the run (1 of 3 tests, repeated). Including it would make the new
benchmark gate flaky. The other five language pipeline benchmarks
(cobol/csharp/rust/php/ruby) run clean serially (5/5, ~84s). Go is already
guarded by its non-gated O(n^2) tripwire (main coverage job) + golden parity
test, so coverage is preserved. Documented inline.

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

* ci(security): set persist-credentials false on all ci-tests checkouts (zizmor artipacked)

The new benchmarks job (and the pre-existing tests / cross-platform jobs) used
actions/checkout with the default persist-credentials, leaving the token in
.git/config. The tests job uploads a test-reports artifact, so that is the
literal credential-persistence-through-artifacts case zizmor's artipacked audit
flags; the others persist creds needlessly.

None of these jobs push — they run npm + vitest only — so persist-credentials:
false is safe (the packaged-install-smoke job already runs setup-gitnexus this
way). All four ci-tests.yml checkouts are now consistent.

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

* bench(scope-capture): unified build-free measure harness for all benchmarked languages

Adds a single tsx harness that measures emit<Lang>ScopeCaptures for every
language with a pipeline benchmark (go, csharp, rust, php, ruby, cobol):
per-language synthetic-DAO scaling (250/800 entities) + an order-independent
sha256 fingerprint over each <lang>-* fixture corpus, with a --check mode gating
both against baselines.json.

It immediately surfaced that csharp, rust, php and ruby still carry the
O(matches x rootChildren) findNodeAtRange(tree.rootNode,...) root-walk that was
fixed for go (#1915) and python (#1918): scaling ratios 3.13 / 3.31 / 3.04 /
3.07 (vs ~1.0 for the fixed go and cobol). They are flagged known_quadratic in
baselines.json so CI guards drift + worsening until each gets the threaded-node
fix (following commits).

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

* perf(ruby): linearize scope-capture (thread captured nodes + dedup set)

emitRubyScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match (import / scope.function / declaration.function /
heritage / attr / call-arity), and the constructor-return pass ran out.some(...)
once per method over the growing output array — two O(n^2) shapes (measured
scaling 3.07).

Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), and precompute the YARD-return
dedup keys into a Set. Output byte-identical (capture fingerprint over the
ruby-* fixture corpus + DAO unchanged); scaling 3.07 -> 1.11 (linear). 127 ruby
resolver tests pass; tsc clean.

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

* perf(php): linearize scope-capture (thread captured nodes)

emitPhpScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match (import / scope.function / declaration / call-arity),
giving O(matches x rootChildren) ~ O(n^2) (measured scaling 3.04).

Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), mirroring go #1915 / python
#1918. Output byte-identical (capture fingerprint over the php-* fixture corpus
+ DAO unchanged); scaling 3.04 -> 1.03 (linear). 205 php resolver tests pass;
tsc clean.

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

* perf(rust): linearize scope-capture (thread captured nodes)

emitRustScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match (import / scope.function / declaration / type-binding
return-hoist / call-arity), giving O(matches x rootChildren) ~ O(n^2) (measured
scaling 3.31 — the worst of the four).

Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), mirroring go #1915 / python
#1918. Output byte-identical (capture fingerprint over the rust-* fixture corpus
+ DAO unchanged, incl. the impl-block return-type hoist path); scaling
3.31 -> 1.05 (linear). Rust resolver tests pass; tsc clean.

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

* perf(csharp): linearize scope-capture (thread captured nodes)

emitCsharpScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match at 7 sites (import / read.member / scope.function /
declaration / call-arity / primary-constructor class+record), giving
O(matches x rootChildren) ~ O(n^2) (measured scaling 3.13).

Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), mirroring go #1915 / python
#1918. Output byte-identical (capture fingerprint over the csharp-* fixture
corpus + DAO unchanged); scaling 3.13 -> 0.99 (linear). C# resolver tests pass;
tsc clean.

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

* ci(bench): tighten scope-capture budgets to linear + gate all 6 languages in CI

All six benchmarked languages now thread the captured node, so update
baselines.json: drop known_quadratic and set scaling_budget 1.5 (linear) for
csharp/rust/php/ruby (go/cobol already linear). Fingerprints are unchanged —
every fix was byte-identical.

Wire the unified build-free guard into the benchmarks job:
'node --import tsx bench/scope-capture/measure.mjs --check' asserts the capture
fingerprint and linear scaling for go/csharp/rust/php/ruby/cobol on every run.
Build-free (no worker pool), so unlike the go pipeline benchmark it is stable in
CI. measure --check passes locally for all six (scaling 0.86-1.10).

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

* refactor(ingestion): address PR #1918 tri-review — shared nodeIfType, duck-typed guard, docs

Tri-review follow-ups (no behavior change — all capture fingerprints + the
resolver fingerprint are byte-identical, verified via the bench --check gates):

- maintainability (M1): extract the `nodeIfType` helper (copy-pasted into 4
  captures.ts files) to ast-helpers.ts as a generic `nodeIfType<T extends
  SyntaxNode>`. csharp/php keep their local SyntaxNode aliases (used elsewhere);
  the generic signature accepts them.
- P2 (latent): duck-type the `resolvePythonImportTarget` shape-guard instead of
  `instanceof Set`. The context type was widened to ReadonlySet<string>; an
  `instanceof Set` check would reject a legitimate non-Set ReadonlySet and
  silently drop all Python import edges. Now checks `.has` + `[Symbol.iterator]`.
- P3 (ruby dedup): document the snapshot-vs-live `out.some`→Set behavior — the
  one narrow corner (two same-named methods one row apart, both ending in
  Const.new) where output differs from the pre-PR code, and why the new
  behavior (emit both) is intended.
- harness cross-ref: note in python-scope/measure.mjs that Python's capture
  scaling is guarded there (not the unified scope-capture harness) so neither
  is removed assuming the other covers Python.

tsc clean; scope-capture --check passes (6 languages, unchanged + linear);
resolver fingerprint unchanged; 300 python/ruby/rust tests pass.

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

* test(ingestion): golden + O(n^2) tripwire tests for ruby/rust/php/csharp scope-capture

Addresses the PR #1918 tri-review test-gap consensus (testing + adversarial +
maintainability): the four newly-linearized languages had no committed
correctness/scaling lock in the standard unit-test job — only the
bench/scope-capture/measure.mjs --check fingerprint, which runs in the separate
benchmarks CI job.

Per language, mirroring the existing go/python tests:
- test/unit/scope-resolution/<lang>/<lang>-captures-golden.test.ts — ORDER-
  SENSITIVE golden (modeled on go-captures-golden.test.ts; catches emission
  reordering the order-independent bench fingerprint misses) over the whole
  lang-resolution/<lang>-* corpus + a 20-entity synthetic DAO, with UPDATE_GOLDEN
  regeneration. Runs in the normal unit-test job (fast-fail).
- test/integration/<lang>-scope-capture-tripwire.test.ts — non-gated O(n^2)
  regression tripwire (400-entity source, <10s budget), like python's.

The ruby golden also pins the snapshot-dedup behavior (two same-named methods
both ending in Const.new emit BOTH @type-binding.return bindings — PR #1918 P3),
and the rust golden exercises the impl-block return-type hoist path.

41 tests pass; tsc clean. Goldens generated against the (byte-identical) current
output.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:44:22 +01:00