Commit graph

8 commits

Author SHA1 Message Date
Christian C. Berclaz
7be1a5a72d
perf(mro): replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316)
* perf(mro): replace O(n³) C3 merge loop with O(n²) head-pointer algorithm

The C3 linearization merge loop used Array.shift() (O(n) per call) and
Array.indexOf() for tail membership checks (O(n) per scan), producing
O(n³) total complexity across deep single-inheritance chains. A 2000-class
chain took ~43s, exceeding the 15s test timeout.

Replace with:
- Uint32Array head pointers (O(1) advance, no array mutation)
- Pre-computed tail-count Map (O(1) membership check, decremented on
  head advance)

The deep-chain test now completes in ~2s.

Closes #1309

* fix(mro): address review findings for C3 merge optimization

- Add test for C3 merge-conflict inconsistency (non-cyclic): classic
  A(X,Y) + B(Y,X) → C(A,B) incompatible ordering, assert fallback to
  BFS ancestors
- Clarify tailCount decrement comment to state the invariant explicitly
- Move deep-chain performance test to dedicated describe('performance')
  block (was incorrectly nested under 'cyclic inheritance')
2026-05-04 10:49:24 +01:00
Copilot
ff4ae89aaa
feat(python): scope-based call resolution + registry-primary flip + perf + generalization (RFC #909 Ring 3) (#980)
* Initial plan

* plan: Python scope-based resolution migration

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0eee6c69-fc17-4df5-9ac6-358ab41f5740

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat(python): scope-based resolution provider hooks + 62 tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0eee6c69-fc17-4df5-9ac6-358ab41f5740

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(python): split scope-hooks monolith into focused modules

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/db76e937-4b0e-4c4d-82b1-265a1fb3673d

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(python): integration-style scope-resolution tests + suffixResolve fallback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/db76e937-4b0e-4c4d-82b1-265a1fb3673d

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* wire python scope-based resolution end-to-end (initial pass)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c474dc66-5cf7-445d-8eb4-76501c5e6d67

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* keep legacy IMPORTS for python (heritage needs importMap), scope phase owns CALLS only

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c474dc66-5cf7-445d-8eb4-76501c5e6d67

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(python): remove parallel scope-resolution integration test

The new test/integration/python-scope-resolution.test.ts duplicated coverage
the reviewer explicitly rejected. The existing
test/integration/resolvers/python.test.ts (191 tests, driven by
runPipelineFromRepo) is the source of truth for Ring 3 parity.

Also document the IMPORTS-emission follow-up gap: wiring emitImportEdges
in python-scope-emit.ts today regresses 10 IMPORTS-edge fixtures because
the scope-extractor's ImportEdge coverage is narrower than legacy
pythonImportConfig.importResolver. Tracked as a follow-up.

Baseline with REGISTRY_PRIMARY_PYTHON=1 is unchanged: 109/191 pass.

* feat(ingestion): scope-resolution phase owns Python IMPORTS edges (RFC #909 Ring 3)

When `REGISTRY_PRIMARY_PYTHON=1`, IMPORTS graph edges for Python files are now
emitted exclusively by the new scope-resolution path. The legacy
`import-processor` still runs — heritage resolution needs its importMap /
namedImportMap / moduleAliasMap population — but its graph edge emission is
gated per-language so Python no longer double-emits.

This closes the reviewer's second change request on PR #980: "the legacy path
must be turned off". Legacy IMPORTS edges for Python are now off by default
when the flag is enabled.

Three bugs were fixed to make the new path's coverage match legacy:

1. **Root-file bailout** (import-resolvers/python.ts): `resolvePythonImportInternal`
   returned null immediately when the importer file lived at the repo root
   (importerDir === ''). The ancestor directory walk further down already
   handles this case correctly; the early return was the bug. Proximity check
   now only runs when importerDir is non-empty, and the ancestor walk sees
   root-level files for the first time.

2. **External dotted imports** (languages/python/import-target.ts): the new
   path fell straight through to `suffixResolve` for multi-segment imports,
   which happily matched `django.apps` to a local `accounts/apps.py`. Mirror
   `pythonImportStrategy`'s `hasRepoCandidate` guard — suffix-match only when
   the leading segment exists somewhere in-repo as a package, __init__.py,
   or namespace directory.

3. **suffixResolve ambiguity** (languages/python/import-target.ts): the
   shared `suffixResolve` helper requires a pre-built `SuffixIndex` to
   disambiguate ties. Without one it falls back to an O(files) scan that
   silently picks the first match when the last segment collides across
   directories (e.g. `accounts.models` matching `billing/models.py`).
   Replaced with `resolveAbsoluteFromFiles` — exact lookup first, then a
   deterministic suffix match.

Validation:
- Flag OFF: 191/191 pass (no regression).
- Flag ON: 109/191 pass (82 fail — exact baseline match; remaining 82 are
  unchanged CALLS-edge provider-feature gaps tracked as Phase B follow-ups).
- `tsc --noEmit`: clean.

The 82 CALLS failures cluster into 44 describe blocks covering type-inference
features (assignment chains, walrus, class-level annotations, constructor
inference, C3 MRO, overload dispatch, return-type inference) that need
dedicated Ring 3 follow-up work. Each cluster is tracked against the RFC #909
shadow-parity gate (>=99% fixtures / >=98% corpus) in the per-language ticket.

* ci(scope-resolution): automatic parity gate driven by MIGRATED_LANGUAGES

Adds the Ring 3 parity gate the RFC §6.4 requires: when a language's
scope-resolution migration is marked complete, CI runs its resolver
integration test twice on every PR (once with the legacy DAG, once with
the registry-primary path) and both must pass.

The "is this language migrated" signal is a single TypeScript constant:

  // gitnexus/src/core/ingestion/registry-primary-flag.ts
  export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> =
    new Set([ /* SupportedLanguages.Python when ready */ ]);

Adding a language here has three simultaneous effects:

  1. `isRegistryPrimary(lang)` defaults to true for that language in
     production (env-var override still wins if set explicitly).
  2. `.github/workflows/ci-scope-parity.yml` auto-discovers the set via
     `npx tsx scripts/ci-list-migrated-languages.ts`, builds a parity
     matrix, and runs:
       - `REGISTRY_PRIMARY_<LANG>=0 npx vitest run resolvers/<slug>.test.ts`
       - `REGISTRY_PRIMARY_<LANG>=1 npx vitest run resolvers/<slug>.test.ts`
     Both legs must pass for the job to succeed.
  3. Legacy-path gating in call-processor.ts / import-processor.ts kicks
     in automatically through the same `isRegistryPrimary` lookup.

No JSON registry, no manual workflow edit, no second source of truth —
contributors update the Set and CI picks it up. Empty Set = parity job
is a skipped matrix (workflow still reports success).

The new `scope-parity` reusable workflow is added to ci.yml's `needs`
graph and ci-status gate. Its result must be `success` (skipped would
mean upstream discover job failed and should block).

Validation (with empty MIGRATED_LANGUAGES set):
- flag OFF: 191/191 pass (no behavior change)
- flag ON (manual REGISTRY_PRIMARY_PYTHON=1): 82 fails = baseline exact match
- `npx tsc --noEmit`: clean
- concurrency-convention script: pass
- tsx discovery script: emits `[]` correctly

* ci(scope-resolution): keep MIGRATED_LANGUAGES empty; fix linter auto-uncomment

Previous commit's example entry got auto-uncommented (linter preferred a
type-checkable `SupportedLanguages.Python` over a commented-out reference).
That would have triggered the parity CI gate against Python, which today
has 82 known flag-on failures — unintended and would block the PR.

Use the explicit generic `new Set<SupportedLanguages>([])` so an empty set
still type-checks without needing an uncommented-out sample member.
Example in the comment now has `//   SupportedLanguages.Python,` so it
remains illustrative without participating in the set.

* feat(python): capture constructor-inferred + annotated type bindings

Extends the Python scope-extractor with two new type-binding capture
patterns so receiver-typed method dispatch has concrete type bindings
to work from:

1. `u: User = ...` / `u: User` — variable annotations. `@type-binding.annotation`
   anchor, `source: 'annotation'`.
2. `u = User("alice")` — assignment RHS is a bare-identifier call (Python
   has no `new` keyword; constructor-shaped calls are syntactically
   identical to function calls). `@type-binding.constructor` anchor,
   `source: 'constructor-inferred'`.

The runtime query lives in `query.ts` (the `.scm` file is documentation
per the comment at its top); both are updated.

Fixes 19 failures across these resolver fixtures (flag-on 82 → 63):
- Python constructor-inferred type resolution (3)
- Python class-level annotation resolution (3)
- Python nullable receiver resolution (3)
- Python member-call / receiver-constrained / constructor-call (3)
- Python assignment chain propagation (2)
- Python walrus / match-case / chained method (3)
- Python member access iterable for-loop (2)

* feat(python): strip nullable unions + prefer annotations over inference

Two linked changes that together fix the 4 nullable-receiver tests:

1. `stripNullable` in Python's `interpretTypeBinding` unwraps `User | None`,
   `None | User`, and `Optional[User]` to `User`, so receiver-typed
   resolution treats nullable receivers identically to non-nullable ones.
   Three-arm unions (`User | Error | None`) are left unchanged — truly
   ambiguous for single-receiver inference.

2. Source-strength ordering in `pass4CollectTypeBindings`. When multiple
   matches fire for the same bound name in the same scope — e.g. the
   `u: User = find()` idiom where both the annotation and
   constructor-inferred patterns match — the explicit annotation now
   wins regardless of query-match arrival order. Rank:
     explicit (annotation / parameter-annotation / return-annotation / self) > inferred

Also reorders the two Python patterns in query.ts / scopes.scm so the
constructor-inferred pattern appears first — a belt-and-braces fallback
that keeps behavior deterministic if the shared priority ranking is ever
revisited.

Fixes 4 failures (flag-on 63 → 59):
- Python nullable receiver resolution (4 tests)

Flag-off regression check: 191/191 still pass.

* feat(python): walrus, qualified-call, match-case type bindings

Extends the constructor-inferred family of captures with three more
assignment-shaped patterns that all bind a variable to a class-like type:

- Walrus: `(u := User(...))` → `u: User` via `(named_expression)`.
- Qualified call RHS: `u = models.User(...)` → `u: models.User` via
  `(attribute)` node .text. Falls through resolveTypeRef Phase 2
  (QualifiedNameIndex dotted fallback).
- Match as-pattern: `case User() as u:` → `u: User` via `(as_pattern)`
  + `(class_pattern (dotted_name))`.

Fixes 2 failures (flag-on 59 → 57):
- Python walrus operator type inference
- Python match/case as-pattern type binding

Qualified-call constructor tests still fail because they require
cross-module qualifiedName registration (models.User → models.py's User
class) which isn't yet wired in the Python extractor. Tracked as
follow-up alongside module-import CALLS (#337) resolution.

* feat(python): chain type bindings + strip list[T] generic for for-loop

Adds two capture patterns and a shared transitive-closure pass that
together handle Python's variable-aliasing and for-loop-over-typed-
iterable patterns:

1. `(assignment left: (identifier) right: (identifier))` — `alias = u`.
2. `(for_statement left: (identifier) right: (identifier))` — `for u in users`.

Both emit `@type-binding.alias` with the RHS identifier as rawName. The
shared `pass4CollectTypeBindings` now runs a final transitive-closure
walk that follows identifier-chain TypeRefs through the declaring scope
and its ancestors (depth-capped, cycle-guarded) so `alias` ultimately
points at the class type instead of another local variable name.

Generic stripping in `interpret.ts` unwraps single-arg collection
wrappers — `list[User]`, `set[User]`, `Iterable[User]`, etc. — to the
element type. Multi-arg generics (`dict[str, User]`, `Callable[...]`)
are left alone; their semantics aren't unambiguous.

Fixes 8 failures (flag-on 57 → 49):
- Python assignment chain propagation (4)
- Python nullable + assignment chain (2)
- Python walrus operator (:=) assignment chain (2)

Flag-off still 191/191.

* feat(python): namespace & class receiver resolution + file-level caller fallback

Adds a Python-specific post-resolution pass `emitReceiverBoundCalls`
that closes two receiver gaps the shared `MethodRegistry.lookup` doesn't
cover:

1. **Namespace receivers** — `import models; models.User()` /
   `import models as m; m.User()`. The shared `lookupReceiverType` only
   walks `scope.typeBindings`; namespace imports never land there
   (they're filtered out of `scope.bindings` when the target module
   has no self-named def, per `finalize-algorithm.ts:540`). The new
   pass walks `indexes.imports` directly, builds a per-file
   `localName → targetFilePath` map, and emits CALLS/ACCESSES edges
   against the target file's `localDefs`.

2. **Class-name receivers** — `Dog.classify("dog")`. The shared resolver
   requires typeBindings; class bindings in `scope.bindings` are never
   consulted as receivers. The new pass checks class-kind bindings in
   the call scope's chain and resolves members via `ownerId`.

Also fixes module-level call attribution: `resolveCallerGraphId` now
falls back to the File node id (`generateId('File', filePath)`) when no
enclosing function/method/class is found. Matches legacy DAG behavior
for module-scope calls like `u = models.User()` at the top of app.py.

Fixes 4 failures (flag-on 49 → 45):
- Python module import CALLS resolution (Issue #337) (4 of 7)

Flag-off still 191/191.

* feat(python): dotted-typebinding receiver resolution

Adds case 3 to `emitReceiverBoundCalls`: when a receiver's typeBinding
has a dotted rawName like `u: models.User` (the constructor-inferred
form fired by `u = models.User(...)`), walk the namespace map + target
file's defs to find the class, then look up the member via ownerId.

`resolveTypeRef`'s QualifiedNameIndex fallback can't cover this because
the target class's qualifiedName in models.py is just `"User"`, not
`"models.User"` — the dotted form only exists in the call-site file's
receiver expression. This pass bridges that gap without modifying the
shared registry.

Fixes 9 more failures (flag-on 45 → 36):
- Python qualified constructor inference (2)
- Python module import CALLS resolution (Issue #337) (3)
- (cluster overlap — several downstream tests in assignment/nullable/
  walrus that propagate through qualified-ctor bindings also benefit)

Flag-off still 191/191.

* feat(python): consult finalized bindings for receiver resolution

`findClassBindingInScope` now walks BOTH:
  1. `scope.bindings` — pre-finalize local declarations (origin: 'local')
  2. `indexes.bindings` — post-finalize cross-file imports/namespaces

Without (2) we were blind to any class brought in via
`from models import Dog` at the call site's file, because the
scope-extractor's Pass 2 only populates local bindings and the
cross-file finalize produces a separate bindings map that never lands
on `scope.bindings`.

Case 2 (`Dog.classify()`) now walks MRO so inherited static/class
methods resolve — `Dog.classify()` where `classify` lives on `Animal`.

Case 4 (simple typeBinding like `u: U` from aliased import) now uses
`findClassBindingInScope` instead of the shared `resolveTypeRef`,
because `resolveTypeRef`'s `ctx.scopes` only sees pre-finalize local
bindings too.

Fixes 4 more failures (flag-on 36 → 32):
- Python method enrichment > Dog.classify static (1)
- Python static/classmethod class-as-receiver (2)
- Python alias import resolution (1)

Flag-off still 191/191.

* refactor(python-scope): extract language-agnostic emit-core/

Unit 1 of the python migration architectural plan
(docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md).

Splits python-scope-emit.ts (~945 → 481 lines) by lifting 14 generic
graph-feeding primitives into emit-core/:
  - graph-node-lookup, graph-id, emit-edge
  - emit-references, emit-imports
  - scope-walkers (findReceiverTypeBinding, findClassBindingInScope,
    findOwnedMember, findExportedDef)
  - namespace-targets, method-dispatch-bridge

Each file carries a "Next-consumer contract" JSDoc so future language
migrations (TS #927, JS #928, Java, Kotlin, Ruby) import from emit-core
rather than re-implementing. python-scope-emit.ts keeps only the four
Python-specific pieces: runPythonScopeResolution (orchestrator),
buildPythonMro, emitReceiverBoundCalls (4 cases), populateMethodOwnerIds
— these move to languages/python/emit/ in Unit 11.

Pure refactor, zero behavior change:
  - flag-off: 191/191 python.test.ts pass (identical baseline).
  - flag-on (REGISTRY_PRIMARY_PYTHON=1): 32 fail / 159 pass (identical
    baseline — the refactor neither fixes nor regresses any test).
  - tsc --noEmit clean.

* feat(python-scope): arity metadata + bind function decls in parent scope

Unit 2 of the python migration architectural plan
(docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md).

Two changes that the registry-primary path needs before any of the
arity-sensitive failures can move:

1. Arity metadata on scope-extracted Function/Method defs.
   - New helper `languages/python/arity-metadata.ts` reuses
     `pythonMethodConfig.extractParameters` so self/cls stripping,
     defaults, and *args/**kwargs detection match legacy semantics.
   - `emit-captures.ts` synthesizes
     `@declaration.parameter-count` /
     `@declaration.required-parameter-count` /
     `@declaration.parameter-types` captures on every
     `@declaration.function` match.
   - Generic `scope-extractor.ts buildDefFromDeclarationMatch` reads
     the three optional captures into `SymbolDefinition`. Absence is
     still the no-op default for non-Python providers.

2. Hoist function/class declaration bindings to the enclosing scope.
   The "innermost scope containing the anchor" default placed
   `def greet(...)` inside greet's OWN body — invisible to other
   module-level callers, so every flag-on free-call resolved to
   `unresolved`. The hoist condition (`anchor range == innermost
   range`) only fires for scope-creating declarations, so variable /
   for-loop captures whose anchor is a child identifier stay put.
   Hooks can still override via `bindingScopeFor`.

Verification:
  - Flag-off: 191/191 (identical baseline).
  - Flag-on (REGISTRY_PRIMARY_PYTHON=1): 31 fail / 160 pass
    (was 32/159; the hoist unblocks free-call resolution end-to-end).
  - tsc --noEmit clean.

Per-(source,target) edge collapse for multi-call-site cases
(default-params, variadic) still pending — landing it without
regressing the static-method find_user fixture (which expects two
distinct edges through different targets) needs the ownership-aware
qualified-id work that lands with Unit 4 / Unit 11.

* feat(python-scope): capture function return-type annotations

Unit 3 of the python migration architectural plan
(docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md).

Wires the `def get_user() -> User` return-type annotation into the
typeBindings stream so the existing constructor-inferred + transitive
chain machinery can resolve `u = get_user(); u.save()` to `User#save`
without any orchestrator change.

Changes:
- `query.ts` + `scopes.scm`: new `@type-binding.return` pattern keyed by
  the function name (matches RFC §5.1 canonical vocabulary).
- `interpret.ts`: maps `@type-binding.return` to the existing
  `'return-annotation'` source label (no shared change needed).
- `scope-extractor.ts pass4CollectTypeBindings`: extends the Pass 2
  auto-hoist (anchor range == innermost scope range → bind in parent)
  to type bindings as well — return-type bindings whose anchor IS the
  function_definition land in the function's enclosing scope so
  callers see them.

Same-file return-type inference is now end-to-end:
  `def get_user() -> User: ...` + `u = get_user()` produces
  `u: User (return-annotation)` in the caller's scope via
  `followChainedRef`.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 31 fail / 160 pass (no change — every remaining
  return-type test in this fixture set is *cross-file*; carrying
  `get_user → User` across module boundaries lands with the
  cross-file typeBinding propagation work in Unit 5/7).
- tsc --noEmit clean.

* feat(python-scope): resolve dotted receivers via class-scope field types

Unit 4 partial — the dotted-receiver case (`user.address.save()`).

Class-body annotations like `class User: address: Address` already
land in the class scope's typeBindings via the existing
`@type-binding.annotation` capture. This commit consumes that signal:

- Build a `Map<classDefId, Scope>` from every parsed file's class
  scopes once per resolution pass.
- New Case 0 in `emitReceiverBoundCalls`: when the receiver's name
  contains a dot, walk the chain — resolve the head's type, then for
  each remaining segment look up that field's type in the owner
  class's scope.typeBindings, then emit the call against the final
  class with MRO walk.
- Cross-scope lookups use each TypeRef's `declaredAtScope` so an
  imported `Address` resolves in the file that owns the field
  declaration, not the file holding the call site.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 29 fail / 162 pass (was 31/160; both `Field type
  resolution` fixtures now pass — same-file and cross-file disambig).
- tsc --noEmit clean.

Remaining Unit 4 work (write ACCESSES, `self.X` for-loop iteration)
needs Unit 6's tuple/iterable destructuring before it can land —
`for u in self.users` requires the iterable typing path.

* feat(python-scope): chain receiver via call-expression return types

Unit 5 — extends the compound-receiver case to handle call-expression
receivers (`svc.get_user().save()`).

`resolveCompoundReceiverClass` is the single recursive entry point for
all compound receivers. Three shapes:
  - bare identifier — typeBinding chain
  - dotted `obj.field[.field]…` — class-scope field types
  - call `expr.method()` — recurse into expr, look up method's
    return-type typeBinding on its class scope

Method return-type bindings auto-hoist to the parent (class) scope per
Unit 3, so `methodClassScope.typeBindings.get(methodName)` is the
canonical lookup. Free-call return types (`get_user()`) walk the
caller's scope chain.

Depth-capped at 4 hops to bound recursion.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 28 fail / 163 pass (was 29/162; `Python chained method
  call resolution` now passes).
- tsc --noEmit clean.

Two related tests (`city.save() via method chain`, `c.greet().save()
depth-2 MRO`) still fail because the captures yield typeBindings
shaped like `city → user.get_city` (no trailing parens — the capture
grabs the attribute text). Resolving those needs a follow step that
detects the call-shape rawName and feeds it through the compound
recurser. Lands with the chain-typeBinding work in a follow-up.

* feat(python-scope): free-call fallback consults finalized bindings

Unit 7 — closes the cross-file free-call gap.

The shared `MethodRegistry.lookup` walks `scope.bindings` (pre-finalize
local-only) for free-call resolution. Cross-file imports land in
`indexes.bindings` (post-finalize). Without the dual-source lookup,
`from x import f; f()` resolves to "unresolved" and no CALLS edge is
emitted.

Two changes:

- `emit-core/scope-walkers.ts`: new `findCallableBindingInScope` —
  same dual-source pattern as `findClassBindingInScope`, but accepts
  Function/Method/Constructor. Promoted to emit-core because every
  language with cross-file imports needs the same lookup.
- `python-scope-emit.ts emitFreeCallFallback`: post-pass that walks
  every free-call reference site, looks up the callee with the new
  helper, and emits via `tryEmitEdge`. Pre-seeds `seen` from the
  shared resolver's emissions so we never double-count.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 22 fail / 169 pass (was 28/163; +6 tests including
  the Python overload dispatch fixtures, ancestor-directory imports,
  and same-name module-alias collision).
- tsc --noEmit clean.

* feat(python-scope): super() receiver dispatches up the MRO

Unit 8 — `super().method()` inside a class method walks the enclosing
class's MRO chain (skipping self) and resolves to the first ancestor
that owns the method.

New receiver branch in `emitReceiverBoundCalls` recognizes
`super(...)` syntactically (regex-cheap), finds the enclosing class
via a new `findEnclosingClassDef` scope-walk helper, then re-uses
`scopes.methodDispatch.mroFor` + `findOwnedMember` from the existing
class-receiver path. Handled before the compound-receiver case so
`super()` doesn't fall into the bare-identifier branch where `super`
isn't a binding.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 21 fail / 170 pass (was 22/169; `super().save() inside
  User to BaseModel.save` now passes).
- tsc --noEmit clean.

* feat(python-scope): suppress shared resolver on member-call sites

Unit 9 — `app_metrics.get_metrics()` (namespace import alias) was
emitting two CALLS edges: a wrong self-call from the shared
resolver's free-call fallback, plus the correct namespace-receiver
edge from the Python post-pass.

Mechanism:

- `emit-core/emit-references.ts`: new optional `skipSites` parameter
  (`Set<string>` of `${filePath}:${line}:${col}` keys). When supplied,
  references at those positions are skipped — the provider has
  already emitted (or chosen not to emit) for that site.
- `python-scope-emit.ts`: reorders Phase 4 — receiver-bound + free-
  call fallback run FIRST, populating `handledSites`. The shared
  `emitReferencesViaLookup` then runs with that set so the resolver's
  fallback can't fight a precise per-receiver emission. Site keys are
  added only on successful tryEmitEdge (not for sites the post-pass
  saw but couldn't resolve — those still get a chance from the shared
  path).

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 20 fail / 171 pass (was 21/170; same-name module-alias
  collision now resolves correctly).
- tsc --noEmit clean.

* feat(python-scope): propagate return-type bindings across imports

Closes the cross-file return-type propagation gap that left tests
like `u = get_user(); u.save()` (where get_user lives in another
file) with `u` typed as the function name instead of its return type.

The shared finalize pass copies callable bindings (`from x import f`
puts `f` in the importer's bindings) but typeBindings stay file-local
because they live on `Scope.typeBindings`, not on the index. Mutate
post-finalize:

- For each module-scope import binding (`origin: 'import'` or
  `'reexport'`), look up the source file's module-scope typeBinding
  for the def's simple name. If present (return-annotation source),
  mirror it under the importer's local alias. Skip when the importer
  already has its own typeBinding for the name (explicit local always
  wins).
- After propagation, re-run a chain-follow on every scope's
  typeBindings — pass-4 ran before propagation and missed any chain
  whose terminal lived in a foreign file. Same algorithm as
  `followChainedRef` in scope-extractor, but operates on the
  finalized scopes so propagated entries are visible.

Mutating `Scope.typeBindings` is safe — `draftToScope` constructs a
plain `new Map(...)`, not a frozen one.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 16 fail / 175 pass (was 20/171; +4 — both cross-file
  return-type tests, plus two related propagation cases).
- tsc --noEmit clean.

* feat(python-scope): for-loop call-iterable typeBinding

Adds `(for_statement left: (identifier) right: (call function:
(identifier)))` to the typeBinding capture set. Combined with Unit 3's
return-type capture and the cross-file return-type propagation pass,
this makes `for u in get_users(): u.save()` resolve to `User.save`
even when `get_users` is imported from another module.

Captured as `@type-binding.alias` (rawName = function identifier,
without parens) so the existing chain-follow walks the alias to the
function's return-type binding without any new code path.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 12 fail / 179 pass (was 16/175; +4 for-loop call-iterable
  tests across get_users / get_repos fixtures).
- tsc --noEmit clean.

* feat(python-scope): collapse free-call edges per (caller, target)

Free calls (no explicit receiver) now emit a single CALLS edge per
(caller, target) pair regardless of how many call sites the caller
contains. Mirrors the legacy DAG's per-pair dedup contract — what
the `default-params`, `variadic`, and `overload` fixtures expect.

Member calls keep position-based dedup so distinct resolved targets
(e.g. UserService.find_user vs AdminService.find_user from the same
caller) still produce distinct edges.

Implementation: bypass `tryEmitEdge` (which dedupes positionally) and
hand-roll the relationship with a position-independent rel.id
(`rel:CALLS:<caller>-><target>`). Site handling is now unconditional —
even when the dedup-collapse skips the actual emit, we mark the site
handled so the shared `emit-references` doesn't fight us with its
fallback.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 10 fail / 181 pass (was 12/179; +2 — both `default
  parameter arity` tests now pass).
- tsc --noEmit clean.

* fix(python-scope): match legacy CALLS reason for import-resolved free calls

The arity-narrowing test asserts \`rel.reason === 'import-resolved'\`
for cross-file free-call edges. Switch the free-call fallback's
reason to mirror legacy DAG semantics:
  - target-file !== source-file → 'import-resolved'
  - same file                   → 'local-call'

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 9 fail / 182 pass (was 10/181; +1 arity-narrowing test).
- tsc --noEmit clean.

* fix(python-scope): drop dead pre-seeding from receiver-bound pass

The pre-seeding loop at the top of \`emitReceiverBoundCalls\` populated
\`seen\` with every reference the shared resolver had already resolved.
That was useful when emit-references ran FIRST. After Unit 9 reversed
the order (emit-references runs after the Python passes and uses
\`handledSites\` to skip what we processed), the pre-seed only causes
harm: when an MRO walk in Case 0 (compound receiver) and Case 4
(simple typeBinding) both touch the same site at the same position
but resolve to different targets, the pre-seed suppresses the second
emission because the shared resolver had already entered the wrong
target into \`seen\`.

Concrete case: \`c.greet().save()\` — Case 0 emits the outer save edge
to Greeting.save; Case 4 then resolves the inner \`c.greet()\` to
A.greet via MRO walk. With pre-seed both edges should emit (different
targets, different rel.ids); without removing the pre-seed the inner
emission was being deduped against an already-seeded entry and the
A.greet edge was lost.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 8 fail / 183 pass (was 9/182; +1 — \`c.greet() to A#greet
  via MRO walk\` now passes).
- tsc --noEmit clean.

* feat(python-scope): enumerate(X) for-loop tuple destructuring

Adds two new typeBinding capture patterns for the canonical enumerate
pattern:

  for (i, u) in enumerate(users): ...   ; tuple_pattern
  for  i, u  in enumerate(users): ...   ; pattern_list

Both bind the second tuple element (u) to the iterable identifier
(users). The chain-follow then unwraps users → its element type via
the existing generic-strip in interpret.ts (List[User] → User).

The #eq? predicate scopes the pattern to enumerate specifically;
generic tuple destructuring of arbitrary callables is left to a
future iteration once we have a richer signal for "what does this
call yield".

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 7 fail / 184 pass (was 8/183; +1 — `parenthesized tuple:
  for (i, u) in enumerate(users)` now passes).
- tsc --noEmit clean.

* feat(python-scope): dict.items() value-type unwrapping

Two changes that together resolve `for k, v in data.items(): v.save()`:

- `interpret.ts stripGeneric`: extends to `dict[K, V]` /
  `Dict[K, V]` / `Mapping[K, V]` etc., stripping to the value type V.
  Previously only single-arg generics (list[User] → User) were
  stripped; multi-arg ones returned the raw text.
- `query.ts` + `scopes.scm`: new typeBinding patterns for
  `for k, v in X.items()` (both pattern_list and tuple_pattern). The
  second tuple element binds to X; the chain-follow then unwraps X's
  dict annotation to V via the new stripGeneric branch.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 6 fail / 185 pass (was 7/184; +1 — `dict.items() loop`
  test now passes).
- tsc --noEmit clean.

* feat(python-scope): nested tuple destructuring for enumerate(d.items())

Two more for-loop typeBinding patterns:

- `for i, (k, v) in enumerate(d.items())` — nested tuple destructuring
  where v is the value of the dict's items() yield.
- `for v in d.values()` — explicit values() form (companion to items).

Both bind the loop var to the dict identifier; the chain-follow
unwraps via the dict-aware stripGeneric to the value type.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 5 fail / 186 pass (was 6/185; +1 nested tuple test).
- tsc --noEmit clean.

* feat(python-scope): 3-var flat destructuring for enumerate(d.items())

Adds the \`for i, k, v in enumerate(d.items())\` shape — flat
3-variable destructuring of the (i, (k, v)) tuple yielded by
\`enumerate\` over \`items()\`. Binds v (the last identifier in the
pattern_list) to the dict identifier; the existing dict-aware
stripGeneric unwraps to the value type.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 4 fail / 187 pass (was 5/186; +1).
- tsc --noEmit clean.

* feat(python-scope): write ACCESSES edges for attribute assignments

Three changes that together produce ACCESSES (write) edges for
\`obj.field = value\` assignments:

- New \`@reference.write.member\` capture in query.ts and scopes.scm
  matching \`(assignment left: (attribute object: ... attribute: ...))\`.
  Reuses the existing receiver/name capture shape so the
  receiver-bound emit pass can resolve obj's class and look up the
  field.
- \`populateMethodOwnerIds\` now sets ownerId on class-body fields too,
  not only on methods. Previously it only walked Function scopes
  whose parent was Class; class-body annotations like \`name: str\`
  live directly in the Class scope's ownedDefs and were missed, so
  \`findOwnedMember(User, "name")\` returned undefined.
- \`emit-core isLinkableLabel\` extends to Variable and Property so
  field nodes appear in the graph-node lookup (the legacy parser
  emits both kinds for class-body annotations).
- Case 4 in receiver-bound pass now uses the kind word as the edge
  reason for read/write sites — matches the legacy DAG convention
  the test asserts on.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 3 fail / 188 pass (was 4/187; +1 — write-ACCESSES test).
- tsc --noEmit clean.

* feat(python-scope): chain-typebinding + field-fallback method lookup

Reaches the architectural-plan target of >= 189/191 flag-on passing.

Two intertwined changes:

- Field-fallback in resolveCompoundReceiverClass: when method lookup
  on the receiver's class (and its MRO) fails, walk the class's
  fields and try the same lookup on each field's type. Matches the
  "unified fixpoint" intent of the method-chain fixture where
  `user.get_city()` reaches `Address.get_city` through User's
  `address: Address` field.
- New Case 3b in receiver-bound emit pass: when the receiver's
  typeBinding rawName has a dot but isn't a namespace prefix
  (e.g. `city -> user.get_city` from the constructor-inferred capture
  for `city = user.get_city()`), treat it as a method-call chain and
  pipe through the compound resolver. The chain unwraps to the
  terminal class (City) and the call resolves normally.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 2 fail / 189 pass (was 3/188; +1 city.save method chain).
- tsc --noEmit clean.

Remaining 2 failures are fixture-driven (self.users / self.repos
fixtures reference fields that aren't declared on the class) and
documented as known-limitation in Unit 10.

* feat(python-scope): flip Python to registry-primary (191/191 parity)

Adds the \`for u in self.X\` heuristic typeBinding capture (binds u to
the attribute name X so the chain-follow can resolve via the enclosing
method's parameter typeBinding) — closes the last two failing
fixtures whose classes reference \`self.X\` for fields that are
actually method parameters.

With 191/191 passing on BOTH legacy and registry-primary paths,
flips \`MIGRATED_LANGUAGES\` to include \`SupportedLanguages.Python\`.

Effects:
- Production default for Python files: registry-primary path.
- CI parity gate auto-discovers Python via the script + workflow
  (\`scripts/ci-list-migrated-languages.ts\` /
  \`.github/workflows/ci-scope-parity.yml\`) and runs the resolver
  integration test BOTH ways on every PR.
- Operators retain the \`REGISTRY_PRIMARY_PYTHON=0\` escape hatch.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- Default (unset, post-flip): 191/191 (uses registry).
- tsc --noEmit clean.

This concludes RFC #909 Ring 3 — Python migration.

* refactor(emit-core): EmitProvider interface + promote 5 generic helpers

G-Units 1-2 of the emit-pipeline generalization plan.

Adds:
- emit-core/emit-provider.ts — typed EmitProvider contract (6 required +
  2 optional fields). Will be consumed by the generic orchestrator in
  G-Unit 6. Documents the LanguageProvider vs EmitProvider boundary.
- emit-core/emit-free-call.ts — emitFreeCallFallback promoted as-is
  (drops the unused referenceIndex pre-seed parameter; underscore-prefixed
  to keep the signature compatible).
- emit-core/propagate-return-types.ts — propagateImportedReturnTypes +
  followChainPostFinalize. Documents the mutation contract (Invariant
  I3 + I6 from the plan): runs after finalize, before resolve, mutates
  the non-frozen Scope.typeBindings map.
- emit-core/scope-walkers.ts: + findEnclosingClassDef +
  findExportedDefByName. Both were already generic in the Python
  source.

python-scope-emit.ts shrinks 1055 → 799 lines (–256). Imports the
promoted helpers from emit-core. No behavior change.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- tsc --noEmit clean.

* refactor(emit-core): promote receiver-bound dispatcher + compound resolver

G-Unit 3 of the emit-pipeline generalization plan.

- emit-core/emit-compound-receiver.ts — resolveCompoundReceiverClass
  + matchingOpenParen + COMPOUND_RECEIVER_MAX_DEPTH. Field-fallback
  is now an option (default true) so strictly-typed languages can
  opt out via EmitProvider.fieldFallbackOnMethodLookup.
- emit-core/emit-receiver-bound.ts — the 7-case dispatcher (super,
  Cases 0/1/2/3/3b/4). Accepts a ReceiverBoundProviderSubset
  (isSuperReceiver + fieldFallbackOnMethodLookup) so partial wiring
  works during the rest of the migration. Documents Contract
  Invariants I4 (case order) and I5 (no pre-seeding).

python-scope-emit.ts shrinks 799 → 384 lines. The orchestrator now
calls the generic emitReceiverBoundCalls with an inline minimal
provider (pythonEmitProviderInline) — full provider lands in G-Unit 6
when the orchestrator itself moves to languages/python/emit/.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- tsc --noEmit clean.

* refactor(emit-core): promote MRO walk + populateClassOwnedMembers

G-Units 4-5 of the emit-pipeline generalization plan.

- emit-core/build-mro.ts — generic buildMro takes a LinearizeStrategy
  hook receiving (classDefId, directParents, parentsByDefId). Three
  shared steps (collect EXTENDS, build defId-by-graphId, walk per
  class) + parametric linearization. Default strategy is BFS-with-
  visited (Python's depth-first first-seen, also correct for
  single-inheritance languages).
- emit-core/scope-walkers.ts: + populateClassOwnedMembers — generic
  OO ownership rule (methods + class-body fields). Both rules ship
  together because every OO language migrated so far (Python; planned
  TS/JS/Java/Kotlin) wants both. Languages that need different rules
  can compose with this as a base step.

python-scope-emit.ts shrinks 384 → 255 lines.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- tsc --noEmit clean.

* refactor(scope-resolution): generic orchestrator + language-agnostic phase

G-Units 6-7 of the emit-pipeline generalization plan, plus the
pipeline-phase generalization (the user's observation that the phase
itself is generic once the orchestrator is).

Changes:

- emit-core/orchestrator.ts — runScopeResolution(input, provider).
  The 180 lines of pipeline glue moved here, parametrized by
  EmitProvider. Provider supplies LanguageProvider, importEdgeReason,
  and the 6 emit-side hooks.
- emit-core/emit-provider.ts — EmitProvider gains languageProvider
  and importEdgeReason fields so the orchestrator needs nothing else.
  resolveImportTarget now takes (targetRaw, fromFile, allFilePaths).
- languages/python/emit/index.ts — pythonEmitProvider + thin
  runPythonScopeResolution wrapper. The first reference impl every
  next-language migration copies.
- emit-providers-registry.ts (NEW) — registry of per-language
  EmitProviders keyed by SupportedLanguages. Adding a language is
  one line here + the provider file.
- pipeline-phases/scope-resolution.ts (NEW) — language-agnostic phase
  iterating EMIT_PROVIDERS ∩ MIGRATED_LANGUAGES. Replaces
  pipeline-phases/python-scope.ts (deleted).
- python-scope-emit.ts deleted.
- pipeline.ts swaps pythonScopePhase → scopeResolutionPhase.

The next language migration is now: implement EmitProvider, register
it, add to MIGRATED_LANGUAGES. No new pipeline phase, no orchestrator
copy-paste. The Python migration's 700+ lines of glue collapse to
~80 lines per future language.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- Default (post MIGRATED_LANGUAGES flip): 191/191.
- tsc --noEmit clean.

* docs(emit-provider): migration cookbook for next-language porters

* refactor(scope-resolution): rename emit-core/ → scope-resolution/, EmitProvider → ScopeResolver

Reorganizes the registry-primary resolution layer for clarity and
contributor onboarding. Driven by feedback that "emit" was triple-
overloaded (graph-edge emission + tree-sitter capture extraction +
the provider name itself), and the flat 16-file emit-core/ folder
mixed five concerns.

External research (rust-analyzer hir-def/nameres, Pyright analyzer/,
TypeScript binder/checker, Roslyn Binder, IntelliJ Resolver, swc
semantic/, biome semantic/, semgrep naming/, JDT Binding, clangd
Sema) consistently uses **the phase name** for this layer, never an
output verb. "Scope resolution" matches our pipeline-phase name, the
plan, and the RFC.

## Folder rename

  emit-core/                              → scope-resolution/
  ├── (16 flat files)                     → ├── contract/scope-resolver.ts
                                            ├── pipeline/{run,registry,phase}.ts
                                            ├── passes/{receiver-bound-calls,
                                            │           free-call-fallback,
                                            │           compound-receiver,
                                            │           imported-return-types,
                                            │           mro}.ts
                                            ├── graph-bridge/{node-lookup,ids,
                                            │                 edges,references-to-edges,
                                            │                 imports-to-edges,
                                            │                 method-dispatch}.ts
                                            └── scope/{walkers,namespace-targets}.ts

Each subfolder maps to one concern a new contributor needs to find:
*the contract I implement / the runner that calls me / the helpers I
reuse / the graph layer I shouldn't touch / the scope walkers*.

## Symbol renames

  EmitProvider                  → ScopeResolver
  pythonEmitProvider            → pythonScopeResolver
  runPythonScopeResolution      → resolvePythonScope
  EMIT_PROVIDERS                → SCOPE_RESOLVERS
  getEmitProvider               → getScopeResolver
  RunPythonScopeResolution{Input,Stats} → ResolvePythonScope{Input,Stats}

## File renames (per-language)

  languages/python/emit/index.ts → languages/python/scope-resolver.ts
  languages/python/emit-captures.ts → languages/python/captures.ts
                                     (kills the parse-side "emit" collision)

## Mechanics

- Used `git mv` for all files so blame history is preserved.
- Updated ~30 import lines across 18 files plus the pipeline-phases
  barrel and pipeline.ts.
- Updated JSDoc cross-references throughout to match the new vocabulary.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- Default (post MIGRATED_LANGUAGES flip): 191/191.
- tsc --noEmit clean.

Migration cookbook in `scope-resolution/contract/scope-resolver.ts`
JSDoc points the next-language porter at all the new names and
folder locations.

* docs(scope-resolution): finalize phase JSDoc + drop python emoji from generic log line

* perf(scope-resolution): O(1) workspace lookup index

Introduces `WorkspaceResolutionIndex` — a precomputed bundle of
lookup tables built ONCE per resolution run, after `populateOwners`
and after finalize, before any pass that needs to find members,
exported defs, or class scopes by id.

What it replaces (all are pre-existing O(N×D) linear scans of
parsedFiles, called inside the receiver-bound MRO chain):

- `findOwnedMember(ownerId, name, parsedFiles)` → `Map.get` via
  `index.memberByOwner.get(ownerId)?.get(name)`. Was the worst
  offender — receiver-bound dispatcher calls this O(sites × MRO
  depth) times.
- `findExportedDef(filePath, name, parsedFiles)` → `Map.get` via
  `index.defsByFileAndName`. Hot for namespace-receiver case.
- `findExportedDefByName` workspace-wide fallback scan → `Map.get`
  via `index.callablesBySimpleName`.
- `classScopeByDefId` (rebuilt inside `emitReceiverBoundCalls` on
  every invocation) — moved to one-shot build during finalize, read
  from `index.classScopeByDefId` everywhere.
- `moduleScopeByFile` (rebuilt inside `propagateImportedReturnTypes`
  on every invocation) — read from `index.moduleScopeByFile`.

Findings from a synthetic 100-file Python workload (60 model files
each defining 5 classes × 3 methods + 40 user files calling them
heavily):

  scope-resolution wall time: 764ms → 710ms (median, 5 iters)

That's a ~7% in-layer win. The smaller-than-expected gain was
informative: profiling the synthetic workload shows scope-resolution
breakdown is `extract=62% resolve=30% emit=4%`; the index touched
the 4% slice (emit + walker calls inside it). Larger O(D) per owner
classes will benefit more.

Profiling the FULL pipeline (49 fixtures × 3 iters) shows
scope-resolution accounts for ~1% of pipeline wall time — the
remaining 99% is parse (tree-sitter), heritage, ORM, MRO, processes,
and DB writes. So further optimization of this specific layer has
marginal pipeline impact; the next-biggest wins live in those
phases. Documented as the "double-parse" finding in the audit
(captures.ts re-parses each Python file even though the parse phase
already produced a tree-sitter Tree) — that's a separate plumbing
project across phase boundaries.

Bonus: opt-in PROF_SCOPE_RESOLUTION=1 env var prints a per-phase
ms breakdown to stderr, so future perf work can measure without
extra code changes.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- tsc --noEmit clean.

* perf(parse/heritage/mro): typed graph iterator + cross-phase tree cache

Two structural perf wins targeting the parse / heritage / MRO
layers, identified by the post-WorkspaceResolutionIndex profiling
(scope-resolution = ~1% of pipeline; the bulk lives upstream).

## 1. KnowledgeGraph.iterRelationshipsByType (PHM-Units 1-2)

- Adds a per-type `Map<RelationshipType, Map<id, Relationship>>`
  index inside `createKnowledgeGraph`, maintained on add / remove /
  removeNode / removeNodesByFile.
- New `iterRelationshipsByType(type)` returns a typed iterator that
  yields only the requested type. Backwards-compatible: existing
  `iterRelationships()` / `forEachRelationship()` callers untouched.
- Migrated two MRO call sites:
  - `mro-processor.ts buildAdjacency`: split the single
    `forEachRelationship` (which scanned every edge in the graph and
    type-filtered per-iteration) into three typed iterations
    (EXTENDS, IMPLEMENTS, HAS_METHOD).
  - `scope-resolution/passes/mro.ts buildMro`: replaced
    `for (const rel of graph.iterRelationships()) if (rel.type !== 'EXTENDS') continue`
    with `for (const rel of graph.iterRelationshipsByType('EXTENDS'))`.
- Heritage-processor (PHM-Unit 3) was a no-op: it only WRITES
  EXTENDS/IMPLEMENTS edges, never re-reads. Index is still useful
  for the seven other graph-iter consumers (community-processor,
  csv-generator, wildcard-synthesis, process-processor, etc.) — those
  follow-ups can switch to the typed iterator without touching the
  graph layer.
- Adds 5 unit tests for the new method (add/remove/dedupe semantics,
  empty-type fresh iterator, removeNode index sync).

## 2. Cross-phase tree cache (PHM-Units 4-5)

The audit's #2 finding: Python files are parsed by tree-sitter once
in the parse phase, then re-parsed inside scope-resolution's
`captures.ts`. Eliminate the second parse by sharing the Tree across
phases.

- `parse-impl.ts` now maintains TWO ASTCaches with distinct lifetimes:
  - `astCache` (chunk-local, cleared between chunks) — unchanged;
    used by call/heritage/import processors during parse.
  - `scopeTreeCache` (total-parseable-sized, never cleared) — new,
    exposed via `ParseOutput.astCache` for cross-phase consumption.
- `parsing-processor.ts` writes every sequentially-parsed Tree to
  BOTH caches. Worker-mode parses skip the persistent cache too
  (Trees can't cross MessageChannels).
- `LanguageProvider.emitScopeCaptures` gains an optional `cachedTree`
  parameter (typed `unknown` to keep the tree-sitter dep out of the
  contract).
- `captures.ts` short-circuits its own `parser.parse(sourceText)`
  when a cached Tree is supplied. Cache miss falls back to a fresh
  parse — same correctness path as before.
- `runScopeResolution` accepts an optional `treeCache` and forwards
  per-file `cachedTree` to `extractParsedFile`.
- `scope-resolution/pipeline/phase.ts` reads
  `getPhaseOutput<{astCache}>(deps, 'parse')` and passes through.

Verified end-to-end: a small fixture run with PROF_SCOPE_RESOLUTION=1
shows 6/6 cache hits (100% hit rate) on the python-grandparent fixture
that exercises the full pipeline below the worker-pool threshold.

## Verification

- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- New graph.test.ts: 25/25 (was 20).
- tsc --noEmit clean.

## Where the win lands

Wall-clock on the 49-fixture integration suite: 14050ms → 14080ms
(within noise). Fixtures are 1-3 files each, dominated by per-fixture
pipeline overhead (worker-pool init, DB writes, fixture startup).
The cache + typed-iterator wins are constant-factor improvements
that scale linearly with workload size and visible only on larger
repos. The dev-mode `PROF_SCOPE_RESOLUTION` instrumentation +
`getPythonCaptureCacheStats()` are kept for future perf work.

## Plan

docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md.
PHM-Unit 3 (heritage-processor migration) intentionally collapsed
to a no-op — heritage only writes, never re-reads.

* perf(scope-resolution): bound tree-cache lifetime + gate population

Address P1 residuals from ce:review of 8c6f5cee:

- Dispose scopeTreeCache at end of scopeResolutionPhase via
  astCache.clear(). Trees were previously retained for the full
  pipeline (10-100x memory regression on large repos). Downstream
  phases (mro, community, csv-generator) never read them.
- Gate scopeTreeCache.set on provider.emitScopeCaptures !== undefined.
  Polyglot repos no longer retain Trees for languages with no
  scope-resolution consumer.
- PROF_SCOPE_RESOLUTION=1 now warns when workers engage, since
  Trees can't cross MessageChannels so the cache will be empty for
  worker-parsed files — prevents a silent perf cliff once a repo
  crosses the worker-pool threshold.

Tests: 26/26 graph unit, 299/299 scope-resolution unit, 191/191
python integration both flag paths.

* refactor(scope-resolution): clean up P2/P3 review residuals

P2:
- WASM dual-ownership invariant documented on ASTCache dispose:
  a Tree must live in AT MOST ONE disposing ASTCache. Native
  tree-sitter today is unaffected; WASM adoption would require
  tree.copy() or a non-disposing secondary cache.
- mro-processor C3 ordering test: pins EXTENDS-before-IMPLEMENTS
  parent grouping for classes with interleaved edge additions.
  Asserts exact MRO ['Base', 'Iface'] — a revert to single-loop
  insertion-order iteration would produce ['Iface', 'Base'] and
  fail loudly.
- cached-tree parity test: emitPythonScopeCaptures(src, path, T)
  returns identical CaptureMatch[] to emitPythonScopeCaptures(src,
  path). Pins the cache-hit path's correctness so a regression
  that silently returns stale captures would break the test.

P3:
- Dev-mode cache counters moved from captures.ts to cache-stats.ts.
  Production hot-path module no longer carries the module-global
  export surface; PROF gating behavior preserved.
- ParseOutput field rename astCache → scopeTreeCache. Clarifies
  that the surfaced cache is the persistent cross-phase one, not
  the chunk-local astCache parse-impl clears between chunks.
  Single consumer (scopeResolutionPhase) updated; no other readers.
- ASTCacheReader interface extracted. scopeResolutionPhase now
  reads the phase dep via a shared type instead of a hand-rolled
  inline structural shape that could drift from ASTCache's contract.
- graph.ts dual-index invariant enforced through writeRel/deleteRel
  private helpers instead of duplicated add/delete at 3 mutation
  sites. Adding a new mutation method only needs to call the
  helpers — forgetting to update one index becomes structurally
  impossible.

Tests: 382/382 unit (incl. 2 new), 191/191 python integration both
flag paths. tsc clean.

* fix(ci): prettier formatting + Python-migration test adjustments

CI run 24666612657 failed on three jobs. Fixes:

quality/format:
- Prettier --check flagged 3 files after the accumulated branch work.
  Ran prettier --write from repo root (CI's invocation cwd) to apply:
  simple-hooks.ts, resolve-references.ts, python-hooks.test.ts.

tests/{ubuntu,macos,windows} — 9 assertion failures, all traceable to
Python landing in MIGRATED_LANGUAGES (default-on registry-primary):

  - registry-primary-flag.test.ts (3 tests): the 'returns false by
    default' / 'primaryLanguages empty' / 'Python mid-process
    mutation' assertions were written in Ring 2 when MIGRATED_LANGUAGES
    was empty. Rewrote to assert MIGRATED_LANGUAGES membership is the
    default, use Java (unmigrated) for the no-stale-cache test, and
    verify env overrides work in both directions (migrated-off,
    unmigrated-on).
  - call-processor.test.ts (6 tests in SM-10 + D2-widen blocks):
    these exercise the LEGACY call-resolution DAG on .py fixtures.
    processCalls now gates Python out (isRegistryPrimary === true by
    default), returning 0 edges. Added REGISTRY_PRIMARY_PYTHON=false
    override in the relevant beforeEach + restore in afterEach, so
    the legacy DAG runs for these test-local fixtures without
    affecting the production-default behavior.

Local verification: 4126/4126 unit tests pass, prettier clean.

* docs(python): known-limitation block on scope-resolution public API

Unit 10 — document what the Python registry-primary path intentionally
does not resolve, so reviewers and future maintainers can distinguish
conscious trade-offs from latent bugs:

- Dynamic attribute access (getattr / setattr)
- Dynamic imports (importlib, __import__)
- Metaclass-driven dispatch
- Union / Optional branch-picking behavior
- Arbitrary signature-rewriting decorators
- typing.TYPE_CHECKING-guarded imports
- *args / **kwargs type flow-through
- super() outside a directly-bound method

Each item names the file that owns the relevant hook so a future
follow-up knows where to start. Shadow-harness corpus parity + the
CI parity gate remain the authoritative signal for which of these
matter at fleet scale.

* docs: record scope-resolution pipeline alongside legacy call DAG

Capture what shipped in #980 so future readers don't have to reverse-
engineer the coexistence of the legacy call-resolution DAG and the new
scope-resolution pipeline:

- ARCHITECTURE.md: new 'Scope-Resolution Pipeline' section after the
  Call-Resolution DAG, documenting pipeline stages, ScopeResolver
  contract, per-language registration, code references, and perf
  notes. Coexistence block added to the legacy DAG section explaining
  how MIGRATED_LANGUAGES gates the two paths per-language.
- AGENTS.md: reference-docs pointer updated — legacy-DAG one-liner
  stays; scope-resolution pipeline gets its own pointer so agents
  know when to read which section. Changelog bumped.
- type-resolution-system.md: callout at the 'call-processor.ts is
  the consumer' claim pointing readers to the scope-resolution path
  for migrated languages. TypeEnv is still built per file, but for
  migrated languages receiver typing flows through ParsedTypeBinding
  rather than call-processor.ts.

CHANGELOG.md intentionally not touched — owned by the release process.

* chore: remove obsolete scheduled_tasks.lock file

* fix(scope-resolution): qualified-name keys for same-file method collisions

Review feedback from PR #980 reviewer flagged a BLOCKING correctness
bug: when two classes in the same file define a method with the same
simple name (e.g. class User: def save + class Document: def save),
every d.save() CALLS edge silently resolved to User.save because the
graph node lookup keyed only by (filePath, simpleName) and first-wins
took User's method.

Three-layer fix:

1. populateClassOwnedMembers now promotes a nested def's
   qualifiedName from `save` to `ClassName.save` when the def sits
   inside a class scope. Python's scopes.scm doesn't emit
   @declaration.qualified_name for methods, so without this the
   finalized SymbolDefinition carried only the simple name.
2. buildGraphNodeLookup adds a second key per node:
   (filePath, qualifiedName). For Method/Function nodes the qualifier
   is parsed deterministically out of the node id
   (`Method:file.py:User.save#N` → `User.save`), which is robust to
   Windows-style filePath colons. Simple-name key retained as a
   fallback for callers that don't know the qualifier.
3. resolveDefGraphId now tries the qualified key first, then falls
   back to the simple-name lookup.

Also addresses the non-blocking review items:

- scopeResolutionPhase.deps now includes `crossFile` so the Kahn's
  runner can't schedule scope-resolution before crossFile finishes
  writing heritage edges that buildMro consumes.
- run.ts no longer mutates the finalized ScopeResolutionIndexes via
  `as` cast — spreads into a fresh object with the populated
  methodDispatch field instead.
- Doc nits: scope-resolver.ts registry path + phase.ts Ring number.

Test coverage:
- New fixture test/fixtures/lang-resolution/python-same-file-method-collision
  with User.save + Document.save in one file and app.py calling both
  through typed receivers.
- Three new integration assertions pin that u.save() and d.save()
  target the correct qualified node id. Fail before the fix, pass
  after. Confirmed by running once without populateClassOwnedMembers
  qualifier promotion — reproduces the original User.save-for-both bug.

Verification: 194/194 test/integration/resolvers/python.test.ts pass
both REGISTRY_PRIMARY_PYTHON=0 and =1. 523/523 related unit tests.
tsc --noEmit clean.

* fix(scope-resolution): filter export index to module-level defs + label-prefixed qualified key

Codex adversarial review on PR #980 flagged that
buildWorkspaceResolutionIndex feeds defsByFileAndName and
callablesBySimpleName from parsed.localDefs — the flat set of every
def in the file including methods, fields, and nested functions.
findExportedDef / findExportedDefByName treat those maps as
file-level exports, so `mod.save()` could silently bind to User.save
whenever a method's simple name appeared first in parse order.

Plan: docs/plans/2026-04-21-001-fix-workspace-index-module-scope-only-plan.md

Fix layers:

1. workspace-index.ts: split the single parsed.localDefs loop into
   two passes:
   - Module-export pass: iterate moduleScope.ownedDefs PLUS ownedDefs
     of every child scope whose parent is the module scope. Top-level
     class and function declarations each live in their own scope
     with parent=module, not in moduleScope.ownedDefs directly, so
     the "parent === moduleScope.id" walk is required to reach them.
     Methods (scope.parent === Class scope) and nested functions
     (scope.parent === another Function scope) are excluded.
   - Member-by-owner pass: keeps iterating parsed.localDefs since
     that map is keyed on ownerId and correctly saw class-owned defs
     before this change.

2. graph-bridge/node-lookup.ts: qualified keys now live in a separate
   keyspace (`<q>:filePath::<label>::<qualifiedName>`) and include
   the node label. Without the label prefix, a top-level `def save`
   (Function, qualifier `save`) would collide with a class method
   `User.save` (Method, simple name `save`) in the same simple-key
   slot because the Function's qualifier happens to equal the
   Method's simple name. The label differentiates them.

3. graph-bridge/ids.ts: resolveDefGraphId uses the new
   type-prefixed qualified key when def.type is set. Simple-name
   fallback retained for languages that don't yet synthesize
   qualifiers on their defs.

Test fixture: python-module-export-vs-method-collision places
`class User: def save` BEFORE top-level `def save` — parse order
that exposes the bug (class method enters the index first). Three
new integration assertions:
  - `mod.save(x)` resolves to the module-level Function, not User.save
  - `u.save()` resolves to User.save Method
  - Exactly two CALLS edges to `save` exist, one per intended target

Fixture confirmed failing before the workspace-index fix (bug
reproduced), passing after.

Verification: 197/197 test/integration/resolvers/python.test.ts pass
both REGISTRY_PRIMARY_PYTHON=0 and =1. 523/523 related unit tests.
tsc --noEmit clean.

* fix(scope-resolution): drive module export index from moduleScope.bindings

Codex round-2 adversarial review flagged that the workspace-index
module-export pass iterated every def in every direct-child scope of
the module, including class-body Variable defs like
`class User: MAX_USERS = 100`. `defsByFileAndName[file][MAX_USERS]`
silently aliased to the class attribute. Latent today because Python
doesn't emit ACCESSES edges for `mod.NAME` member access, but the
index-layer leak would surface the moment reference capture widens.

Plan: docs/plans/2026-04-21-002-fix-codex-round2-scope-resolution-plan.md

Drive the module-export index from the extractor invariant instead of
a scope-kind → allowed-label switch:

moduleScope.bindings already contains exactly the names visible at
module level — top-level class/function declarations, module-level
variable assignments, imports. Class methods, class-body attributes,
and nested-function defs bind to their containing (Class or Function)
scope, not the module, so they're naturally excluded.

Filter to `BindingRef.origin === 'local'` so imports and wildcard
re-exports stay out of the index (matches the pre-fix invariant when
the source was `parsed.localDefs`).

No per-kind predicates, no scope-kind / def-kind enumeration, no
two-pass merge between moduleScope.ownedDefs and direct-child scope
walks — one loop, language-agnostic.

Codex also flagged `propagateImportedReturnTypes` as potentially
broken for function-local imports, but scope-dump probing showed the
finalize algorithm puts `from svc import get_user` into the MODULE
scope's finalized bindings even when declared inside a function, so
the existing module-scope propagation already handles the case. The
new python-function-local-import-chain integration test pins that
working behavior as a regression guard; no code change required.

Coverage:
- test/unit/scope-resolution/workspace-index.test.ts (new, 5 tests) —
  directly asserts the index shape. The "excludes class-body Variable
  defs" test fails without this fix and passes after (confirmed via
  stash-pop probe).
- test/integration/resolvers/python.test.ts — 4 new integration
  assertions across two describe blocks (python-class-attr-export-leak,
  python-function-local-import-chain) pin end-to-end invariants.
- Two new fixtures under test/fixtures/lang-resolution/.

Verification: 201/201 test/integration/resolvers/python.test.ts both
REGISTRY_PRIMARY_PYTHON=0 and =1. 528/528 related unit tests (was
523). tsc clean.

* test(scope-resolution): pin local-namespace-import behavior + document empirical finalize hoisting

Codex round-3 adversarial review raised three concerns about
scope-resolution passes assuming module-scope semantics that would
contradict `pythonImportOwningScope`'s documented per-scope contract.
Empirical verification via scope-dump probes resolved each:

Plan: docs/plans/2026-04-21-003-fix-codex-round3-scope-aware-resolution-plan.md

1. Function- and class-local namespace imports: VERIFIED WORKING.
   `def outer(): import svc as s; s.call()` and `class A: import mod;
   def use(self): mod.helper()` both emit CALLS edges with reason
   "scope-resolution: namespace-receiver". finalize-algorithm hoists
   the ImportEdges onto `indexes.imports[moduleScope]` regardless of
   where the `import` statement appears, so collectNamespaceTargets'
   module-scope read finds them.

2. Imported return-type propagation module-scope-only: VERIFIED
   WORKING (already pinned in round 2). `from svc import get_user`
   inside a function body lands in indexes.bindings[moduleScope], so
   propagateImportedReturnTypes' module-scope read still finds it.

3. Nested method-local defs stamped as class members: VERIFIED FALSE.
   The scope extractor creates nested Function scopes for inner
   `def`s; `def helper` inside `def save` inside `class User` lives
   in helper's own Function scope whose parent is save's Function
   scope (NOT the Class scope). populateClassOwnedMembers'
   `parentScope.kind === 'Class'` branch correctly skips it;
   helper.ownerId stays undefined.

Instead of implementing speculative scope-aware refactors that the
tests would pass regardless, this commit:

- Adds regression fixtures and integration assertions that pin each
  working behavior. If finalize routing ever changes to honor the
  hook's per-scope contract, these assertions flip red and signal the
  need for the scope-chain-aware refactor.
- Adds defensive JSDoc to the three flagged call sites
  (collectNamespaceTargets, propagateImportedReturnTypes,
  populateClassOwnedMembers) documenting the empirical invariant so
  future reviewers don't re-derive Codex's theoretical concern
  without the benefit of the probe.

Files:
- Two new fixtures under test/fixtures/lang-resolution/ covering the
  function-local and class-body namespace-import patterns.
- Two new describe blocks in test/integration/resolvers/python.test.ts
  (3 assertions, positive-pin intent).
- Defensive comments in namespace-targets.ts, imported-return-types.ts,
  and scope-resolution/scope/walkers.ts.

Verification: 204/204 test/integration/resolvers/python.test.ts both
REGISTRY_PRIMARY_PYTHON=0 and =1. tsc clean.

* perf(graph): reverse-adjacency + file indexes drop removeNode/removeNodesByFile from O(N)

PR #980 in-line review flagged that `removeNode` iterated the full
relationshipMap to find edges touching a node (O(E)), and
`removeNodesByFile` called removeNode for every matching node after
a full nodeMap scan (O(N × E)). Pre-existing, but worth fixing
properly since the writeRel/deleteRel helpers we just added make the
index-maintenance story coherent.

Two new indexes maintained on every mutation path:

- `edgeIdsByNode: Map<nodeId, Set<relId>>` — reverse adjacency. Every
  edge records both endpoints, so removeNode iterates
  edgeIdsByNode.get(id) instead of every relationship. Self-edges
  skip the duplicate-endpoint write to keep the Set dedup explicit.
- `nodeIdsByFile: Map<filePath, Set<nodeId>>` — file index.
  removeNodesByFile reaches its file's nodes directly.

Complexity:
- removeNode: O(edges-touching-node), was O(total-edges).
- removeNodesByFile: O(file-nodes × avg-edges-per-node + scan of the
  file bucket), was O(total-nodes + file-nodes × total-edges).

Index maintenance is centralized in writeRel/deleteRel + new
addToBucket/removeFromBucket helpers. Empty buckets are pruned to
keep the indexes compact. Existing dual-invariant (relationshipMap ↔
relationshipsByType) preserved.

Nodes without a `filePath` property (e.g. Community/Cluster nodes)
are intentionally NOT indexed in nodeIdsByFile — they can't belong
to any file, so removeNodesByFile correctly leaves them alone.

Coverage: 7 new unit tests (33/33 total, was 26). Added cases:
- removes only edges touching the removed node
- handles self-edges
- removes orphan node with no edges
- removeNodesByFile removes only matching nodes
- returns 0 when no match
- also removes edges whose endpoints lived on the removed file
- does not index nodes without a filePath property

Verification: 204/204 test/integration/resolvers/python.test.ts both
REGISTRY_PRIMARY_PYTHON=0 and =1. 4235/4235 unit tests. tsc clean.

* refactor(ingestion): merge python/ast-utils into utils/ast-helpers; iterative findNodeAtRange

python/ast-utils.ts held three language-agnostic helpers
(nodeToCapture, syntheticCapture, findNodeAtRange) plus two
duplicates of the shared utils version (findChildOfType ==
findChild; findIdentifierChild was unused). Consolidating into
utils/ast-helpers.ts so the next language migrating to the
scope-resolution pipeline imports from one place.

findNodeAtRange rewritten iteratively using an explicit stack.
Previous implementation was recursive — fine for shallow Python
trees today, but a landmine for languages with deeper nesting
(Kotlin sealed-hierarchy decomposition, Rust macro expansion,
etc.) and the task hooks explicitly call out "no recursion".
Children are pushed reverse-index so LIFO pop visits them
left-to-right; row-bound pruning preserves the prior early-skip
optimization (the `break` shortcut is replaced with `continue`
since a stack can't leverage ordered sibling termination).

findChildOfType consumers migrated to the existing findChild
helper. findIdentifierChild deleted — no callers remained.

Coverage: 204/204 test/integration/resolvers/python.test.ts both
REGISTRY_PRIMARY_PYTHON=0 and =1. 339/339 scope-resolution +
graph unit tests. tsc clean.

* refactor(scope-resolution): remove unused shouldShadow / shouldCreateScope hooks

Both LanguageProvider hooks were dead weight:

- `shouldShadow` had zero call sites — the interface declared it,
  Python implemented a trivial always-true no-op, but no consumer
  ever read it. The shadowing decision lives in pythonMergeBindings
  and the central merge algorithm, not in a per-scope predicate.
- `shouldCreateScope` had one call site in pass1BuildScopes but the
  only language implementing it (Python) always returned true. No
  producer ever emits a `@scope.block` for Python, so the hook's
  "declines to create" branch was unreachable. Other languages
  didn't implement it at all.

Removing both:

- Drops the interface declarations in language-provider.ts.
- Drops `shouldCreateScope` from ScopeExtractorHooks Pick and from
  the pass1BuildScopes conditional — the stack-based parent-resolve
  loop becomes unconditional.
- Drops pythonShouldShadow / pythonShouldCreateScope from simple-hooks,
  the Python index barrel, and the python.ts provider wiring.
- Drops the tests that exercised the removed hooks: one block-
  suppression scenario in scope-extractor.test.ts, one shouldCreateScope
  test in parse-worker-scope-integration.test.ts, and the
  pythonShouldShadow / pythonShouldCreateScope always-true assertions
  in python-hooks.test.ts. pythonBindingScopeFor's delegate-to-default
  test is preserved in its own describe block.

Shadowing itself is unchanged: pythonMergeBindings still runs, LEGB
ordering still applies, wildcard transparency is still handled via
the merge precedence rules. The hook API just no longer has a
vestigial per-scope toggle we decided not to use.

Verification: 204/204 test/integration/resolvers/python.test.ts both
REGISTRY_PRIMARY_PYTHON=0 and =1. 335/335 scope-resolution + graph
unit tests (was 339, net -4 after removing the hook-specific
assertions). tsc clean.

* refactor(scope-resolution): drop dead exports surfaced by knip

Knip flagged 44+ dead exports in the PR surface. Cleanup:

Barrel deletion:
- Remove src/core/ingestion/scope-resolution/index.ts entirely.
  It re-exported 30+ symbols but only one file
  (languages/python/scope-resolver.ts) imported from it, and only
  7 symbols. Matches the project's "no barrel re-exports" preference
  and removes a drift surface. scope-resolver.ts now imports from
  concrete files (passes/mro.ts, scope/walkers.ts, contract/...).

Dead functions/interfaces removed:
- resolvePythonScope + ResolvePythonScopeInput + ResolvePythonScopeStats
  in languages/python/scope-resolver.ts — never called. pipelinePhase
  reaches pythonScopeResolver via SCOPE_RESOLVERS, not via a
  per-language entry point.
- getScopeResolver in scope-resolution/pipeline/registry.ts — had zero
  callers. Consumers read SCOPE_RESOLVERS directly.

Exports demoted to module-internal (used only within their own file):
- PYTHON_SCOPE_QUERY (query.ts) + its re-export from python/index.ts
- PROF (cache-stats.ts)
- PythonArityMetadata (arity-metadata.ts)
- ReferenceSiteSkipSet (graph-bridge/references-to-edges.ts)
- ReceiverBoundProviderSubset (passes/receiver-bound-calls.ts)
- ResolveCompoundReceiverOptions interface (passes/compound-receiver.ts)
- matchingOpenParen function (passes/compound-receiver.ts)
- followChainPostFinalize function (passes/imported-return-types.ts)
- RunScopeResolutionInput + RunScopeResolutionStats (pipeline/run.ts)

Also removed:
- Redundant `export type { Scope }` re-export from contract/scope-resolver.ts
  (consumers import Scope directly from gitnexus-shared).

Verification: knip reports zero dead exports in PR-touched files.
204/204 test/integration/resolvers/python.test.ts both flag paths.
335/335 scope-resolution + graph unit tests. tsc clean.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-21 15:50:00 +01:00
Copilot
6388113e10
fix: prevent stack overflow and memory exhaustion on large repo analysis (#814)
* Initial plan

* fix: prevent stack overflow and memory issues on large repo analysis

- Convert c3Linearize from recursive to iterative (explicit work stack)
  to handle deep class hierarchies without stack overflow
- Replace push(...arr) spread patterns with safe loops in parse-worker.ts
  and lbug-adapter.ts to prevent stack overflow on large arrays
- Stream relationship CSV lines directly to per-pair temp files in
  lbug-adapter.ts instead of accumulating millions of lines in memory
- Add test for deep 500-level inheritance chain

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: add stack size flag and enhanced error messages for large repos

- Auto-set --stack-size=4096 alongside --max-old-space-size in analyze
  command to prevent stack overflow on deep class hierarchies
- Add helpful error guidance for known large-repo failure modes
  (stack overflow, heap OOM, Map size limits)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address code review feedback

- Add error handling for write stream close in lbug-adapter.ts
- Handle backpressure when writing relationship CSV lines to disk
- Clarify ENTER/MERGE phase transition comment in resolve.ts
- Fix inconsistent stack size in error message (4096 not 8192)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address PR review — resource leak, redundant guard, Set, test depth

- Fix write-stream resource leak on readline error by destroying all
  open WriteStreams before rejecting (lbug-adapter.ts)
- Switch failedPairCsvPaths from array to Set for O(1) lookup
- Remove redundant MERGE-phase empty-parents guard in resolve.ts
  (unreachable — ENTER phase already handles that case)
- Increase deep inheritance test DEPTH from 500 to 2000 for
  reliable regression coverage across platforms

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cf1f3e22-3864-454a-a3a5-2bded9ebfdba

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: fix prettier formatting in lbug-adapter.ts

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unintended package.json/lock changes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: strip NODE_OPTIONS in skip-git-cli test child processes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: don't put --stack-size in NODE_OPTIONS (rejected by Node 24)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: pass --stack-size as CLI arg only, not in NODE_OPTIONS (Node 24 compat)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-13 20:08:49 +01:00
Gergő Magyar
0561d24efd
feat: METHOD_IMPLEMENTS edges, overload disambiguation, MethodExtractor unification (#574) (#642) 2026-04-04 18:41:47 +01:00
Gergő Magyar
bf09eab95b
feat: configure prettier with pre-commit hook (#563)
* feat: configure prettier with pre-commit hook integration

Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo
root with husky pre-commit hook integration. Moves husky from
gitnexus/ to root package.json for reliable hook installation.

- Root package.json with prepare/format/format:check scripts
- .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4
- .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md
- .gitattributes enforcing LF line endings for Windows consistency
- Pre-commit hook uses direct node_modules/.bin/ paths (no npx)

* style: apply prettier formatting to entire codebase

One-time bulk format. No logic changes.
Use .git-blame-ignore-revs to skip this commit in git blame.

* chore: add .git-blame-ignore-revs for prettier format commit

* perf: pre-commit hook runs only tests related to staged files

Use vitest --related to scope test execution to tests that import
the changed files, instead of running the full suite on every commit.

* perf: remove vitest from pre-commit hook, keep in CI only

Pre-commit now runs lint-staged + tsc only. Tests run in CI
(ci-tests.yml) where they belong — keeps commits fast.

* ci: add prettier format check to quality workflow

PRs will now fail if code isn't formatted with prettier.
2026-03-28 14:58:04 +00:00
Gergő Magyar
7999b6ba7b
refactor: SICP-informed LanguageProvider architecture (#488)
* refactor: SICP-informed LanguageProvider architecture for ingestion pipeline

Consolidate 16 scattered dispatch surfaces into a single LanguageProvider
Strategy interface per language. Processors are now fully language-agnostic —
zero SupportedLanguages.X enum access, zero dispatch table imports.

Architecture (5-layer DAG, zero circular dependencies):
  L0: Capability modules (dispatch tables, single source of truth)
  L1: LanguageProvider interface + createLanguageProvider factory
  L2: 13 per-language provider files (Strategy objects)
  L3: Registry with satisfies Record<SL, LP> + pre-built lookup maps
  L4: Processors (language-agnostic, all behavior via provider.*)

Key changes:
- Add LanguageProvider interface with 15 properties (6 required, 9 optional)
- Create 13 provider files in languages/ + php-helpers.ts
- Migrate all processors to getProvider(language) — cached once per scope
- Replace heritage if-checks with provider.interfaceNamePattern/heritageDefaultEdge
- Replace MRO switch(language) with switch(provider.mroStrategy)
- Replace isNodeExported with provider.exportChecker
- Move PHP description extraction behind provider.descriptionExtractor
- Move Swift implicit imports behind provider.implicitImportWirer
- Move PHP route detection behind provider.isRouteFile
- Move Kotlin wildcard append behind provider.importPathPreprocessor
- Remove deprecated TypeEnvironment.env, add fileScope()/allScopes()
- De-export TypeEnv type (module-private)
- Pre-build extensionMap, WILDCARD_LANGUAGES, SYNTHESIS_LANGUAGES at load
- Remove dead entryPointPatterns/frameworkPatterns from interface
- Derive createLanguageProvider config type via Pick/Partial/Omit
- Tighten callback types from any to SyntaxNode
- Migrate 270+ test call sites from .env to TypeEnvironment API

Adding a new language: 3 files (enum + provider + registry line).
No processor file touched. Ever.

* refactor: clean architecture for LanguageProvider with O(1) AST cache

Address all PR #488 review comments and achieve pristine SICP layer separation:

Interface redesign:
- Split LanguageProvider into Config (input) + Provider (runtime with defaults)
- Rename createLanguageProvider → defineLanguage with explicit DEFAULTS constant
- Add MroStrategy, ImportSemantics named type aliases for better IDE tooltips
- Tighten labelOverride signature: string|null → NodeLabel|null (compile-time safety)
- Tighten descriptionExtractor nodeLabel: string → NodeLabel
- Un-export LanguageProviderConfig (internal to defineLanguage)

CI fixes (all 4 failures resolved):
- isNodeExported: add null guard for unknown languages
- preprocessImportPath tests: pass getProvider() instead of raw enum
- MRO tests: update expected strings to match language-agnostic prefixes

Code deduplication:
- Extract findDescendant/extractStringContent to ast-helpers.ts (single source of truth)
- Unify Kotlin method detection: remove duplicate from extractFunctionName,
  use provider.labelOverride as single source of truth via findEnclosingFunctionId
- extractFunctionName return type: string → NodeLabel

Performance (O(1) AST node access):
- Add per-file Map-based memoization in parse-worker for parent-chain walks
- Cache enclosingClassId, enclosingFunctionId, exportStatus per SyntaxNode
- Clear caches before each file parse (not after — handles parse failures)

Architecture (pristine languages/ folder):
- Move php-helpers.ts → helpers/php.ts (L0 capability, not L2 config)
- Create helpers/swift.ts from extracted Swift provider logic
- Extract cppLabelOverride AST walk → isCppInsideClassOrStruct in ast-helpers.ts
- Extract isPhpRouteFile → helpers/php.ts
- All 13 provider files are now pure configuration — zero implementation logic
- Ruby: remove no-op namedBindingExtractor assignment (undefined from dispatch table)

* refactor: eliminate LANGUAGE_QUERIES, typeConfigs, namedBindingExtractors dispatch tables

Phase 1 of L0 dispatch table elimination. Providers now import capabilities
directly instead of indexing into redundant Record<SL, T> dispatch tables:

- LANGUAGE_QUERIES: providers import named query constants directly
  (TYPESCRIPT_QUERIES, PYTHON_QUERIES, etc.). Table kept in tree-sitter-queries.ts
  for call-processor.ts dynamic lookup + test consumers.

- typeConfigs: providers import from individual type-extractor files
  (typescriptConfig from typescript.ts, javaTypeConfig from jvm.ts, etc.).
  Dispatch table fully removed from type-extractors/index.ts.

- namedBindingExtractors: providers import extractors directly from
  named-binding-extraction.ts (extractTsNamedBindings, etc.).
  Dispatch table fully removed from import-resolution.ts.

Net: -48 LOC of dispatch table indirection. L3 satisfies Record<SL, LP>
remains the single exhaustiveness check.

* refactor: eliminate exportCheckers, callRouters, importResolvers dispatch tables

Phase 2 of L0 dispatch table elimination. All 6 dispatch tables are now gone:

- exportCheckers: individual checkers exported directly (tsExportChecker,
  pythonExportChecker, etc.). isNodeExported uses a local checkersByLanguage
  map to avoid circular dependency with languages/index.ts.

- callRouters: table removed. Providers import noRouting or routeRubyCall
  directly. noRouting now exported. Dead import removed from call-processor.ts.

- importResolvers: resolver functions exported with clean names
  (resolveTypescriptImport, resolveJavaImport, etc.). Inline lambdas
  extracted to named exports. Dispatch functions renamed from *Dispatch
  suffix to clean resolve*Import pattern.

Combined with Phase 1, all 6 L0 dispatch tables have been eliminated.
L3 satisfies Record<SL, LanguageProvider> is the single exhaustiveness check.
Providers are now fully self-contained — each imports its capabilities directly.

* perf+refactor: type-env caching, sequential fallback caching, utils.ts split

Phase 3 — performance optimizations and barrel cleanup:

Type-env parent-walk caching:
- Memoize findEnclosingClassName and findEnclosingParentClassName with
  per-file Map<SyntaxNode, string|undefined> caches
- Eliminates O(n*m) repeated child scanning in extractParentClassFromNode
- Caches cleared in buildTypeEnv before each file's walk phase

Sequential fallback caching:
- Add classIdCache + exportCache Maps to parsing-processor.ts
- Mirrors the O(1) memoization pattern from parse-worker.ts
- Both paths now have identical caching for parent-chain walks

Split utils.ts barrel into focused modules:
- noise-filter.ts: BUILT_IN_NAMES + isBuiltInOrNoise (167 LOC)
- language-detection.ts: getLanguageFromFilename (58 LOC)
- utils.ts slimmed to re-exports + yieldToEventLoop + isVerboseIngestionEnabled
- Backward compatible — existing imports from utils.ts still work

* refactor: rename resolvers/ → import-resolvers/, restructure tests per-concern

Directory renames (git mv — history preserved):
- src/core/ingestion/resolvers/ → import-resolvers/ (10 files)
- test/unit/call-routing.test.ts → call-routing/ruby.test.ts
- test/unit/named-binding-extraction.test.ts → named-bindings/csharp.test.ts
- test/unit/import-resolution.test.ts → import-resolution/preprocessing.test.ts

All 11 import paths updated to reference new import-resolvers/ location.
Test imports updated for new subdirectory depth.

Note: test/integration/resolvers/ NOT renamed — those tests cover the full
ingestion pipeline per-language, not just import resolution.

* refactor: eliminate utils.ts barrel — all 33 consumers now import directly

Migrated 65 import sites across 33 files to import from the focused source
module instead of the utils.ts barrel:

- ast-helpers.js: SyntaxNode, extractFunctionName, findEnclosingClassId, etc.
- call-analysis.js: inferCallForm, extractReceiverName, countCallArguments, etc.
- noise-filter.js: BUILT_IN_NAMES, isBuiltInOrNoise
- language-detection.js: getLanguageFromFilename

utils.ts reduced to 2 original functions only:
- yieldToEventLoop
- isVerboseIngestionEnabled

Zero re-exports remain. Every import is now direct to its source module.

* refactor: create utils/ folder, move all shared utilities, delete utils.ts barrel

Final phase of module structure migration:

- git mv ast-helpers.ts, call-analysis.ts, noise-filter.ts,
  language-detection.ts → utils/ subdirectory (history preserved)
- Extract yieldToEventLoop → utils/event-loop.ts
- Extract isVerboseIngestionEnabled → utils/verbose.ts
- Delete utils.ts (zero re-exports, zero functions remain)
- Update 38 import paths across source and test files

The ingestion/ root is now clean — only processors, capability modules,
and the pipeline orchestrator live at the top level. All shared utilities
are in utils/, all language-specific helpers in helpers/, all import
resolvers in import-resolvers/.

* refactor: move findChild from import-resolvers/utils.ts to utils/ast-helpers.ts

findChild is a generic AST helper (find first named child by type) — it
belongs with the other AST traversal utilities, not in the import resolver
module. 4 consumers updated to import from utils/ast-helpers.js.

* refactor: split named-binding-extraction.ts into per-language files

Rename named-binding-extraction.ts → named-binding-processor.ts (git mv,
history preserved), keeping only walkBindingChain for re-export chain resolution.

7 per-language extractor functions moved to named-bindings/ subdirectory:
- named-bindings/typescript.ts (extractTsNamedBindings — TS + JS)
- named-bindings/python.ts (extractPythonNamedBindings)
- named-bindings/kotlin.ts (extractKotlinNamedBindings)
- named-bindings/rust.ts (extractRustNamedBindings + collectRustBindings)
- named-bindings/php.ts (extractPhpNamedBindings)
- named-bindings/csharp.ts (extractCsharpNamedBindings)
- named-bindings/java.ts (extractJavaNamedBindings)

Each provider now imports its binding extractor from the per-language file.

* refactor: eliminate import-resolution.ts — distribute to natural homes

Split per-language resolvers into import-resolvers/ per-language files and
eliminate the import-resolution.ts catch-all module entirely:

Per-language resolvers moved to import-resolvers/:
- standard.ts: resolveStandard, resolveJavascriptImport, resolveTypescriptImport,
  resolveCImport, resolveCppImport
- jvm.ts: resolveJavaImport, resolveKotlinImport
- go.ts: resolveGoImport
- csharp.ts: resolveCSharpImport (helper renamed to Internal)
- php.ts, python.ts, ruby.ts, rust.ts: same pattern
- swift.ts: new file for resolveSwiftImport

Types distributed to their concern directories:
- import-resolvers/types.ts: ImportResult, ImportConfigs, ResolveCtx, ImportResolverFn
- named-bindings/types.ts: NamedBinding, NamedBindingExtractorFn

preprocessImportPath moved to import-processor.ts (its primary consumer).

import-resolution.ts deleted — zero catch-all modules remain.

* refactor: tighten SPR — eliminate re-exports, dead code, type holes, and redundant patterns

12 review findings resolved across the ingestion layer:

Type safety:
- CallRouter callNode: any → SyntaxNode (closes type hole)
- CaptureMap type alias replaces Record<string, any>
- providersWithImplicitWiring filter now type-narrowed (removes ! assertions)
- Ruby exportChecker: unnecessary as-cast removed, named export created

Architecture:
- Circular type dependency eliminated (ImportResolutionContext moved to types.ts)
- LANGUAGE_QUERIES residual dispatch replaced with provider.treeSitterQueries
- noRouting sentinel deleted — callRouter now properly optional on 12 providers
- All 6 re-exports from import-processor/pipeline/languages eliminated

Pattern cleanup:
- Dead checkersByLanguage table + isNodeExported removed from export-detection
- 4 duplicated config interfaces consolidated to language-config.ts
- extractCsharpNamedBindings → extractCSharpNamedBindings (casing consistency)

Simplification:
- import-resolvers/index.ts barrel deleted (dead re-exports)
- helpers/ inlined into languages/ (php.ts, swift.ts) — 1 directory removed

Verified: tsc --noEmit clean, 3837 tests pass, 0 failures.

* refactor: address review — remove LANGUAGE_QUERIES table, type-extractors barrel, fix Windows timeout

Review comment fixes (github.com/abhigyanpatwari/GitNexus/pull/488#issuecomment-4117817648):

1. LANGUAGE_QUERIES dispatch table removed from tree-sitter-queries.ts
   — 5 test files migrated to getProvider(lang).treeSitterQueries
   — eliminates last parallel dispatch surface

2. type-extractors/index.ts barrel deleted
   — type-env.ts now imports TYPED_PARAMETER_TYPES from shared.js directly

3. Windows CI timeout fix: afterAll cleanup hook in test-indexed-db.ts
   now passes explicit 120s timeout to prevent KuzuDB C++ destructor
   hang from hitting vitest's default 30s testTimeout on Windows

Verified: tsc --noEmit clean, 3835 tests pass, 0 failures.

* refactor: eliminate chained getProvider property access — assign to variable first

All getProvider(lang).property calls now follow the pattern:
  const provider = getProvider(language);
  const x = provider.property;

5 source files + 4 test files updated (~35 occurrences).
This ensures consistent provider variable usage and avoids
repeated lookups in hot paths.

* refactor: remove last 4 re-exports from import-resolvers, fix stale CaptureMap comment

- Remove `export type { TsconfigPaths }` from standard.ts
- Remove `export type { GoModuleConfig }` from go.ts
- Remove `export type { ComposerConfig }` from php.ts
- Remove `export type { CSharpProjectConfig }` from csharp.ts
  All 4 types are canonically defined in language-config.ts;
  zero consumers imported via the resolver re-exports.

- Fix stale CaptureMap JSDoc: said "Uses any" but type is SyntaxNode | undefined
2026-03-24 13:42:39 +00:00
Gergő Magyar
11a3d0515c
feat: Phase 8 field/property type resolution (#354)
* feat: Phase 8 field/property type resolution — resolve chained member access

Add field/property type extraction to the type resolution system so that
chained member access like `user.address.save()` resolves the intermediate
receiver type (`address → Address`) through Property symbols in SymbolTable.

Key changes:
- SymbolTable: add `declaredType` field, `fieldByOwner` O(1) index,
  `lookupFieldByOwner()` method, P0 conditional callableIndex invalidation,
  P2 exclude Properties from globalIndex to prevent namespace pollution
- tree-sitter queries: add `definition.property` for TypeScript, Java, Go
- parse-worker: extract declared types for Property nodes via
  `extractPropertyDeclaredType()`, capture field-access receiver info
- call-processor: add `resolveFieldAccessType()` helper and field-access
  branch in both sequential and worker receiver resolution paths
- Integration tests: new field-types test suite verifying end-to-end
  `user.address.save() → Address#save` resolution

* fix: Go tree-sitter query captures field_declaration not field_declaration_list

Post-review fix: the Go struct field query incorrectly put @definition.property
on field_declaration_list (the list container) instead of field_declaration
(the individual field). Also removed unused `language` parameter from
extractPropertyDeclaredType.

* feat: expand field-type tests to 6 languages, fix Go ownerId and Kotlin navigation_expression

- Add integration test fixtures for Java, C#, Go, Kotlin, PHP (alongside existing TS)
- Fix Go: add type_declaration handling in findEnclosingClassId for struct fields
  (field_declaration → field_declaration_list → struct_type → type_spec → type_declaration)
- Fix Kotlin: add navigation_expression handling in field-access resolution
  (Kotlin uses navigation_expression + navigation_suffix, not member_expression)
- Add extractMemberAccessParts helper in call-processor for cross-language member access
- All 24 field-type tests pass across 6 languages, 181 Go+Kotlin tests pass with no regressions

* refactor: split HAS_METHOD into HAS_METHOD + HAS_PROPERTY edge types

Property nodes now use HAS_PROPERTY edges instead of HAS_METHOD, giving
the graph schema proper semantic separation between methods and fields.

- HAS_METHOD: Method, Constructor, Function (when inside a class)
- HAS_PROPERTY: Property nodes (class fields, struct fields, attributes)

MRO processor only reads HAS_METHOD — properties correctly excluded from
method resolution order. Impact analysis accepts both edge types.

Updated 12 files: graph types, schema, tools docs, parse-worker,
parsing-processor, call-processor, and 6 test files.

* fix(test): update security test to expect 7 VALID_RELATION_TYPES (added HAS_PROPERTY)

* test: add unit tests for Phase 8 SymbolTable features (39 tests, up from 19)

Cover all new branches: declaredType metadata, Property exclusion from
globalIndex, conditional callableIndex invalidation, lookupFieldByOwner
(happy path + edge cases), lookupFuzzyCallable filtering, and clear()
with fieldByOwner. Fixes branch coverage threshold (21.8% → 23%+).

* feat: Phase 8B mixed field+method chain resolution, C++/Rust chain fixes

Unify field and method chain resolution into a single `extractMixedChain`
walker that handles interleaved patterns like `svc.getUser().address.save()`.
Fix C++ chain calls (tree-sitter-cpp `field_expression` uses `argument` not
`object`), Rust unit struct instantiation (`let svc = TypeName;`), and add
stdlib passthrough for `unwrap()`/`clone()`/`expect()` in chain loops.

Key changes:
- Replace `receiverCallChain` + `receiverFieldAccess` with unified
  `receiverMixedChain: MixedChainStep[]` on ExtractedCall
- Add `extractMixedChain` in utils.ts (handles both call_expression and
  field_expression nodes, including C++ `argument` field)
- Add `TYPE_PRESERVING_METHODS` set for stdlib identity operations
- Add C++ inline method double-indexing guard in parsing-processor.ts
  and parse-worker.ts
- Add Rust unit struct recognition in type-extractors/rust.ts
- Split field-types.test.ts into per-language test files
- Add ts-mixed-chain fixture and integration tests
- Resolve rust.test.ts todo: Option<T>.unwrap().save() now works
- Update roadmap: Phases 7+8 complete, Phase 9 is next

* fix: Python declaredType extraction and sequential-path property registration

- Move @definition.property capture from expression_statement to assignment
  node in Python queries so Strategy 1 childForFieldName('type') succeeds
- Pass item.declaredType through ctx.symbols.add in sequential call-processor
  path, matching worker path behavior (fixes Ruby YARD declaredType drop)
- Add Python chain resolution integration test (user.address.save → Address#save)
- Update Rust/Python status in roadmap and system docs to reflect actual coverage

* fix: Python/Ruby field type disambiguation and Rust chain test

Three fixes from PR #354 third review:

1. Python typed_parameter name extraction: tree-sitter-python's
   typed_parameter uses positional children for the name, not a named
   field. TypeEnv and extractParameter now fall back to firstNamedChild.

2. Ruby/Python call-step field resolution: Ruby's AST uses `call` nodes
   for both property access and method calls. The chain walker now tries
   resolveFieldAccessType before resolveCallTarget for call steps, so
   attr_accessor properties resolve via declaredType.

3. Rust chain resolution test: added missing integration test asserting
   user.address.save() resolves to Address#save.

Also splits C/C++ and TS/JS columns in type-resolution-system.md
language matrix with footnotes for accuracy.

1062 resolver integration tests passing, 0 failures.

* refactor: Phase 8 code review cleanup — extract walkMixedChain, fix MCP agent gaps

- Extract duplicated chain resolution loop into shared walkMixedChain() helper,
  eliminating ~60 lines of copy-pasted code between sequential and worker paths
- Add returnType to ResolveResult, removing redundant lookupFuzzy+find per chain step
- Fix context() tool to include HAS_METHOD, HAS_PROPERTY, OVERRIDES in queries
  so agents can discover class members
- Fix p.declaredType Cypher example (column doesn't exist) → p.description
- Add HAS_METHOD, HAS_PROPERTY, OVERRIDES to schema resource
- Document HAS_METHOD/HAS_PROPERTY in impact tool description
- Delete dead code extractMemberAccessParts (superseded by extractMixedChain)
- Replace any with SyntaxNode on extractPropertyDeclaredType
- Add Rust deep-field-chain test (5 tests), Java mixed-chain (4), Go mixed-chain (4)
- All 1075 tests pass (13 new, 0 regressions)

* refactor: type SymbolDefinition.type as NodeLabel, add O(1) receiver index

- Change SymbolDefinition.type from string to NodeLabel union (35 members)
  across symbol-table.ts, parse-worker.ts, parsing-processor.ts — compiler
  now enforces correctness at all comparison/assignment sites
- Replace O(N*M) linear scan in lookupReceiverType with pre-built
  ReceiverTypeIndex (Map<funcName, Map<varName, Entry>>) for O(1) lookups
  with proper ambiguity handling and file-level fallback
- All 1075 tests pass, 0 regressions

* fix: capture C++ pointer/ref fields, Kotlin data class props, PHP constructor promotion

Add tree-sitter query patterns for three previously missed property declaration
forms: C++ pointer/reference member fields (Address* addr; Address& ref;),
Kotlin primary constructor val/var parameters (data class User(val name: String)),
and PHP 8.0+ constructor property promotion (public Address $address).

Fix "10 languages" off-by-one in docs (Ruby is single-level only, not deep chain).
Update Python feature matrix cell from No* to Yes* after 31b95f0 fix.

11 new integration tests with per-language fixtures verify property capture,
HAS_PROPERTY edge emission, and field-access chain resolution.
2026-03-18 18:47:33 +00:00
Gergő Magyar
1afe9166aa
feat: language-aware code intelligence — symbol resolution, MRO, constructor discrimination (#238)
* feat: add Method Resolution Order (MRO) with language-specific rules

Implement full MRO computation for multi-language inheritance hierarchies:

- HAS_METHOD edges: Class→Method ownership edges emitted during parsing
  (both worker pool and sequential fallback paths)
- Method signatures: extract parameterCount and returnType from AST nodes
- C# heritage fix: distinguish EXTENDS vs IMPLEMENTS for base_list captures
  using symbol table lookup + I[A-Z] naming heuristic fallback
- MRO processor (Phase 4.5): walks inheritance DAG, detects method-name
  collisions across parents, applies language-specific resolution:
  - C++: leftmost base class in declaration order wins
  - C#/Java: class method wins over interface default
  - Python: C3 linearization with cycle detection
  - Rust: no auto-resolution (requires qualified syntax)
  - Default: first definition in BFS order wins
- OVERRIDES edges emitted for resolved method collisions
- KuzuDB schema: Method table extended with parameterCount/returnType;
  dedicated CSV writer and COPY query for 10-column Method rows
- MCP tools: updated Cypher examples for HAS_METHOD, OVERRIDES, diamond

72 tests across 5 test files covering MRO resolution, HAS_METHOD edges,
method signature extraction, C# heritage resolution, and integration
tests across C#/Rust/Python/TS/Java/C++.

* feat: add scope-based symbol resolution replacing raw lookupFuzzy

Introduces a shared 3-tier resolveSymbol function used by both
heritage-processor and call-processor:
1. Same-file (lookupExactFull — authoritative)
2. Import-scoped (filtered by ImportMap — high confidence)
3. Global fuzzy (first match — low confidence fallback)

Adds lookupExactFull to SymbolTable returning full SymbolDefinition
with type info needed for heritage Class/Interface disambiguation.

* refactor: tighten symbol resolution — Tier 3 refuses ambiguous matches

- lookupExactFull now O(1) via direct SymbolDefinition storage in fileIndex
  (shared object references with globalIndex — zero additional memory)
- Added resolveSymbolInternal() preserving { definition, tier, candidateCount }
  for test assertions and logging
- Tier 3 now returns null when multiple global candidates exist instead of
  arbitrary allDefs[0] — a wrong edge is worse than no edge
- call-processor: renamed fuzzy-global → unique-global, removed dead branch
- 12 new tests: tier assertions, ambiguous refusal per language family,
  heritage false-positive guard, O(1) shared reference verification

* fix: critical language support bugs in import resolution and MRO

Phase 5 critical fixes from all-language analysis:
- Python: add relative_import query capture (PEP 328) — `.models`, `..utils`
  were silently dropped, producing zero ImportMap entries
- Rust: extract prefix from grouped imports `crate::module::{A, B}` — brace
  groups previously failed resolution entirely
- Swift: use normalizedFileList for Windows path compatibility in module
  import resolution (matches Go's resolveGoPackage pattern)
- MRO: fix c_sharp → csharp language name mismatch (enum is 'csharp'),
  add Kotlin to C#/Java resolution rules (class method wins over interface)

* feat: add strict multi-language integration tests + fix C/C++ import resolution

Add 32 integration tests across 6 language fixtures (TypeScript, C#, C++,
Java, Python, Rust) with exact toBe/toEqual assertions validating heritage
edges, import resolution, and trait implementations.

Fix C/C++ import resolution bug where dot-to-slash conversion mangled
include paths (e.g. "animal.h" became "animal/h"). Now skips conversion
for C/C++ languages which use actual file paths in #include directives.

* fix: language-gate heritage heuristic, add Swift extension heritage, handle Rust grouped imports

- Gate I[A-Z] naming heuristic to C#/Java only (was firing for all languages)
- Swift unresolved types default to IMPLEMENTS (protocol conformance is the norm)
- Add tree-sitter query for Swift extension protocol conformance (extension Foo: Protocol)
- Handle Rust top-level grouped imports (use {crate::a, crate::b}) in both import loops
- Add 4 new heritage-processor tests (TypeScript refusal, Swift default, Swift Tier 1)

* feat: add Go struct embedding heritage + PackageMap optimization

Add Go struct embedding detection (anonymous fields → EXTENDS edges) via
new tree-sitter heritage query with named-field filtering in both
parse-worker and heritage-processor paths.

Implement PackageMap optimization for Go cross-package resolution:
replace O(N) file-level ImportMap expansion with directory-level suffix
matching (Tier 2b in symbol resolver). Graph IMPORTS edges are preserved
via addImportGraphEdge split.

Remove overly broad @definition.type from GO_QUERIES that was
double-matching structs/interfaces as TypeAlias nodes, breaking Tier 3
unique-global resolution.

Add Go fixture (go-pkg) with Admin→User embedding, cross-package calls,
and 7 integration tests covering structs, functions, imports, calls,
and heritage edges.

* test: add Kotlin heritage integration tests

Adds a kotlin-heritage fixture and 7 integration tests validating
class inheritance, interface implementation, JVM-style import
resolution, and symbol-table-driven EXTENDS/IMPLEMENTS disambiguation
via Kotlin delegation specifiers.

* feat: extract resolvers, add PHP tests, ambiguous tests for all languages

- Extract language-specific resolvers from import-processor.ts into
  resolvers/ directory (P7): jvm, go, csharp, php, rust, standard, utils
- import-processor.ts reduced from 1412 to 711 lines (50% reduction)
- Add comprehensive PHP integration tests: PSR-4 imports, traits, enums,
  heritage edges, method calls, MRO overrides
- Add ambiguous symbol resolution tests for all 9 languages verifying
  correct disambiguation via import chains
- Split monolithic lang-resolution.test.ts (1080 lines) into 9 per-language
  files under test/integration/resolvers/ with shared helpers

* feat: update integration tests to include resolver tests for multiple languages

* fix: address code review — schema gap, Rust impl name, Property OVERRIDES

Bugs fixed:
- Add 13 missing FROM/TO pairs in RELATION_SCHEMA for HAS_METHOD edges
  (Class/Interface/Struct/Trait/Impl/Record to Method/Constructor/Property)
- Fix findEnclosingClassId to pick implementing type for Rust
  impl Trait for Struct blocks (was picking trait name)
- Exclude Property nodes from MRO OVERRIDES collision detection
- Change MRO language fallback from typescript to unknown

Tests added:
- Unit: Property OVERRIDES exclusion (2 tests), Rust impl Trait for
  Struct name resolution (2 tests), schema HAS_METHOD pair coverage
- Integration: no OVERRIDES targets Property nodes across all 9 languages
- PHP fixture: added shared $status property to both traits to create
  real collision scenario for Property OVERRIDES exclusion test

Documentation:
- OVERRIDES edge direction (Class to Method), Go return type gap,
  BFS first-reach heuristic limitation

* feat: harden CALLS-edge resolution — Phase 0 validation

- Fix same-file confidence (0.85 → 0.95) to correctly outrank import-scoped (0.9)
- Fix Tier 1 overload preservation: use globalIndex filter instead of fileIndex lookup
- Add callable-kind guard: refuse CALLS edges to Interface and Enum symbols
- Fix Kotlin countCallArguments: handle call_suffix → value_arguments nesting
- Fix Kotlin extractFunctionName: add simple_identifier to fallback search
- Strictly type findParameterList and countCallArguments (remove all `any`)
- Add arity-based call resolution integration tests for 9 languages
- Add unit regression tests for Interface/Enum CALLS refusal

* chore: remove C# build artifacts from fixtures

* feat: add call-form discrimination and ownerId to symbol table (Phase 1)

Add inferCallForm() and extractReceiverName() to distinguish free/member/constructor
calls at the AST level across all 9 languages. Add ownerId field to SymbolDefinition
linking Method/Constructor/Property to their owning class. Includes 36 unit tests
and member-call integration tests for all 9 languages (132 tests, 0 failures).

* feat: constructor/struct-literal resolution across all languages (Phase 2)

Add constructor discrimination to CALLS-edge resolution: new Foo(),
User{...} struct literals, and C# primary constructors now resolve to
Constructor/Class/Struct/Record nodes instead of being filtered out.

Queries: new_expression (C++), object_creation_expression (PHP),
composite_literal (Go), struct_expression (Rust), primary constructor
and implicit_object_creation_expression (C#).

Relaxes global tier in collectTieredCandidates to pass all candidates
through filterCallableCandidates, allowing kind/arity narrowing to
disambiguate at lower confidence.

* feat: receiver-constrained resolution with integration tests for all 9 languages

Add receiver-type filtering (Phase 3): when a member call like `user.save()`
has a known receiver type from TypeEnv, filter candidates by ownerId to
disambiguate methods with the same name across different classes.

Key changes:
- call-processor: build per-file TypeEnv, pass receiverTypeName to resolveCallTarget
- parse-worker: extract receiverTypeName from TypeEnv in worker thread
- resolveCallTarget: new step D filters by ownerId matching receiver type
- utils: extractReceiverName supports C++ field_expression (argument field)
- utils: findEnclosingClassId extracts Go method receiver types
- type-env: handle Go qualified_type, Kotlin user_type/variable_declaration
- parse-worker + parsing-processor: Function added to needsOwner for
  Kotlin/Rust/Python class methods captured as Function nodes

Integration tests added for receiver-constrained resolution across all 9
languages: TypeScript, Java, Python, Go, Rust, C++, C#, Kotlin, PHP.

* feat: NamedImportMap, scoped TypeEnv, broadened signatures + TS rest-param variadic fix

Address all 4 PR #238 review items:
1. Remove redundant lookupFuzzy in processRoutesFromExtracted
2. Add NamedImportMap for TS/Python symbol-level import tracking (Tier 2a)
3. Make TypeEnv scope-aware (Map<scopeKey, Map<varName, type>>) to fix
   non-deterministic receiver resolution across functions
4. Broaden extractMethodSignature: Go/Rust/C++ return types, variadic
   detection for Go/Java/Python/C++/Kotlin/TypeScript rest params

Discovered and fixed: TS rest params (...args) were not detected as
variadic — added rest_pattern detection inside required_parameter nodes.

Integration tests added: scoped receiver, named import disambiguation,
and variadic call resolution for both TypeScript and Python.

* fix: alias import resolution, Go multi-assign TypeEnv, dead code removal

- NamedImportMap now stores {sourcePath, exportedName} so aliased imports
  (import { User as U }) resolve U → User in the source file
- Named binding check moved before empty-allDefs early return in both
  call-processor and symbol-resolver, fixing constructor calls via aliases
- Go extractFromGoShortVarDeclaration iterates all LHS/RHS pairs for
  multi-assignment (user, repo := User{}, Repo{}) instead of only first
- Remove unused TYPED_DECLARATION_TYPES set (TYPED_PARAMETER_TYPES kept)
- Integration tests for both fixes (go-multi-assign, typescript-alias-imports)

* feat: alias import extraction for Kotlin, Rust, PHP, C# + integration tests

Add named import alias extraction to both pipeline paths
(import-processor.ts and parse-worker.ts) for Kotlin, Rust, PHP,
and C#. Add integration test fixtures and tests for all 5 languages
(Python alias extraction already worked, just needed the test).

Each test verifies: class detection, member call resolution through
aliases to correct target files, and IMPORTS edge emission.

* refactor: use SupportedLanguages enum everywhere instead of raw strings

Replace all raw language string literals and `language: string` types
with the SupportedLanguages enum across 10 files. This ensures
compile-time safety for language dispatch and eliminates dead
`language === 'tsx'` checks (tsx maps to TypeScript in the enum).

* fix: tier-ordering bug, re-export chains, PHP grouped imports, Java named imports

- Fix collectTieredCandidates tier-ordering: same-file now checked before
  named bindings, preventing imports from shadowing local definitions
  (matches resolveSymbolInternal priority order)
- Add re-export chain resolution for TypeScript/JavaScript barrel files:
  export { X } from './base' and export type { X } from './base' now
  followed up to 5 hops through NamedImportMap
- Fix PHP grouped import alias extraction: use App\Models\{User, Repo as R}
  now correctly handled in both parse-worker and import-processor
- Add Java NamedImportMap support: import com.example.models.User now
  records User as a named binding for precise disambiguation
- Add 16 new integration tests across TypeScript, PHP, and Java resolvers
  (220 total resolver tests, all passing)

* refactor: consolidate alias extraction + add variadic/constructor/shadow integration tests

- Extract shared named-binding-extraction.ts from duplicate logic in
  import-processor.ts and parse-worker.ts (net -200 lines)
- Deduplicate appendKotlinWildcard (now imported from resolvers/index.ts)
- Add integration tests: constructor calls (Kotlin, Python), variadic
  resolution (Go, Java, C#, C++, Kotlin), re-export chains (Python),
  local definition shadowing (Python, Go)
- Add TODO(stack-graph) for TypeEnv scope key collision
- 225 integration tests passing (was 223)

* fix: PHP non-aliased imports, Python node identity, re-export chain dedup + local-shadow tests

- PHP flat non-aliased imports (use App\Models\User) now stored in NamedImportMap
- PHP grouped non-aliased imports ({User} in {User, Repo as R}) now stored in NamedImportMap
- Python: replace non-public child.id with child.startIndex for node identity
- Extract shared walkBindingChain() from symbol-resolver and call-processor
- Add PHP variadic resolution fixture + test (variadic_parameter already covers PHP)
- Add local-shadow integration tests for Java, C#, Kotlin, Rust, PHP, C++ (6 languages)

* feat: Rust non-aliased use bindings, Kotlin non-aliased imports, re-export chain resolution

Extend NamedImportMap coverage for Rust and Kotlin non-aliased imports:

- Rust: rename collectUseAsClauses → collectRustBindings, extract terminal
  scoped_identifier (use crate::models::User) and identifier in use_list
  (use crate::models::{User, Repo}) into NamedImportMap. This also enables
  pub use re-export chain following via walkBindingChain.
- Kotlin: extend extractKotlinNamedBindings to handle non-aliased imports
  (import com.example.User), skipping wildcard imports.
- Add rust-reexport-chain fixture + 3 integration tests verifying Handler{}
  resolves through mod.rs pub use to handler.rs.
- Add Kotlin heritage + constructor-calls reason assertions for non-aliased
  import-resolved resolution.
- Add C# heritage test documenting namespace import tier behavior.

* fix: skip Kotlin lowercase member imports in NamedImportMap

Member imports like `import util.OneArg.writeAudit` (lowercase last
segment) must not populate NamedImportMap — same-named function imports
from different classes collide, breaking arity-based disambiguation.
Apply the same guard Java already uses: skip lowercase last segments.

* fix: skip spurious path-prefix bindings in Rust grouped imports

collectRustBindings was extracting the path segment (e.g. "models") from
`use crate::models::{User, Repo}` as a spurious NamedImportMap entry.
Skip scoped_identifier nodes that are direct children of scoped_use_list
since they are path prefixes, not importable symbols.

Adds rust-grouped-imports fixture and 4 integration tests verifying both
symbols resolve correctly and no spurious binding leaks through.

* fix: use startIndex in TypeEnv scope key to prevent same-name method collision

Two methods named identically in different classes within the same file
previously shared a scope key, causing non-deterministic type resolution.
Now keys use funcName@startIndex for uniqueness.

Also adds tests documenting destructuring assignment extraction gap.

* test: document C# namespace-level import limitation in named binding extraction

* test: document same-arity overload discrimination limitation in call processor

* perf: parallelize calls/heritage/routes processing in worker path

Worker path now runs processCallsFromExtracted, processHeritageFromExtracted,
and processRoutesFromExtracted via Promise.all instead of sequentially.
Safe because all three only read shared state and write via addRelationship's
dedup guard. Sequential fallback path stays sequential (shared LRU astCache).

Also fixes Rust collectRustBindings spurious path-prefix bindings for 3+ level
grouped imports, and adds @param JSDoc for walkBindingChain's allDefs invariant.

* docs: improve Promise.all safety comment and walkBindingChain JSDoc

Clarify that the parallelization safety comes from disjoint relationship
types + idempotent id-keyed Maps, not from lack of shared state (the
graph is shared). Strengthen allDefs JSDoc to describe silent-miss
consequence of passing pre-filtered results.

* refactor: extract language-specific processing into modular dispatch tables

Phase 1: Extract type binding logic from type-env.ts (635→125 LOC) into
type-extractors/ directory with per-language files and Record<SupportedLanguages,
LanguageTypeConfig> + satisfies dispatch.

Phase 2: Extract 5 config loaders from import-processor.ts into
language-config.ts (removed ~196 LOC of inline loaders).

Phase 3: Convert export-detection.ts switch/case to exhaustive
Record<SupportedLanguages, ExportChecker> + satisfies dispatch table,
fix node: any → SyntaxNode.

Also adds language feature matrix to README.

All 1146 unit tests and 433 integration tests pass.

* refactor: extract type binding logic into type-extractors/ directory (Phase 1)

Extract per-language type extraction from type-env.ts (635→125 LOC) into
type-extractors/ with Record<SupportedLanguages, LanguageTypeConfig> + satisfies
dispatch. 9 per-language files, shared helpers, and barrel index.

* refactor: extract config loaders to language-config.ts (Phase 2)

Move 5 language-specific config loaders and their type interfaces from
import-processor.ts into standalone language-config.ts module.
2026-03-13 13:12:23 +00:00