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(); + }); });