From 9372b17049cc968676d620382c5cae68bcb7e7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 5 Aug 2026 11:35:27 +0100 Subject: [PATCH] fix(python): resolve calls through an unaliased dotted namespace import (#2826) (#2828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(python): resolve calls through an unaliased dotted namespace import (#2826) `import pkg.db` followed by `pkg.db.session_scope()` emitted no CALLS edge, while all three sibling spellings resolved. In a codebase whose style guide mandates absolute imports this is close to the only cross-module call form used, so `impact()` reported `impactedCount: 0, risk: LOW, epistemic: exact` for functions with dozens of real callers — a dropped caller reading as a verified all-clear. The resolution path was never missing; one map was keyed on the wrong half of the import. `interpretPythonImport`'s plain arm splits `import pkg.db` into `localName: 'pkg'` (the name Python actually binds) and `importedName: 'pkg.db'`, and finalize carries both onto the edge as `localName` / `targetExportedName`. `collectNamespaceTargets` keyed only on `localName`, but the receiver text captured at the call site is the whole dotted path — Python's query binds the attribute's `object` field with a wildcard, so `pkg.db.session_scope()` yields the receiver `pkg.db`. Case 0 declines it (a module is not a class) and falls through, Case 1 looks up `pkg.db` and misses, and Case 1.5 needs `resolveQualifiedReceiverMember`, which only the C++ provider implements. The site drops silently. Key the map on the dotted import path as well — gated on a provider opt-in, not on the edge shape. The shape alone cannot decide it: Swift's `import Foo.Bar` produces the identical pair (`localName: 'Foo'`, `targetExportedName: 'Foo.Bar'`), but there the FIRST segment is the resolved target and `Foo.Bar` names a nested type. Minting a key for it would hand `resolveConstructionExpressionClass` an authoritative namespace — that branch deliberately does not fall through on a miss — and break `Foo.Bar(x)` construction that resolves correctly today. Hence `ScopeResolver.namespaceReceiverIncludesImportPath`, which only Python sets. The root-segment check on the added key does real work: `import pkg.db as pdb` binds only `pdb`, so writing `pkg.db.f()` there is a NameError, and its edge (localName `pdb`, path `pkg.db`) is correctly rejected. Two same-package imports stay separate — `import pkg.db` + `import pkg.cache` key `pkg.db` and `pkg.cache` independently, so neither call can land in the other's module; the shared `pkg` bucket keeps its existing ambiguity rather than gaining any. Tests: five integration rows (the issue's own repro, the three sibling spellings as controls, non-crossing two-package imports, a three-segment receiver, and dotted construction) plus a unit pin on the keying rule that asserts a Swift-shaped edge mints nothing. All five integration rows fail on the pre-fix tree; the controls pass on both, which is what makes them controls. Resolver integration suite 3024 passed / 1 skipped / 0 failed; scope-resolution unit suite 1446 passed. This changes what the resolver produces, not how it is stored — no schema or version constant applies, and an existing index needs a re-analyze to show the new edges. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): shadow-test a dotted namespace key by its root segment (#2826) `isNamespaceNameShadowed` walks the scope chain looking for a binding, type binding, lexical name, or owned def named exactly `namespaceName`. Once a namespace key can be a dotted import path, that string never matches anything: `import pkg.db` binds `pkg`, so a local `pkg = Decoy()` shadows the import, but the guard was asked about `pkg.db` and answered "not shadowed". The consequence is not a missed edge but a wrong one. The caller treats a verified namespace as authoritative and deliberately does not fall through to the workspace-wide simple-name heuristics, so an unguarded shadowed receiver resolves construction against the imported module instead of the local value. Test the first dot-separated segment instead. Single-segment names are unaffected — their root is themselves — so every pre-existing row keeps its behaviour. This ships with the key that first routes a dotted name into the guard rather than after it: the previous commit is what makes the defect reachable. The new pin fails on the pre-fix guard (verified by reverting the four comparisons and re-running: 1 failed / 5 passed), so it discriminates rather than merely passing. Co-Authored-By: Claude Opus 5 (1M context) * test(python): pin the callee name on the dotted-construction row (#2826) The row asserted only that `builds` reached `pkg/db.py`. That module also exports `session_scope`, so a regression that resolved the construction to the wrong member of the right module would have kept the test green — it pinned the file, not the answer. Assert the exact edge set for the caller instead. Verified against the current tree with a scratch probe: `builds -> Model@pkg/db.py` is the only edge the file produces. Co-Authored-By: Claude Opus 5 (1M context) * docs(plans): include the #2826 engineering plan in the PR `.gitignore` keeps `docs/*` local because planning output is normally throwaway. Force-added here at the reviewer's request so the plan travels with the work it drove: it records the evidence chain behind the fix, the two places the plan turned out to be wrong, and the follow-ups deliberately left out of scope. Co-Authored-By: Claude Opus 5 (1M context) * refactor(resolution): make the namespace shadow guard shared (#2826) `isNamespaceNameShadowed` lived module-private in `compound-receiver.ts` with a single caller. The namespace map it guards has three consumers, and the next commit adds the guard to a second one, so it moves to `scope/walkers.ts` alongside the other scope-chain primitives rather than being duplicated. Behaviour is unchanged — this is a move plus documentation. Two notes were added because both are easy to get wrong later: - Fails closed on a missing scope or a parent cycle. For every caller, suppressing costs a missing edge while trusting a corrupt scope chain costs a wrong one, so the bias is deliberate. - It reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, which is the opposite of the fix #2745 applied to Rust's `headBoundLocally`. There the question was "is this name bound at all?", so missing finalize's import channels lost real bindings. Here the question is "does something LOCAL shadow the import?", and the import's own finalized binding is exactly what must not count — routing this through `lookupBindingsAt` would find every namespace import shadowing itself and suppress the lot. Verified against a target module carrying a self-named def, which still resolves. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): close the three remaining namespace-receiver gaps (#2826) Three defects the first fix left behind. All three were confirmed by probe before being touched, and a fourth suspected gap was disproved the same way. ## 1. Case 1 resolved through an import a local had shadowed `namespaceTargets` is collected per FILE, but Case 1 in `receiver-bound-calls` consulted it with no lexical guard at all, so import pkg.db def f(pkg): # parameter shadows the package return pkg.db.session_scope() emitted an edge to pkg/db.py. That is a WRONG edge, and it predates the dotted key: the single-segment spelling (`import single` + `def f(single)`) failed identically. The compound-receiver construction path has applied this guard since #2770; Case 1 simply never did. Now both use the shared guard. ## 2 + 3. The root key named the leaf module, not the package These read as two gaps and are one. `import a.b.c` binds ONE name — `a` — but makes three attribute paths callable, naming three different files: a → a/__init__.py a.b → a/b/__init__.py a.b.c → a/b/c.py The map keyed only `a`, pointed at the LEAF. So `a.helper()` resolved into a/b/c.py whenever that module happened to export `helper` — silently preferring a decoy over the real definition in the package — and `a.b.mid()` resolved to nothing at all. One wrong edge and one missing edge from a single mis-keying. Fixing it needs per-language knowledge the shared collector cannot have: which prefixes are reachable, and which file each names. The `__init__.py` convention is Python's alone, and the edge shape is ambiguous across languages — Swift's `import Foo.Bar` produces an identical `localName`/`targetExportedName` pair that means the opposite thing. So the previous commit's boolean opt-in is replaced by `ScopeResolver.namespaceReceiverPaths`, which returns every spelling with the file it names; absent or declining, the shared default (bound name → own target) is unchanged for every other language. Prefix files are proposed, not asserted — `moduleFileExists` drops any the workspace never parsed, so a PEP-420 namespace package contributes no key rather than one pointing at a missing file. ## Disproved: C# was not a fourth gap The plan listed C# `using System.Collections.Generic` + `System.Collections.Generic.List` as the same class of bug. It is not: a probe shows `My.Deep.Space.Helpers.Work()` already resolves through the FQN namespace bindings in `walkers.ts`. No change made, and the claim is withdrawn rather than carried forward as a known gap. ## Testing Integration: the shadow block asserts the exact surviving edge set (an absence-only assertion would also pass if the guard over-suppressed and killed the clean rows); the prefix block asserts all three spellings land on their own file, with `helper` defined in BOTH package and leaf so a wrong edge is visible rather than merely possible. Unit: 16 rows on the keying contract, including that a Swift-shaped edge mints nothing and an alias import keys neither the path nor the root. Resolver integration 3024 passed / 1 skipped / 0 failed; scope-resolution unit 1452 passed; tsc clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): probe both path separators when resolving a prefix package (#2826) Workspace file paths are not normalized to POSIX at ingestion — `import-target` already re-normalizes at five other comparison points, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`. The prefix probe built only the `/` spelling, so on Windows it would compare `a/b/__init__.py` against an `a\b\__init__.py` key, find nothing, and mint no prefix keys at all. That fails quietly, which is the worst shape for it: `a.b.mid()` simply goes back to unresolved on one platform, with no drop recorded and every test on POSIX still green. Probe both spellings and key whichever the workspace actually holds. The new row is mutation-tested — reverting to the `/`-only probe turns it red (1 failed / 10 passed), so it pins the behaviour rather than passing alongside it. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): correct three defects a multi-lane review found in this PR (#2826) All three were introduced by this PR's own earlier commits, and none was found by re-reading the diff — each came from a lane attacking an angle the author had not. ## 1. The shadow guard ran BEFORE the map lookup it gates Case 1 evaluated `isNamespaceNameShadowed` unconditionally, then consulted `namespaceTargets`. So every call/read/write site with an explicit receiver, in every language, paid a scope-chain walk (a Set allocation, three Map lookups and a linear `ownedDefs` scan per level) ahead of an O(1) hash miss that was going to decline it anyway. The proof it was an oversight rather than a decision sits in this same PR: the sibling guard in `compound-receiver.ts` reads the map first and only guards on a hit. Two call sites of one shared function, opposite order. Semantics are identical either way — a miss yields `undefined` regardless — which is exactly why it survived several readings. ## 2. Prefix packages were anchored on the import spelling, not the resolved leaf `pythonNamespaceReceiverPaths` built `a/__init__.py` from the dotted path joined at the workspace root, never consulting the file the import actually resolved to. But `resolvePythonImportTarget` resolves off-root in two of its three tiers, so `import utils.db` can land on `libs/common/utils/db.py`. That produced a wrong edge where a same-named `utils/` package exists at the root, and produced NOTHING in a `src/` layout — the prefix feature was inert for the most common Python project shape, silently. Now the prefix directories are derived by walking back from the resolved leaf, which is exact for root, `src/` and off-root layouts alike. It also inherits the leaf's own separator, which subsumes the previous dual-separator probe: that probe was dead code anyway, because `filesystem-walker.ts` normalizes `\` to `/` before a path ever becomes a `ParsedFile.filePath`. Its test row is removed rather than left asserting an unreachable state. ## 3. Keying the root at `__init__.py` INSTEAD of the leaf lost re-exports `findExportedDef` accepts only a binding whose `origin === 'local'`. The canonical Python package re-exports from its submodules — `from .b.c import helper` in `__init__.py` — which is an IMPORT binding, so it is rejected. Keying the prefix solely at the package therefore turned `a.helper()` from a correct edge into no edge at all for the most common package shape. Every fixture in this PR defined its members locally in `__init__.py`, which is precisely the one layout where that mistake is invisible. The prefix now keys the package FIRST and the leaf behind it. A real definition in `__init__.py` still wins over a same-named decoy deeper in the package, and a name merely re-exported there still resolves through the leaf. Ordering is the contract, so the unit rows assert the exact arrays rather than membership. ## Testing New rows: off-root layout with a decoy `utils/` at the root, and a `src/` layout. Both mutation-tested — reverting to the spelling-anchored build turns them red. The re-export case was verified end-to-end with a scratch fixture whose `__init__.py` only re-exports (`uses -> helper@a/b/c.py`). Resolver integration 3131 passed / 1 skipped / 0 failed — unchanged from before these fixes, so they regress nothing. Scope-resolution unit 1459 passed. tsc clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): stop the namespace shadow guard AT the module scope (#2826) CI caught a regression this PR introduced: `cjs-exports-assignment.test.ts` lost both of its cross-file rows — cross-file require() member call resolves expected [] to deeply equal [ 'handle' ] an `exports` parameter does not hijack the module (UMD factory) expected [] to deeply equal [ 'publicApi' ] — i.e. `const svc = require('./svc'); svc.handle()` stopped resolving in JavaScript. Cause: in CommonJS the namespace import IS a variable declaration. One statement produces both the ImportEdge and a module-scope `const` binding, so the guard, by inspecting the module scope, found the import's own name there and read it as a shadow of itself — suppressing exactly the receivers it exists to enable. The guard's own contract sentence already said the right thing: "a declaration BETWEEN the call site and its module scope". The module scope is the floor of that walk, not a rung on it. It now returns at Module without inspecting it. Nothing is lost on the suppression side: a genuine shadow is a parameter, a local, or a nested declaration, and all of those live in scopes strictly inside the module. The Python rows that pin suppression (`def f(pkg): pkg.db.f()` and its single-segment `import single` twin) still pass, because a parameter is an inner scope. Worth recording for the next reader: two independent review lanes examined this exact scenario and both REFUTED it, reasoning that `require()` yields an ImportEdge in `scope.imports` rather than a local binding. That is true for Python's `import x` and false for CommonJS, where one statement is both. My own probe used a Python fixture and so could not surface it either. Agreement between reviewers was not evidence; the test corpus was. Verified: cjs-exports-assignment 36/36, the #2826 integration rows 7/7, scope-resolution unit 126/126. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- ...s-plan-python-dotted-namespace-receiver.md | 473 ++++++++++++++++++ .../languages/python/import-target.ts | 73 +++ .../core/ingestion/languages/python/index.ts | 1 + .../languages/python/scope-resolver.ts | 7 + .../contract/scope-resolver.ts | 38 ++ .../passes/compound-receiver.ts | 37 +- .../passes/receiver-bound-calls.ts | 26 +- .../scope/namespace-targets.ts | 54 +- .../scope-resolution/scope/walkers.ts | 71 +++ .../test/integration/resolvers/python.test.ts | 269 ++++++++++ .../namespace-targets-import-path.test.ts | 181 +++++++ ...thon-module-namespace-construction.test.ts | 58 ++- 12 files changed, 1242 insertions(+), 46 deletions(-) create mode 100644 docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md create mode 100644 gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts diff --git a/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md new file mode 100644 index 000000000..bf4c175c8 --- /dev/null +++ b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md @@ -0,0 +1,473 @@ +# GitNexus Engineering Plan + +> Task: Emit the missing `CALLS` edge for Python's unaliased multi-segment namespace import (`import pkg.db` + `pkg.db.session_scope()`), issue #2826. +> Evidence verified at commit b2cd1c2ad637657125248c0dd2046de71ceea965; GitNexus index 13 commits behind HEAD, refresh skipped: every cited path is byte-identical between the index commit (1ef6447e) and the pinned commit — verified by blob-id comparison, so no graph claim here rests on drifted content. PDG layer absent from this index (`MATCH ()-[r:CodeRelation {type:'CDG'}]->() RETURN count(r)` → 0); `--pdg` upgrade skipped, source reads substitute at higher evidence strength. +> Evidence provenance schema 2; global dirty digest 0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c; cited-path manifest 13 sorted entries; exact generated plan path excluded. + +## 1. Objective + +`import pkg.db` followed by `pkg.db.session_scope()` must emit a `CALLS` edge from the caller to `session_scope`, matching the three sibling import spellings that already resolve (`from pkg.db import session_scope`, `import pkg.db as pdb`, `from pkg import db`). Two same-package imports in one file (`import pkg.a` + `import pkg.b`) must not cross-resolve, and no shared file under `gitnexus/src/core/ingestion/` may name a language (AGENTS.md §42). + +## 2. Current Behaviour + +The failure is a **key/lookup mismatch inside one map**, not a missing resolution path. + +For `import pkg.db`, `splitImportStmt` emits one match with `@import.source` = the whole `dotted_name` text `"pkg.db"` `[verified]` (`gitnexus/src/core/ingestion/languages/python/import-decomposer.ts:46-54`). `interpretPythonImport`'s `'plain'` arm then splits it `[verified]` (`gitnexus/src/core/ingestion/languages/python/interpret.ts:33-42`): + +```ts + case 'plain': { + // `import numpy` + if (sourceCap === undefined) return null; + return { + kind: 'namespace', + localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a` + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } +``` + +`finalizeImportEdges` carries both halves onto the edge: `localName` verbatim, and `targetExportedName = parsed.importedName` for `kind === 'namespace'` `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:398-406, 434-447`). So the finalized `ImportEdge` is `{ localName: 'pkg', targetExportedName: 'pkg.db', targetFile: 'pkg/db.py', kind: 'namespace' }`. + +`collectNamespaceTargets` keys **only on `localName`** `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts:44-57`), producing `{'pkg' → ['pkg/db.py']}`. + +At the call site, Python's query binds the attribute's `object` field with a wildcard — `object: (_) @reference.receiver` `[verified]` (`gitnexus/src/core/ingestion/languages/python/query.ts:267-270`) — so for `pkg.db.session_scope()` the receiver node is the inner `attribute`, and `extractExplicitReceiver` takes its raw text `[verified]` (`gitnexus/src/core/ingestion/scope-extractor.ts:1235-1239`): `receiverName === 'pkg.db'`. + +`emitReceiverBoundCalls` then walks its cases `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts:404-421, 546-655, 831-848`): + +- **Case 0 (compound receiver)** fires because `receiverName.includes('.')` (line 563-567). It asks `resolveCompoundReceiverClass` for a **class**; `pkg.db` names a module, so it returns `undefined`, sets `compoundReceiverUnresolved = true`, and — critically — does **not** `handledSites.add`, so control falls through (lines 577, 622-655). +- **Case 1 (namespace receiver)** runs `namespaceTargets.get('pkg.db')` (line 832). The map holds `'pkg'`. Miss. +- **Case 1.5** needs `provider.resolveQualifiedReceiverMember`, implemented only by the C++ provider `[verified]` (`gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts:399-406`; `context` on that symbol shows one outgoing call to `resolveCppQualifiedNamespaceMember` and no other implementer). Python leaves it undefined, so the case is skipped. + +No later case types a module receiver, so the site drops. Reproduced on both `origin/main` and PR #2810's head; PR #2810 changes Python receiver *typing* (`languages/python/receiver-binding.ts`) and does not touch this path `[verified]` by running the repro against both trees. + +The three sibling spellings resolve because each binds a **single-segment** local name: `pdb` (alias arm), `session_scope` (named binding, not a receiver at all), and `db` (reclassified to `kind: 'namespace'` by #2770's `isNamespaceImport` hook, keying the map on `'db'`). + +## 3. Relevant Architecture + +`collectNamespaceTargets` is the shared, language-neutral bridge between finalized import edges and receiver resolution. Its contract note already states that `ImportEdge.kind === 'namespace'` is authoritative and that providers may reclassify into it — that reclassification hook (`isNamespaceImport`) is #2770's extension point `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107`). + +Its output feeds three consumers, all per-file (`fileCompoundOpts`, `receiver-bound-calls.ts:405-406`): + +1. `emitReceiverBoundCalls` Case 1 — namespace-receiver member calls (`receiver-bound-calls.ts:832`); +2. `resolveConstructionExpressionClass` — namespace-qualified construction `pkg.db.Model()` (`compound-receiver.ts:245-260`); +3. `resolveCompoundReceiverClass`'s namespace-qualified-constructor disambiguation `options.namespaceTargets?.has(objExpr)` (`compound-receiver.ts:759-766`). + +AGENTS.md line 42 is the binding constraint: *"Shared code in `gitnexus/src/core/ingestion/` must not name languages — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks."* `[verified]` + +## 4. GitNexus Findings + +- `context({name: 'collectNamespaceTargets', repo: 'GitNexus'})` — `epistemic: "exact"`; incoming calls are exactly two: `emitReceiverBoundCalls` (`.../passes/receiver-bound-calls.ts`) and a test-local `build` in `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts`. `[graph]` These are the d=1 dependents; the two `compound-receiver.ts` consumers reach the map by parameter rather than by call, so they do not appear here and were found by source grep `[verified]`. +- `context({name: 'resolveQualifiedReceiverMember', repo: 'GitNexus'})` — resolves to a single definition at `languages/cpp/scope-resolver.ts:399`, `outgoing.calls: [resolveCppQualifiedNamespaceMember]`, no incoming. `[graph]` Confirms the Case-1.5 hook is C++-only, matching the issue reporter's read of the published bundle. +- `cypher({statement: "MATCH ()-[r:CodeRelation {type: 'CDG'}]->() RETURN count(r)"})` — `| cdg_rows | 0 |`. `[graph]` The index carries no PDG layer; §5 is therefore empty by fact, not by omission. +- Related tests located by directory listing `[verified]`: `test/fixtures/lang-resolution/` already holds `python-module-import`, `python-bare-import`, `python-plain-import-alias`, `python-multi-segment-ancestor-import`, `python-function-local-namespace-import`, `python-class-body-namespace-import`, and #2770's `python-from-module-alias`. `test/integration/resolvers/python.test.ts` is the convention-matching home for the new assertions (#2770 added its coverage there, +38 lines). + +## 5. Statement-Level PDG Findings + +Empty by fact: the current index has zero `CDG` rows, so no statement-level slice exists to build. A `--pdg` re-index was deliberately not run — it is the largest fixed cost available to this session, the analyzer holds no writer lock against a live MCP server (#2658), and every constraint the slice would supply (which case gates the namespace lookup, whether Case 0's failure falls through) was read directly from source at higher evidence strength in §2. + +## 6. Proposed Changes + +### 6.1 `collectNamespaceTargets` — also key on the dotted access path + +- **File:** `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` +- **Symbol:** `collectNamespaceTargets` (source-verified) +- **Responsibility:** map every receiver spelling that names an imported module to that module's file(s). +- **Change:** inside the existing edge loop, after recording `edge.localName`, also record `edge.targetExportedName` **when it contains a dot and its first dot-separated segment equals `edge.localName`**. Same array-dedupe as the existing key. +- **Why this is language-neutral (AGENTS.md §42):** the condition names no language. It encodes one structural fact — *a namespace binding whose exported module name is a dotted path rooted at the local name is also reachable under that whole path.* Verified against every other namespace-emitting provider at the pinned commit `[verified]`: + - TypeScript `import * as X from './y'` → `localName 'X'`, `importedName './y'`; first segment `''` ≠ `'X'` → no key (`languages/typescript/interpret.ts:77-81, 118-122`). + - C# `using System.Collections.Generic` → `localName 'Generic'` (last segment), `importedName 'System.Collections.Generic'`; first segment `'System'` ≠ `'Generic'` → no key (`languages/csharp/interpret.ts:33-37, 62-66`). + - Go / Rust / Ruby → `localName === importedName`, no dot → no key (`languages/{go,rust,ruby}/interpret.ts`). + - Python `import pkg.db` → `'pkg' === 'pkg.db'.split('.')[0]` → key `'pkg.db'` added. This is the only provider the predicate admits today. +- **Constraint:** additive only. The existing `localName` key must keep its current value and ordering so no currently-resolving site changes target. +- **Two-package safety:** `import pkg.a` + `import pkg.b` in one file yields `{'pkg' → ['pkg/a.py','pkg/b.py'], 'pkg.a' → ['pkg/a.py'], 'pkg.b' → ['pkg/b.py']}`. Receiver `pkg.a` hits exactly one file; the ambiguous `'pkg'` bucket is only reachable by a receiver literally spelled `pkg`, which is unchanged from today. `[inferred]` — pinned by a test in §8. + +### 6.2 `isNamespaceNameShadowed` — test the root segment, not the dotted path + +- **File:** `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` +- **Symbol:** `isNamespaceNameShadowed` (source-verified, lines 152-183) and its one call site at line 250. +- **Defect this fix activates:** the guard walks the scope chain looking for a binding, type binding, lexical name, or owned def **named exactly `namespaceName`**. With 6.1 in place, `namespaceName` can be `'pkg.db'`, but Python binds only `pkg` — so a local `pkg = something` that genuinely shadows the import would fail to suppress the namespace interpretation, and the "verified namespace is authoritative" branch (line 249-259) would return a wrong class instead of declining. +- **Change:** shadow-test the first dot-separated segment of `namespaceName` (identical behaviour for the single-segment names it sees today, since root === whole name). +- **Not scope creep:** 6.1 is what first routes a dotted name into this guard; shipping 6.1 without it introduces the false positive. + +### 6.3 No change required in `receiver-bound-calls.ts` + +Case 1's lookup already uses the full dotted `receiverName` and Case 0's failure already falls through to it (`receiver-bound-calls.ts:577, 622-655, 832`) `[verified]`. Recorded here so the executor does not "fix" a path that is already correct. + +## 7. Implementation Sequence + +1. **Add the failing fixture and assertions first.** Create `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/` (files in §8) and a `describe` block in `gitnexus/test/integration/resolvers/python.test.ts` following the file's existing `writeFixtureRepo` + `mkdtempSync` convention. Confirm the dotted row fails and all three control rows pass. Delete the scratch `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` in this step — its content is superseded by the fixture-backed tests. +2. **Implement 6.1** in `namespace-targets.ts`, and update its header contract note to state that a namespace edge may be keyed both by its local name and by a dotted access path rooted at that name. Re-run the step-1 tests: the dotted row must flip to passing with the controls still green. +3. **Implement 6.2** in `compound-receiver.ts` with the shadowing test from §8 (a local `pkg = Decoy()` must suppress, not misresolve). +4. **Run the regression surface**: full resolver + scope-resolution integration suites, both packages' `tsc --noEmit`. +5. **Regenerate recorded baselines once, last.** Run each `--check` gate; regenerate only the baselines that actually moved (`bench/receiver-resolution/baseline.json` is the expected one — this change adds resolved edges). Per plan-template §7, this is deliberately the final step so intermediate commits do not churn and re-drift the artifacts. + +## 8. Test Strategy + +**New fixture** `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/`: + +| file | contents | +| --- | --- | +| `pkg/__init__.py` | empty | +| `pkg/db.py` | `def session_scope(): ...` | +| `pkg/cache.py` | `def session_scope(): ...` — the decoy that makes cross-resolution detectable | +| `caller_dotted.py` | `import pkg.db` + `def uses_dotted(): return pkg.db.session_scope()` | +| `caller_from.py`, `caller_alias.py`, `caller_frommod.py` | the three sibling controls from the issue | +| `caller_two_pkgs.py` | `import pkg.db` **and** `import pkg.cache`, one function calling each | +| `caller_deep.py` | `import pkg.sub.deep` + `pkg.sub.deep.f()` (3-segment) | +| `caller_shadowed.py` | module-level `import pkg.db`, then a function with a local `pkg = Decoy()` before `pkg.db.session_scope()` | + +**Scenarios** (input → action → expected): + +1. `caller_dotted.py` → run pipeline → `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope`, `reason: 'import-resolved'`. **This is the issue's acceptance row.** +2. The three sibling callers → same run → all three still resolve to `pkg/db.py:session_scope`. Regression control: a run where the controls also broke would prove nothing about row 1. +3. `caller_two_pkgs.py` → `pkg.db.session_scope()` resolves **only** to `pkg/db.py` and `pkg.cache.session_scope()` **only** to `pkg/cache.py`; assert the absence of the crossed pair explicitly, not just the presence of the right one. +4. `caller_deep.py` → 3-segment receiver resolves — proves the predicate is not hard-coded to two segments. +5. `caller_shadowed.py` → **no** edge from the shadowed function to `pkg/db.py` (6.2's guard). Fails loudly if 6.2 regresses. +6. Cross-language non-regression: the existing TypeScript / C# / Go namespace-import resolver tests must stay green unchanged — that is the executable proof the new key is not minted for them. + +**Tests to update:** `gitnexus/test/integration/resolvers/python.test.ts` (add the describe block). `gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts` is a direct `collectNamespaceTargets` caller — re-run it; extend it only if its expectations enumerate map keys exhaustively. + +**Verification commands** (each verified to exist in `gitnexus/package.json` / `.github/workflows/ci-tests.yml` at the pinned commit): + +```bash +# from gitnexus/ — pretest:integration runs scripts/build.js, so the parse worker exists +GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts +GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers +npm run test:unit -- test/unit/scope-resolution +npx tsc --noEmit # and the same in ../gitnexus-shared +node --import tsx bench/receiver-resolution/measure.mjs --check +node --import tsx bench/python-scope/measure.mjs --check +node --import tsx bench/python-scope/import-target-fingerprint.mjs --check +node --import tsx bench/scope-capture/measure.mjs --check +``` + +`GITNEXUS_WORKER_READY_TIMEOUT_MS=60000` is required on this host: the default 5000 ms worker-ready deadline fails as a crash-loop here (observed while reproducing the issue), which is environmental, not a code fault. + +## 9. Risk and Impact Analysis + +Accounting for every direct (d=1) dependent of the changed map: + +| d=1 dependent | risk | mitigation | +| --- | --- | --- | +| `emitReceiverBoundCalls` Case 1 (`receiver-bound-calls.ts:832`) | New keys make previously-dropped sites resolve. A wrong target would be a *new* false edge. | The predicate admits only Python's `import a.b` shape; each new key maps to exactly one file per import statement. §8 scenario 3 pins non-crossing. | +| `resolveConstructionExpressionClass` (`compound-receiver.ts:245-260`) | `pkg.db.Model()` now takes the "verified namespace is authoritative" branch, which deliberately does **not** fall through on a miss or ambiguity — so a wrong key would convert a working heuristic resolution into a silent decline. | The branch requires `namespaceFiles.length > 0`, i.e. the import genuinely resolved. Ambiguity still returns `undefined` (`namespaceMatches.length === 1` guard). Shadowing is fixed by 6.2. | +| `resolveCompoundReceiverClass` namespace-constructor disambiguation (`compound-receiver.ts:759-766`) | `namespaceTargets.has(objExpr)` now true for dotted namespaces, routing `pkg.db.Model(x).run()` into the construction interpretation. | Correct by intent — that branch exists precisely to make a namespace-qualified bare constructor safe. Behaviour change, so §8 should include a construction row if the fixture's cost is low. | +| `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts:build` | May assert exact map contents. | Re-run in step 4; extend rather than weaken if it enumerates keys. | +| C++ provider | Case 1 is skipped entirely for C++ (`provider.resolveQualifiedReceiverMember !== undefined`), but the two `compound-receiver.ts` consumers are **not** provider-gated. | C++ `#include` does not produce a `kind: 'namespace'` edge with a dotted `targetExportedName` rooted at its local name; the predicate declines. Covered by the existing C++ suites plus `bench/cpp-qualified-ns/measure.mjs --check`. | + +**Recorded-artifact risk:** `bench/receiver-resolution/measure.mjs --check` gates both a shape matrix and a drop-count arm; new resolved edges are expected to move the count arm and the gate fails on drift. Regenerating in step 5 only (per §7) keeps intermediate commits clean. `bench/python-scope/*` and `bench/scope-capture/*` fingerprint captures and import-target resolution — neither is touched by this change, so a movement there is a signal to stop and investigate, not to regenerate. + +**Performance:** one extra `Map.set` per multi-segment namespace import per file; the loop is already O(module import edges). No new traversal. + +**No schema/version impact:** this changes what the resolver produces, not how it is stored. Existing indexes need a re-analyze to show the new edges — matching the note PR #2810 carried for the same reason. + +## 10. Files Expected to Change + +| File | Symbols | Reason | +| ---- | ------- | ------ | +| `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` | `collectNamespaceTargets` | Add the dotted-access-path key (§6.1) and update the contract note | +| `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` | `isNamespaceNameShadowed` | Shadow-test the root segment (§6.2) | +| `gitnexus/test/integration/resolvers/python.test.ts` | new `describe` block | Issue acceptance row + controls + regression rows | +| `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/**` | — | New fixture (§8) | +| `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` | — | Delete; superseded by the fixture-backed tests | +| `gitnexus/bench/receiver-resolution/baseline.json` | — | Regenerate once, final step, only if `--check` moves | + +## 11. Reusable Implementation Context + +```yaml +implementation_context: + task_summary: > + Python `import pkg.db` + `pkg.db.session_scope()` emits no CALLS edge (#2826). + Root cause: collectNamespaceTargets keys its map only on ImportEdge.localName + ('pkg'), while the receiver text is the full dotted path ('pkg.db'). Fix by + additionally keying on ImportEdge.targetExportedName when it is dotted and + rooted at localName — a predicate no other provider satisfies — plus a + root-segment fix to the shadow guard the new key first exposes. + acceptance_criteria: + - 'CALLS edge uses_dotted -> pkg/db.py:session_scope with reason import-resolved' + - 'The three sibling spellings (from-import, alias, from-module-attr) still resolve' + - 'import pkg.a + import pkg.b in one file do not cross-resolve' + - 'A local binding shadowing the package root suppresses the namespace interpretation' + - 'No shared file under gitnexus/src/core/ingestion/ names a language (AGENTS.md §42)' + + evidence_provenance: + schema_version: 2 + head_commit: 'b2cd1c2ad637657125248c0dd2046de71ceea965' + generated_plan_path: 'docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md' + global_dirty_digest: + algorithm: 'sha256' + canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records' + value: '0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c' + cited_path_manifest: + - path: '.github/workflows/ci-tests.yml' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + index_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + worktree_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + untracked_digest: 'absent' + - path: 'AGENTS.md' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + index_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + worktree_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + untracked_digest: 'absent' + - path: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + index_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + worktree_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + untracked_digest: 'absent' + - path: 'gitnexus-shared/src/scope-resolution/types.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + index_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + worktree_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/import-decomposer.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + index_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + worktree_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/interpret.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + index_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + worktree_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/query.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + index_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + worktree_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-extractor.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + index_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + worktree_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + index_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + worktree_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + index_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + worktree_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + index_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + worktree_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + untracked_digest: 'absent' + - path: 'gitnexus/test/integration/resolvers/python.test.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + index_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + worktree_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + untracked_digest: 'absent' + - path: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts' + object_kind: { head: absent, index: absent, worktree: absent, untracked: regular } + state: 'untracked' + rename_from: null + rename_to: null + head_digest: 'absent' + index_digest: 'absent' + worktree_digest: 'absent' + untracked_digest: 'sha256:6fe3a74a69db12a1a0aeceef2b32eb0c04d5e4880fc93b7b840348118e70078c' + + primary_symbols: + - symbol: 'collectNamespaceTargets' + file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + lines: '39-57' + role: 'The defect site — builds the receiver-name → target-file map keyed only on localName' + - symbol: 'interpretPythonImport' + file: 'gitnexus/src/core/ingestion/languages/python/interpret.ts' + lines: '33-42' + role: 'Splits `import a.b` into localName "a" / importedName "a.b"; source of both halves' + - symbol: 'emitReceiverBoundCalls' + file: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts' + lines: '404-421, 546-655, 831-848' + role: 'Case 0 declines on a module receiver and falls through; Case 1 does the failing map lookup' + - symbol: 'isNamespaceNameShadowed' + file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + lines: '152-183' + role: 'Shadow guard that must test the root segment once dotted keys exist' + - symbol: 'finalizeImportEdges' + file: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts' + lines: '398-406, 434-447' + role: 'Carries importedName onto ImportEdge.targetExportedName for namespace edges' + + related_symbols: + - symbol: 'resolveQualifiedReceiverMember' + relationship: 'ScopeResolver hook, C++-only implementer' + relevance: 'Case 1.5 — deliberately NOT the fix path; implementing it for Python would duplicate what Case 1 already does' + - symbol: 'resolveConstructionExpressionClass' + relationship: 'consumes namespaceTargets by parameter' + relevance: 'Second consumer of the map; gains correct pkg.db.Model() resolution' + - symbol: 'resolveCompoundReceiverClass' + relationship: 'consumes namespaceTargets by parameter (compound-receiver.ts:759-766)' + relevance: 'Third consumer; has() now true for dotted namespaces' + - symbol: 'isNamespaceImport' + relationship: 'finalize hook added by #2770' + relevance: 'Prior art — how the from-pkg-import-db sibling was made to resolve' + - symbol: 'build' + relationship: 'test-of collectNamespaceTargets' + relevance: 'test/unit/scope-resolution/python/python-module-namespace-construction.test.ts — re-run after the change' + + execution_path: + - 'splitImportStatement emits one match per imported name; @import.source = full dotted_name text' + - 'interpretPythonImport plain arm → ParsedImport{kind:namespace, localName:first-segment, importedName:full-dotted}' + - 'finalizeImportEdges → ImportEdge{localName, targetExportedName=importedName, targetFile, kind:namespace}' + - 'collectNamespaceTargets builds Map keyed on localName only ← DEFECT' + - 'scope-extractor extractExplicitReceiver takes raw text of the attribute object → "pkg.db"' + - 'emitReceiverBoundCalls Case 0 declines (module, not class), falls through without marking handled' + - 'Case 1 map lookup on "pkg.db" misses; Case 1.5 skipped (no Python hook); site drops silently' + + pdg_constraints: [] # index has zero CDG rows; no --pdg layer to slice + + architectural_patterns: + - pattern: 'Provider reclassification at finalize instead of shared-code special-casing' + example_location: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107 (isNamespaceImport, #2770)' + usage_guidance: 'Considered and rejected here: the information needed is already on the finalized edge, so no new hook is warranted' + - pattern: 'Verified namespace is authoritative — do not fall through to workspace-wide simple-name heuristics' + example_location: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts:245-259' + usage_guidance: 'Because that branch declines rather than guessing, a wrong key costs a lost edge, not a wrong one — but the shadow guard must be right' + - pattern: 'Fixture + assertions in test/integration/resolvers/python.test.ts' + example_location: 'gitnexus/test/integration/resolvers/python.test.ts:562-600 (vendored-django guard)' + usage_guidance: 'mkdtempSync + writeFixtureRepo + afterAll rmSync; assert both presence of the right edge and absence of the wrong one' + + files_to_modify: + - file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + symbols: ['collectNamespaceTargets'] + intended_change: 'Additionally key the map on edge.targetExportedName when it contains a dot and its first segment equals edge.localName; keep the existing localName key unchanged; update the header contract note' + - file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + symbols: ['isNamespaceNameShadowed'] + intended_change: 'Shadow-test the first dot-separated segment of namespaceName (no-op for single-segment names)' + - file: 'gitnexus/test/integration/resolvers/python.test.ts' + symbols: [] + intended_change: 'Add a describe block covering the six §8 scenarios' + - file: 'gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/' + symbols: [] + intended_change: 'New fixture per the §8 table' + - file: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts' + symbols: [] + intended_change: 'Delete — superseded by the fixture-backed tests' + + tests: + - file: 'gitnexus/test/integration/resolvers/python.test.ts' + scenarios: + - 'import pkg.db + pkg.db.session_scope() → run pipeline → CALLS uses_dotted → pkg/db.py:session_scope, reason import-resolved' + - 'three sibling spellings in the same repo → run pipeline → all still resolve to pkg/db.py:session_scope (control)' + - 'import pkg.db AND import pkg.cache in one file, both defining session_scope → each call resolves only to its own module; assert the crossed pair is ABSENT' + - 'import pkg.sub.deep + pkg.sub.deep.f() → 3-segment receiver resolves' + - 'module-level import pkg.db shadowed by a function-local pkg = Decoy() → NO edge to pkg/db.py' + - 'existing TypeScript/C#/Go namespace-import resolver tests → unchanged green (no key minted for them)' + - file: 'gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts' + scenarios: + - 'Re-run unchanged; extend only if it enumerates map keys exhaustively' + + verification_commands: + - 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts' + - 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers' + - 'cd gitnexus && npm run test:unit -- test/unit/scope-resolution' + - 'cd gitnexus && npx tsc --noEmit' + - 'cd gitnexus-shared && npx tsc --noEmit' + - 'cd gitnexus && node --import tsx bench/receiver-resolution/measure.mjs --check' + - 'cd gitnexus && node --import tsx bench/python-scope/measure.mjs --check' + - 'cd gitnexus && node --import tsx bench/python-scope/import-target-fingerprint.mjs --check' + - 'cd gitnexus && node --import tsx bench/scope-capture/measure.mjs --check' + + risks: + - 'New map keys reach three consumers, two of them by parameter rather than by call — the graph d=1 list alone under-reports them' + - 'compound-receiver treats a verified namespace as authoritative and declines instead of falling through, so a bad key loses edges silently' + - 'bench/receiver-resolution/baseline.json is expected to move; regenerate ONCE in the final step' + - 'Default 5000 ms worker-ready timeout crash-loops on this host; export GITNEXUS_WORKER_READY_TIMEOUT_MS=60000' + + assumptions: + - 'Every non-Python provider fails the dotted-rooted-at-localName predicate. CHECK: grep "kind: .namespace." across gitnexus/src/core/ingestion/languages/*/interpret.ts and confirm localName is never the first segment of a dotted importedName. Verified at b2cd1c2ad for typescript, csharp, go, rust, ruby.' + - 'python.test.ts is no longer gated behind REGISTRY_PRIMARY_PYTHON. CHECK: grep REGISTRY_PRIMARY in that file — zero hits at b2cd1c2ad, so it runs unconditionally.' + - 'The GitNexus index is 13 commits behind but byte-identical on every cited path. CHECK: git rev-parse 1ef6447e: vs b2cd1c2ad:.' + + open_questions: + - 'Should the misleading localName-only key for a dotted import (pkg → pkg/db.py) be removed? It can produce a false positive today: pkg.helper() resolves into pkg/db.py if db.py happens to define helper. Deferred — a separate behaviour change needing its own regression pass.' + - 'Python`s `import a.b.c` also makes `a.b` reachable. The proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Deferred as a narrower follow-up.' + - 'C# `using System.Collections.Generic` + `System.Collections.Generic.List` is the same class of gap and is deliberately NOT addressed here (its localName is the last segment, so the predicate declines). Worth its own issue.' + + avoid: + - 'Do not repeat full repository discovery' + - 'Do not replace established patterns without evidence' + - 'Do not implement resolveQualifiedReceiverMember for Python — Case 1 already does this job; a second path would double-resolve' + - 'Do not change ImportEdge.localName for dotted imports (interpret.ts:38) — it is the deliberate `import a.b.c exposes a` semantics and other consumers depend on it' + - 'Do not name a language in gitnexus/src/core/ingestion/ shared code (AGENTS.md §42)' + - 'Do not regenerate bench baselines per step — only once, in the final step' + - 'Do not weaken an existing test to accommodate the new keys; extend it instead' +``` + +## 12. Assumptions and Open Questions + +**Assumptions** (each re-checkable cheaply by the executor): + +1. Every non-Python namespace-emitting provider fails the `dotted && first segment === localName` predicate. Verified at `b2cd1c2ad` for TypeScript, C#, Go, Rust and Ruby by reading each `interpret.ts`; JavaScript, Java and PHP emit no `kind: 'namespace'` import there. **Re-check:** grep `kind: 'namespace'` across `gitnexus/src/core/ingestion/languages/*/interpret.ts`. +2. `python.test.ts` runs unconditionally — no `REGISTRY_PRIMARY_PYTHON` gate remains at the pinned commit (zero grep hits). An older parity-leg convention no longer applies. +3. The index's 13-commit lag is harmless here because every cited path is byte-identical at the index commit and the pinned commit. + +**Open questions / explicitly deferred:** + +- **The bogus first-segment key.** For `import pkg.db`, the map still holds `'pkg' → ['pkg/db.py']`, so `pkg.helper()` would resolve into `pkg/db.py` if that file happens to define `helper` — a pre-existing false positive this plan does **not** fix. Removing it is a separate behaviour change with its own regression surface (`python-multi-segment-ancestor-import`, `python-bare-import`). Worth pinning the current behaviour in a test so it is visible rather than silent. +- **`import a.b.c` also binds `a.b`.** Python makes intermediate packages reachable; the proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Narrower follow-up. +- **C# has the mirror-image gap.** `using System.Collections.Generic` + `System.Collections.Generic.List` fails the predicate because C# sets `localName` to the *last* segment. Deliberately out of scope; deserves its own issue. +- **Construction coverage.** §8 does not currently include a `pkg.db.Model()` row. Add one if the fixture cost is trivial — that path (`compound-receiver.ts:245-260`) changes behaviour and is otherwise untested by this plan. + +## 13. Definition of Done + +1. `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope` (`reason: 'import-resolved'`) is emitted, asserted by a fixture-backed test in `python.test.ts`. +2. All three sibling control rows still resolve in the same run. +3. `import pkg.a` + `import pkg.b` in one file resolve only to their own modules; the crossed pair is asserted **absent**. +4. A 3-segment receiver resolves; a package root shadowed by a local binding does **not**. +5. The scratch `repro-2826-python-dotted-import.test.ts` is deleted. +6. No file under `gitnexus/src/core/ingestion/` names a language. +7. `npm run test:integration -- test/integration/resolvers` and `npm run test:unit -- test/unit/scope-resolution` pass; `tsc --noEmit` clean in both packages. +8. Every bench `--check` in §8 passes, with `bench/receiver-resolution/baseline.json` regenerated exactly once in the final commit if and only if it moved — and any movement in `python-scope`/`scope-capture` investigated rather than regenerated. diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index d30823b92..d06912cf5 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -439,3 +439,76 @@ export function isPythonImportedModule( normalizedTarget.endsWith('/' + packageFile) ); } + +/** + * The receiver spellings `import a.b.c` makes callable, and the file each one + * names (#2826). + * + * `import a.b.c` binds ONE name — `a` — but makes three attribute paths + * reachable, and they name three different files: + * + * a → a/__init__.py + * a.b → a/b/__init__.py + * a.b.c → a/b/c.py (the edge's own target) + * + * The shared default keyed `a` to the LEAF, which is wrong in both directions: + * `a.helper()` resolved into `a/b/c.py` whenever that module happened to export + * `helper`, and `a.b.mid()` resolved to nothing. + * + * Returns `undefined` — meaning "use the shared default" — for every spelling + * where the bound name is not the path's root: + * - `import single` — no dotted path to expand; + * - `import a.b as x` — binds only `x`; writing `a.b.f()` there is a + * NameError, so `a.b` must NOT become a key; + * - `from pkg import db` — reclassified to a namespace edge whose + * importPath is the bare name `db`. + * + * Prefix files are proposed, not asserted: `moduleFileExists` drops any that + * the workspace did not parse, so a PEP-420 namespace package (no + * `__init__.py`) contributes no key rather than one pointing at a missing file. + */ +export function pythonNamespaceReceiverPaths( + edge: { readonly localName: string; readonly importPath: string; readonly targetFile: string }, + moduleFileExists: (filePath: string) => boolean, +): readonly (readonly [string, string])[] | undefined { + const segments = edge.importPath.split('.'); + if (segments.length < 2) return undefined; + if (segments[0] !== edge.localName) return undefined; + + const out: (readonly [string, string])[] = [[edge.importPath, edge.targetFile]]; + + // Anchor the prefix packages on the RESOLVED leaf, never on the import + // spelling. `resolvePythonImportTarget` resolves off-root in two of its three + // tiers (suffix match and ancestor-relative), so `import utils.db` can land on + // `libs/common/utils/db.py`. Building `utils/__init__.py` from the spelling + // would then name a DIFFERENT package that merely shares the root segment — + // a wrong edge — and in a `src/` layout it would match nothing at all, + // silently making prefix keying inert for the most common Python layout. + // + // Walking back from the leaf also inherits that path's own separator, so no + // POSIX-vs-Windows probing is needed: workspace paths are not normalized at + // ingestion, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`. + const dirs = edge.targetFile.split('/').slice(0, -1); + // The import's leading segments name the leaf's innermost directories. + const offset = dirs.length - (segments.length - 1); + if (offset < 0) return out; + + for (let i = 1; i < segments.length; i++) { + const spelling = segments.slice(0, i).join('.'); + const packageFile = dirs.slice(0, offset + i).join('/') + '/__init__.py'; + // Package FIRST, then the leaf as a fallback — order is the whole point. + // + // `findExportedDef` only accepts a binding whose `origin === 'local'`, and + // the canonical package re-exports (`from .b.c import helper` in + // `__init__.py`) produce an IMPORT binding. Keying the prefix at the + // package alone therefore loses `a.helper()` entirely for the most common + // package shape — the fixtures here all define members locally in + // `__init__.py`, which is precisely the one layout where that mistake is + // invisible. Keeping the leaf behind the package restores that resolution + // while still letting a real definition in `__init__.py` win over a + // same-named decoy deeper in the package. + if (moduleFileExists(packageFile)) out.push([spelling, packageFile]); + out.push([spelling, edge.targetFile]); + } + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index 5cb5bda4d..dafddf357 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -78,6 +78,7 @@ export { pythonMergeBindings } from './merge-bindings.js'; export { pythonArityCompatibility } from './arity.js'; export { isPythonImportedModule, + pythonNamespaceReceiverPaths, resolvePythonImportTarget, type PythonResolveContext, } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 12b20ded8..25bee8f96 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -21,6 +21,7 @@ import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { pythonProvider } from '../python.js'; import { isPythonImportedModule, + pythonNamespaceReceiverPaths, pythonArityCompatibility, pythonMergeBindings, resolvePythonImportTarget, @@ -57,6 +58,12 @@ const pythonScopeResolver: ScopeResolver = { isNamespaceImport: (parsedImport, targetFile, fromFile) => isPythonImportedModule(parsedImport, targetFile, fromFile), + // `import a.b.c` binds only `a`, yet makes `a`, `a.b` and `a.b.c` all + // callable — each naming a different file. Without this the absolute-import + // style is invisible to the call graph, and the root key points at the leaf + // module instead of the package (#2826). + namespaceReceiverPaths: pythonNamespaceReceiverPaths, + // Python LEGB precedence: local > import/namespace/reexport > wildcard. // The per-scope id is unused by pythonMergeBindings (tier ordering // is computed purely from BindingRef.origin), so we don't need to diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 089d98364..a02f407c0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1084,6 +1084,44 @@ export interface ScopeResolver { callsite?: Callsite, ) => SymbolDefinition | 'ambiguous' | undefined; + /** + * Every receiver spelling under which a namespace import's target is + * reachable, and the file each spelling names (#2826). Returning + * `undefined` keeps the shared default: the local binding name alone, + * mapped to the edge's own target file. + * + * Python needs this because one `import a.b.c` statement binds THREE + * spellings at once — `a`, `a.b` and `a.b.c` — each naming a DIFFERENT + * file (`a/__init__.py`, `a/b/__init__.py`, `a/b/c.py`), while + * `ImportEdge` carries only the leaf. The default keyed `a` (its + * `localName`) to the LEAF, so `a.helper()` resolved into `a/b/c.py` + * whenever that module happened to export `helper` — a wrong edge — and + * `a.b.mid()` resolved to nothing at all. + * + * Shared code cannot derive this. Swift's `import Foo.Bar` produces an + * edge shape identical to Python's (`localName: 'Foo'`, + * `targetExportedName: 'Foo.Bar'`), yet there the FIRST segment is the + * resolved target and `Foo.Bar` names a nested TYPE; keying it would hand + * `resolveConstructionExpressionClass` an authoritative namespace — that + * branch deliberately does not fall through on a miss — and break + * `Foo.Bar(x)` construction that resolves correctly today. And the + * `__init__.py` convention that turns a dotted prefix into a file is + * Python's alone. + * + * `moduleFileExists` reports whether a path is a module the workspace + * actually parsed, so a provider can propose a prefix file and have it + * dropped when absent (a PEP-420 namespace package has no `__init__.py`) + * rather than minting a key to a file that is not there. + */ + readonly namespaceReceiverPaths?: ( + edge: { + readonly localName: string; + readonly importPath: string; + readonly targetFile: string; + }, + moduleFileExists: (filePath: string) => boolean, + ) => readonly (readonly [spelling: string, targetFile: string])[] | undefined; + /** * Optional language-specific member-lattice lookup. Runs for a resolved * simple receiver type before the generic flattened-MRO walk. Languages diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 39f98b467..72f2dcab7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -35,6 +35,7 @@ import { findExportedDefByName, findReceiverTypeBinding, isClassLike, + isNamespaceNameShadowed, } from '../scope/walkers.js'; /** Max depth for compound-receiver chain resolution (`a().b().c().d()`). @@ -156,42 +157,6 @@ function escapeForRegExp(literal: string): string { return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** True when a local declaration between the call site and its module scope - * shadows a file-level namespace import with the same name. Namespace targets - * are collected per file, so callers must apply this lexical guard before - * trusting them at an inner scope. */ -function isNamespaceNameShadowed( - namespaceName: string, - inScope: ScopeId, - scopes: ScopeResolutionIndexes, -): boolean { - let currentId: ScopeId | null = inScope; - const visited = new Set(); - while (currentId !== null) { - if (visited.has(currentId)) return true; - visited.add(currentId); - const scope = scopes.scopeTree.getScope(currentId); - if (scope === undefined) return true; - if ( - scope.kind !== 'Object' && - (scope.bindings.has(namespaceName) || - scope.typeBindings.has(namespaceName) || - scope.lexicalNames?.has(namespaceName) === true || - scope.ownedDefs.some((def) => { - const qualifiedName = def.qualifiedName; - if (qualifiedName === undefined) return false; - const dot = qualifiedName.lastIndexOf('.'); - return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === namespaceName; - })) - ) { - return true; - } - if (scope.kind === 'Module') return false; - currentId = scope.parent; - } - return true; -} - /** * Type of a construction expression's callee — the class it constructs. * diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 1202687a4..ce7ae7902 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -65,6 +65,7 @@ import { findReceiverTypeBinding, findValueBindingInScope, isClassLike, + isNamespaceNameShadowed, type DecorationStripper, } from '../scope/walkers.js'; import { @@ -107,6 +108,7 @@ type ReceiverBoundProviderSubset = Pick< | 'constructionSyntax' | 'stripTypePreservingDecoration' | 'resolveQualifiedReceiverMember' + | 'namespaceReceiverPaths' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' @@ -524,7 +526,10 @@ export function emitReceiverBoundCalls( }; for (const parsed of parsedFiles) { - const namespaceTargets = collectNamespaceTargets(parsed, scopes); + const namespaceTargets = collectNamespaceTargets(parsed, scopes, { + receiverPaths: provider.namespaceReceiverPaths, + moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), + }); const fileCompoundOpts = { ...compoundOpts, namespaceTargets }; // Per-file resolved-callee-id capture context (#2227 U2). Built once per // file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge` @@ -993,7 +998,24 @@ export function emitReceiverBoundCalls( } // ── Case 1: namespace receiver ─────────────────────────────── - const targetFiles = namespaceTargets.get(receiverName); + // `namespaceTargets` is collected per FILE, so a local declaration that + // shadows the import must suppress it — `def f(pkg): pkg.db.query()` + // calls a method on the PARAMETER, and resolving it through the import + // emits a wrong edge, not a missing one. The compound-receiver + // construction path has applied this guard since #2770; Case 1 never did, + // for dotted and single-segment receivers alike. + // Map lookup FIRST: it is an O(1) miss for almost every site, and the + // guard is a scope-chain walk (a Set allocation plus a linear `ownedDefs` + // scan per level). Guarding before looking up would charge that walk to + // every explicit-receiver site in every language, for a candidate set + // that is usually empty. Mirrors the order the compound-receiver + // construction path already uses. + const namespaceCandidates = namespaceTargets.get(receiverName); + const targetFiles = + namespaceCandidates !== undefined && + !isNamespaceNameShadowed(receiverName, site.inScope, scopes) + ? namespaceCandidates + : undefined; if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { let found = false; for (const targetFile of targetFiles) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index f82a01be2..e80b7f359 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -20,6 +20,15 @@ * syntax or reclassify a named import after target resolution proves it names * a module. * + * A namespace edge may be reachable under TWO receiver spellings: the name it + * binds locally, and — for a language that opts in via + * `ScopeResolver.namespaceReceiverIncludesImportPath` — the dotted module path + * it was imported under (#2826). Python's `import a.b` binds only `a` while + * the call site writes `a.b`, so both keys are needed. The opt-in exists + * because the edge shape alone cannot tell that case from Swift's + * `import Foo.Bar`, where the same pair means the opposite thing — see the + * hook's contract note. + * * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` * documents that function-local and class-body imports bind to the * inner scope, which would make a module-only read incomplete. In @@ -36,23 +45,58 @@ import type { ParsedFile } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { ScopeResolver } from '../contract/scope-resolver.js'; + +export interface NamespaceTargetOptions { + /** `ScopeResolver.namespaceReceiverPaths` for the file's language. Absent + * (or returning `undefined` per edge) keeps the local-name-only default: + * the extra spellings are opt-in, never inferred from the edge shape. */ + readonly receiverPaths?: ScopeResolver['namespaceReceiverPaths']; + /** Whether a path is a module the workspace parsed. Lets a provider propose + * a prefix file and have it dropped when absent, instead of minting a key + * to a file that does not exist. Defaults to "nothing exists". */ + readonly moduleFileExists?: (filePath: string) => boolean; +} export function collectNamespaceTargets( parsed: ParsedFile, scopes: ScopeResolutionIndexes, + options?: NamespaceTargetOptions, ): Map { const out = new Map(); const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; - for (const edge of moduleEdges) { - if (edge.targetFile === null || edge.kind !== 'namespace') continue; - let targets = out.get(edge.localName); + const addTarget = (key: string, targetFile: string): void => { + let targets = out.get(key); if (targets === undefined) { targets = []; - out.set(edge.localName, targets); + out.set(key, targets); } - if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); + if (!targets.includes(targetFile)) targets.push(targetFile); + }; + + const moduleFileExists = options?.moduleFileExists ?? ((): boolean => false); + + for (const edge of moduleEdges) { + if (edge.targetFile === null || edge.kind !== 'namespace') continue; + + const spellings = options?.receiverPaths?.( + { + localName: edge.localName, + importPath: edge.targetExportedName, + targetFile: edge.targetFile, + }, + moduleFileExists, + ); + + // A provider that declines this edge — or has no hook — gets the default: + // the bound name alone, pointing at this edge's own target. + if (spellings === undefined) { + addTarget(edge.localName, edge.targetFile); + continue; + } + for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile); } return out; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index b4b8aeab9..cf9c3560e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -227,6 +227,77 @@ export function isReceiverOwnedButUnbound( return false; } +/** + * True when a declaration between the call site and its module scope shadows a + * file-level namespace import of the same name. Namespace targets are collected + * per FILE, so every consumer of that map must apply this lexical guard before + * trusting it at an inner scope — otherwise `def f(pkg): pkg.db.query()` + * resolves through the import that the parameter shadows, producing a wrong + * edge rather than a missing one. + * + * A namespace key may itself be a dotted import path (`pkg.db`, #2826), but the + * name a declaration can shadow is always the ROOT identifier — `pkg = Decoy()` + * shadows `pkg.db` too. Testing the whole dotted string would never match a + * binding, so the guard would silently stop guarding for exactly the keys it + * was extended to cover. Single-segment names are unaffected: their root is + * themselves. + * + * Fails closed (returns `true`) on a missing scope or a parent cycle: for every + * caller, suppressing a resolution costs a missing edge, while trusting a + * corrupt scope chain costs a wrong one. + * + * Reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, and + * that is deliberate — the opposite of the fix #2745 applied to Rust's + * `headBoundLocally`. There the question was "is this name bound at all?", so + * missing the finalized/augmented import channels lost real bindings. Here the + * question is "does something LOCAL shadow the import?", and the import's own + * finalized binding is the one thing that must NOT count: routing this through + * `lookupBindingsAt` would find the namespace import shadowing itself and + * suppress every namespace receiver in the workspace. Locals, parameters and + * lexical names all live in the scope's own tables, which is exactly the set + * this walk wants. + */ +export function isNamespaceNameShadowed( + namespaceName: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, +): boolean { + const firstDot = namespaceName.indexOf('.'); + const rootName = firstDot === -1 ? namespaceName : namespaceName.slice(0, firstDot); + let currentId: ScopeId | null = inScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return true; + // Stop AT the module scope without inspecting it. In languages where a + // namespace import IS a variable declaration — CommonJS + // `const svc = require('./svc')` — the import puts its own name into the + // module scope's tables, so inspecting them reads the import as its own + // shadow and suppresses every receiver it was meant to enable (#2723). + // The contract is "a declaration BETWEEN the call site and its module + // scope", and the module scope is the floor, not a rung. + if (scope.kind === 'Module') return false; + if ( + scope.kind !== 'Object' && + (scope.bindings.has(rootName) || + scope.typeBindings.has(rootName) || + scope.lexicalNames?.has(rootName) === true || + scope.ownedDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === rootName; + })) + ) { + return true; + } + currentId = scope.parent; + } + return true; +} + export function findReceiverTypeBinding( startScope: ScopeId, receiverName: string, diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index db2c2d155..add7b7bd0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -3102,3 +3102,272 @@ describe('Python inline constructor receiver resolution', () => { ]); }); }); + +// --------------------------------------------------------------------------- +// #2826 — unaliased multi-segment namespace import. +// +// `import pkg.db` binds only `pkg`, but the receiver text at the call site is +// the whole dotted path `pkg.db`. Every sibling spelling binds a single-segment +// name and so already resolved; this one fell between the namespace-receiver +// case (keyed on the local binding) and the qualified-receiver hook (C++ only). +// +// The three sibling rows are controls, not decoration: a run where they also +// broke would say nothing about the row under test. +// --------------------------------------------------------------------------- + +describe('Python unaliased multi-segment namespace import (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-dotted-ns-')); + writeFixtureRepo(repoDir, { + 'pkg/__init__.py': '', + 'pkg/db.py': `class Model: + pass + + +def session_scope(): + return "db" +`, + // Same member name in a sibling module: makes a cross-resolution visible + // instead of letting the right answer and a lucky answer look identical. + 'pkg/cache.py': `def session_scope(): + return "cache" +`, + 'pkg/sub/__init__.py': '', + 'pkg/sub/deep.py': `def deep_fn(): + return "deep" +`, + 'caller_dotted.py': `import pkg.db + +def uses_dotted(): + return pkg.db.session_scope() +`, + 'caller_from.py': `from pkg.db import session_scope + +def uses_from(): + return session_scope() +`, + 'caller_alias.py': `import pkg.db as pdb + +def uses_alias(): + return pdb.session_scope() +`, + 'caller_frommod.py': `from pkg import db + +def uses_from_module_attr(): + return db.session_scope() +`, + 'caller_two_pkgs.py': `import pkg.db +import pkg.cache + +def uses_db(): + return pkg.db.session_scope() + +def uses_cache(): + return pkg.cache.session_scope() +`, + 'caller_deep.py': `import pkg.sub.deep + +def uses_deep(): + return pkg.sub.deep.deep_fn() +`, + 'caller_construct.py': `import pkg.db + +def builds(): + return pkg.db.Model() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + const sessionScopeCallers = (targetFile: string): string[] => + getRelationships(result, 'CALLS') + .filter((c) => c.target === 'session_scope' && c.targetFilePath === targetFile) + .map((c) => c.source) + .sort(); + + it('resolves the unaliased dotted receiver to the imported module', () => { + const edge = getRelationships(result, 'CALLS').find( + (c) => c.source === 'uses_dotted' && c.target === 'session_scope', + ); + expect(edge).toMatchObject({ + source: 'uses_dotted', + target: 'session_scope', + targetFilePath: 'pkg/db.py', + sourceFilePath: 'caller_dotted.py', + }); + }); + + it('keeps the three sibling spellings resolving (control)', () => { + expect(sessionScopeCallers('pkg/db.py')).toEqual( + expect.arrayContaining(['uses_alias', 'uses_from', 'uses_from_module_attr']), + ); + }); + + it('does not cross-resolve two same-package imports in one file', () => { + // Both modules export `session_scope`, so a receiver-blind fallback would + // be invisible in a presence-only assertion. Pin the exact pairing. + const pairs = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller_two_pkgs.py' && c.target === 'session_scope') + .map((c) => `${c.source}->${c.targetFilePath}`) + .sort(); + expect(pairs).toEqual(['uses_cache->pkg/cache.py', 'uses_db->pkg/db.py']); + }); + + it('resolves a three-segment dotted receiver', () => { + const edge = getRelationships(result, 'CALLS').find( + (c) => c.source === 'uses_deep' && c.target === 'deep_fn', + ); + expect(edge).toMatchObject({ target: 'deep_fn', targetFilePath: 'pkg/sub/deep.py' }); + }); + + it('resolves construction through a dotted namespace receiver', () => { + // Pin the callee NAME, not just the file: pkg/db.py also exports + // `session_scope`, so a file-only assertion would stay green if the + // construction resolved to the wrong member of the right module. + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller_construct.py') + .map((c) => `${c.source}->${c.target}@${c.targetFilePath}`) + .sort(); + expect(edges).toEqual(['builds->Model@pkg/db.py']); + }); +}); + +// --------------------------------------------------------------------------- +// #2826 follow-up — a namespace receiver shadowed by a local declaration. +// +// Case 1 (namespace receiver) in `receiver-bound-calls.ts` consulted the +// per-file namespace map with no lexical guard at all, so a parameter or local +// named like the imported package still resolved through the import. That is a +// WRONG edge, not a missing one, and it predates the dotted-path key — the +// single-segment rows below fail the same way without the guard. +// --------------------------------------------------------------------------- + +describe('Python namespace receiver shadowed by a local binding (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-ns-shadow-')); + writeFixtureRepo(repoDir, { + 'pkg/__init__.py': '', + 'pkg/db.py': `def session_scope(): + return "db" +`, + 'single.py': `def session_scope(): + return "single" +`, + 'caller_dotted.py': `import pkg.db + +def clean_dotted(): + return pkg.db.session_scope() + +def param_shadow_dotted(pkg): + return pkg.db.session_scope() + +def local_shadow_dotted(): + pkg = object() + return pkg.db.session_scope() +`, + 'caller_single.py': `import single + +def clean_single(): + return single.session_scope() + +def param_shadow_single(single): + return single.session_scope() + +def local_shadow_single(): + single = object() + return single.session_scope() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('emits an edge only from the unshadowed callers', () => { + // Exact edge set: an assertion on absence alone would also pass if the + // guard over-suppressed and killed the clean rows too. + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.target === 'session_scope') + .map((c) => `${c.source}->${c.targetFilePath}`) + .sort(); + expect(edges).toEqual(['clean_dotted->pkg/db.py', 'clean_single->single.py']); + }); +}); + +// --------------------------------------------------------------------------- +// #2826 follow-up — `import a.b.c` binds THREE receiver spellings, not one. +// +// Python makes `a`, `a.b` and `a.b.c` all callable off a single import, and +// each names a DIFFERENT file. The namespace map originally keyed only the +// bound name `a`, pointed at the LEAF module — wrong in both directions: +// `a.helper()` resolved into the leaf whenever it happened to export `helper` +// (a wrong edge, preferring a decoy over the real definition), and `a.b.mid()` +// resolved to nothing. +// --------------------------------------------------------------------------- + +describe('Python dotted import binds every package prefix (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-prefix-')); + writeFixtureRepo(repoDir, { + // `helper` exists in BOTH the package and the leaf. Without the fix the + // root key points at the leaf and the decoy wins, so this pair is what + // makes the wrong edge visible rather than merely plausible. + 'a/__init__.py': `def helper(): + return "package" +`, + 'a/b/__init__.py': `def mid_fn(): + return "mid" +`, + 'a/b/c.py': `def helper(): + return "leaf-decoy" + + +def leaf_fn(): + return "leaf" +`, + 'caller.py': `import a.b.c + +def uses_leaf(): + return a.b.c.leaf_fn() + +def uses_mid(): + return a.b.mid_fn() + +def uses_root(): + return a.helper() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves each prefix to its own module', () => { + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller.py') + .map((c) => `${c.source}->${c.target}@${c.targetFilePath}`) + .sort(); + expect(edges).toEqual([ + 'uses_leaf->leaf_fn@a/b/c.py', + 'uses_mid->mid_fn@a/b/__init__.py', + 'uses_root->helper@a/__init__.py', + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts new file mode 100644 index 000000000..20ca10078 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared'; +import { collectNamespaceTargets } from '../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js'; +import { pythonNamespaceReceiverPaths } from '../../../src/core/ingestion/languages/python/import-target.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// `collectNamespaceTargets` reads exactly two things: the file's module scope +// and `scopes.imports`. Hand-building those keeps this test about the keying +// rule itself rather than about any one language's parser — which matters, +// because the rule's whole job is to tell otherwise identical-looking edges +// from different languages apart. + +const MODULE_SCOPE = 'mod:caller' as ScopeId; + +function edge(partial: Partial): ImportEdge { + return { + localName: 'pkg', + targetFile: 'pkg/db.py', + targetExportedName: 'pkg.db', + kind: 'namespace', + ...partial, + } as ImportEdge; +} + +/** Collect with Python's hook, against a workspace containing `files`. */ +function collectPython(edges: readonly ImportEdge[], files: readonly string[] = []) { + const parsed = { filePath: 'caller.py', moduleScope: MODULE_SCOPE } as ParsedFile; + const scopes = { imports: new Map([[MODULE_SCOPE, edges]]) } as unknown as ScopeResolutionIndexes; + const present = new Set(files); + return collectNamespaceTargets(parsed, scopes, { + receiverPaths: pythonNamespaceReceiverPaths, + moduleFileExists: (filePath) => present.has(filePath), + }); +} + +/** Collect the way a provider with no hook does. */ +function collectDefault(edges: readonly ImportEdge[]) { + const parsed = { filePath: 'caller.ts', moduleScope: MODULE_SCOPE } as ParsedFile; + const scopes = { imports: new Map([[MODULE_SCOPE, edges]]) } as unknown as ScopeResolutionIndexes; + return collectNamespaceTargets(parsed, scopes); +} + +describe('collectNamespaceTargets — namespace receiver spellings (#2826)', () => { + it('keys only the bound name when no provider hook is supplied', () => { + const targets = collectDefault([edge({})]); + expect(targets.get('pkg')).toEqual(['pkg/db.py']); + expect(targets.has('pkg.db')).toBe(false); + }); + + it('keys the dotted import path for Python', () => { + expect(collectPython([edge({})]).get('pkg.db')).toEqual(['pkg/db.py']); + }); + + // The root key is the reason this is a hook and not a flag. `import pkg.db` + // binds `pkg`, but `pkg` names the PACKAGE, not the submodule — keying it to + // pkg/db.py made `pkg.helper()` resolve into the submodule whenever that file + // happened to export `helper`, silently preferring a decoy over the real one. + it('keys the package root at its own __init__, never at the leaf module', () => { + const targets = collectPython([edge({})], ['pkg/__init__.py']); + expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py']); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + }); + + it('omits a prefix whose package file the workspace never parsed', () => { + // PEP-420 namespace package: no __init__.py. Better no key than one + // pointing at a file that does not exist — or at the wrong file. + const targets = collectPython([edge({})], []); + expect(targets.get('pkg')).toEqual(['pkg/db.py']); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + }); + + it('keys every intermediate package of a deep import', () => { + const deep = edge({ + localName: 'a', + targetExportedName: 'a.b.c', + targetFile: 'a/b/c.py', + }); + const targets = collectPython([deep], ['a/__init__.py', 'a/b/__init__.py']); + expect(targets.get('a')).toEqual(['a/__init__.py', 'a/b/c.py']); + expect(targets.get('a.b')).toEqual(['a/b/__init__.py', 'a/b/c.py']); + expect(targets.get('a.b.c')).toEqual(['a/b/c.py']); + }); + + // Swift's `import Foo.Bar` produces an edge structurally identical to + // Python's `import pkg.db` — localName 'Foo', targetExportedName 'Foo.Bar'. + // Swift resolves the FIRST segment as the SPM target, so 'Foo.Bar' names a + // nested type, not the imported file. Minting a key for it would hand + // `resolveConstructionExpressionClass` an authoritative-but-wrong namespace, + // and that function deliberately does not fall through on a miss — so a + // working `Foo.Bar(x)` would start resolving to nothing. Only the provider + // opt-in keeps the two apart; a structural predicate cannot. + it('mints nothing extra for a provider without the hook, on a Swift-shaped edge', () => { + const swiftShaped = edge({ + localName: 'Foo', + targetExportedName: 'Foo.Bar', + targetFile: 'Sources/Foo/Foo.swift', + }); + const targets = collectDefault([swiftShaped]); + expect(targets.get('Foo')).toEqual(['Sources/Foo/Foo.swift']); + expect(targets.has('Foo.Bar')).toBe(false); + }); + + it('does not key an alias import under the module path it does not bind', () => { + // `import pkg.db as pdb` binds ONLY `pdb`; `pkg.db.f()` is a NameError. + const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db' }); + const targets = collectPython([aliased], ['pkg/__init__.py']); + expect(targets.get('pdb')).toEqual(['pkg/db.py']); + expect(targets.has('pkg.db')).toBe(false); + expect(targets.has('pkg')).toBe(false); + }); + + it('keeps two same-package imports on separate keys', () => { + const targets = collectPython( + [ + edge({ targetExportedName: 'pkg.db', targetFile: 'pkg/db.py' }), + edge({ targetExportedName: 'pkg.cache', targetFile: 'pkg/cache.py' }), + ], + ['pkg/__init__.py'], + ); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + expect(targets.get('pkg.cache')).toEqual(['pkg/cache.py']); + // The shared root LEADS with the package itself — not with whichever + // submodule happened to be imported first — and keeps both leaves behind it + // so a name merely re-exported by `__init__.py` still resolves. + expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py', 'pkg/cache.py']); + }); + + it('ignores non-namespace and unresolved edges', () => { + const targets = collectPython( + [ + edge({ kind: 'named', localName: 'db', targetExportedName: 'pkg.db' }), + edge({ targetFile: null, targetExportedName: 'pkg.gone' }), + ], + ['pkg/__init__.py'], + ); + expect(targets.size).toBe(0); + }); + + // Prefix packages are anchored on the RESOLVED leaf, not on the import + // spelling: `resolvePythonImportTarget` resolves off-root in two of its three + // tiers, so an import can land outside the workspace root. + it('anchors prefix packages on the resolved leaf, not the workspace root', () => { + const offRoot = edge({ + localName: 'utils', + targetExportedName: 'utils.db', + targetFile: 'libs/common/utils/db.py', + }); + // A DIFFERENT `utils` package exists at the root. Anchoring on the spelling + // would key `utils` to it — a module this import never named. + const targets = collectPython( + [offRoot], + ['utils/__init__.py', 'libs/common/utils/__init__.py'], + ); + expect(targets.get('utils')).toEqual([ + 'libs/common/utils/__init__.py', + 'libs/common/utils/db.py', + ]); + expect(targets.get('utils.db')).toEqual(['libs/common/utils/db.py']); + }); + + it('keys prefixes in a src/-style layout', () => { + const srcLayout = edge({ + localName: 'a', + targetExportedName: 'a.b.c', + targetFile: 'src/a/b/c.py', + }); + const targets = collectPython([srcLayout], ['src/a/__init__.py', 'src/a/b/__init__.py']); + expect(targets.get('a')).toEqual(['src/a/__init__.py', 'src/a/b/c.py']); + expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py', 'src/a/b/c.py']); + expect(targets.get('a.b.c')).toEqual(['src/a/b/c.py']); + }); + + it('falls back to the default for a bare single-segment import', () => { + const bare = edge({ + localName: 'single', + targetExportedName: 'single', + targetFile: 'single.py', + }); + expect(collectPython([bare]).get('single')).toEqual(['single.py']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts index 75b3303c4..5b7d3402b 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts @@ -25,6 +25,18 @@ def shadowed(models): def locally_shadowed(): models = object() return models.User() +`, + ], + [ + 'pkg/dotted.py', + `import pkg.models + +def dotted(): + return pkg.models.User() + +def dotted_root_shadowed(): + pkg = object() + return pkg.models.User() `, ], [ @@ -66,9 +78,16 @@ function build() { }, }); const index = buildWorkspaceResolutionIndex(parsedFiles); + const namespaceTargetsFor = (file: ParsedFile) => + collectNamespaceTargets(file, scopes, { + // Mirror the production wiring in receiver-bound-calls.ts: the extra + // receiver spellings come from the provider hook, not from a default. + receiverPaths: pythonScopeResolver.namespaceReceiverPaths, + moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), + }); const app = parsedFiles.find((file) => file.filePath === 'pkg/app.py'); if (app === undefined) throw new Error('missing app fixture'); - const namespaceTargets = collectNamespaceTargets(app, scopes); + const namespaceTargets = namespaceTargetsFor(app); const resolveIn = (functionName: string, expression: string) => { const functionScope = app.scopes.find( @@ -83,11 +102,28 @@ function build() { }); }; - return { resolveIn }; + const dotted = parsedFiles.find((file) => file.filePath === 'pkg/dotted.py'); + if (dotted === undefined) throw new Error('missing dotted fixture'); + const dottedNamespaceTargets = namespaceTargetsFor(dotted); + + const resolveDottedIn = (functionName: string, expression: string) => { + const functionScope = dotted.scopes.find( + (scope) => + scope.kind === 'Function' && + scope.ownedDefs.some((def) => def.qualifiedName === functionName), + ); + if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`); + return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, { + constructionSyntax: { bare: true }, + namespaceTargets: dottedNamespaceTargets, + }); + }; + + return { resolveIn, resolveDottedIn }; } describe('Python module namespace construction', () => { - const { resolveIn } = build(); + const { resolveIn, resolveDottedIn } = build(); it('resolves an exported class from the verified module target', () => { expect(resolveIn('valid', 'models.User()')).toMatchObject({ @@ -107,4 +143,20 @@ describe('Python module namespace construction', () => { it('does not reuse a file-level namespace when a local shadows it', () => { expect(resolveIn('locally_shadowed', 'models.User()')).toBeUndefined(); }); + + // #2826: `import pkg.models` binds only `pkg`, so the namespace key is the + // dotted path `pkg.models` while the shadowable name is the root `pkg`. + it('resolves construction through a dotted import-path namespace', () => { + expect(resolveDottedIn('dotted', 'pkg.models.User()')).toMatchObject({ + filePath: 'pkg/models.py', + qualifiedName: 'User', + }); + }); + + it('does not reuse a dotted namespace when a local shadows its ROOT segment', () => { + // Fails without the root-segment fix: testing the whole `pkg.models` + // string against scope bindings never matches, so the guard would pass a + // shadowed receiver straight through to the authoritative namespace branch. + expect(resolveDottedIn('dotted_root_shadowed', 'pkg.models.User()')).toBeUndefined(); + }); });