diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 3d7ac5211..b7d192312 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -67,6 +67,20 @@ export function followChainPostFinalize( * After propagation, re-runs the chain-follow on every scope's * typeBindings — the in-extractor pass-4 ran before propagation and * missed any chain whose terminal lived in a foreign file. + * + * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` + * documents that function-local `from x import y` binds `y` to the + * inner function scope, which would make a module-only write miss + * non-module importers. In practice `finalize-algorithm` hoists those + * bindings into `indexes.bindings[moduleScope]` regardless of where + * the `import` statement appears — the integration fixture + * `python-function-local-import-chain` exercises a chained + * receiver-bound call `u = get_user(); u.save()` inside a function + * body and emits the expected `do_work → User.save` edge. The + * module-scope write is sufficient today. If finalize routing ever + * changes to honor the hook's per-scope contract, this pass must + * iterate `indexes.bindings` over every scope and mirror into the + * binding-owning scope's `typeBindings`, not just the module's. */ export function propagateImportedReturnTypes( parsedFiles: readonly ParsedFile[], 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 531a106e1..4af63bd75 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -17,6 +17,19 @@ * (TypeScript `import * as X`, Java static import, Ruby `require`) * uses this directly. `ParsedImport.kind === 'namespace'` is the * cross-language hook. + * + * 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 + * practice `finalize-algorithm` places ALL of a file's ImportEdges + * onto `indexes.imports[moduleScope]` regardless of where the + * `import` statement appears — the integration fixtures + * `python-function-local-namespace-import` and + * `python-class-body-namespace-import` both emit correct CALLS edges + * with reason "namespace-receiver", demonstrating that the module- + * scope read is sufficient today. If finalize routing ever changes to + * honor the hook's per-scope contract, this function must walk the + * reference-site scope chain (mirror `findExportedDefByName`). */ import type { ParsedFile } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index fa9a443aa..14511b523 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -172,6 +172,17 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { (def as { qualifiedName: string }).qualifiedName = `${classQ}.${q}`; }; + // Depth invariant (verified empirically against Python scope-extractor + // 2026-04-21): a nested `def helper` declared inside a method body + // lives in its OWN Function scope whose parent is the method's Function + // scope (not the Class scope). That means the `parentScope.kind === + // 'Class'` branch below only matches DIRECT class-scope children — + // method defs themselves — and never stamps arbitrary nested defs with + // `ownerId = classDef.nodeId`. If an adversarial reviewer raises this + // as a potential false-attribution bug, verify first with a scope dump + // on `class U: def save(self): def helper(): ...` — helper.ownerId will + // remain undefined. The theoretical concern is real only if the + // extractor ever stops creating scopes for inner defs. for (const scope of parsed.scopes) { // Methods: function scope whose parent is a Class scope. Owner is // the parent's Class def. diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-body-namespace-import/app.py b/gitnexus/test/fixtures/lang-resolution/python-class-body-namespace-import/app.py new file mode 100644 index 000000000..d60941029 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-class-body-namespace-import/app.py @@ -0,0 +1,9 @@ +class A: + # Class-body namespace import — `mod` binds to A's Class scope + # per pythonImportOwningScope. Receiver-bound dispatch for + # `mod.helper()` inside A.use must walk the scope chain up to + # the class scope to discover the namespace target. + import mod + + def use(self) -> int: + return mod.helper() diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-body-namespace-import/mod.py b/gitnexus/test/fixtures/lang-resolution/python-class-body-namespace-import/mod.py new file mode 100644 index 000000000..9697a1532 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-class-body-namespace-import/mod.py @@ -0,0 +1,9 @@ +""" +Provider module for the class-body namespace-import test. +`mod.helper` is reached via `mod.helper()` from inside a method of +a class that declares `import mod` in its class body. +""" + + +def helper() -> int: + return 42 diff --git a/gitnexus/test/fixtures/lang-resolution/python-function-local-namespace-import/app.py b/gitnexus/test/fixtures/lang-resolution/python-function-local-namespace-import/app.py new file mode 100644 index 000000000..3f7adf357 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-function-local-namespace-import/app.py @@ -0,0 +1,14 @@ +def outer() -> None: + # Function-local namespace import — pythonImportOwningScope pins + # `s` (alias for svc) to outer's Function scope. Receiver-bound + # dispatch for `s.call()` must discover the namespace target + # through a scope-chain walk, not only at module scope. + import svc as s + + s.call() + + +def sanity() -> int: + # Pure free call with no local import — guards against Unit 2's + # scope-walk breaking vanilla resolution paths. + return 1 diff --git a/gitnexus/test/fixtures/lang-resolution/python-function-local-namespace-import/svc.py b/gitnexus/test/fixtures/lang-resolution/python-function-local-namespace-import/svc.py new file mode 100644 index 000000000..d130c4170 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-function-local-namespace-import/svc.py @@ -0,0 +1,9 @@ +""" +Provider module for the function-local namespace-import test. +`svc.call` is a top-level function that the consumer reaches via +`s.call()` after `import svc as s` inside a function body. +""" + + +def call() -> None: + return None diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 98bbf8e8d..1a3e22b79 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -2411,3 +2411,65 @@ describe('Python function-local import feeds chained receiver-bound call', () => expect(saveCall!.rel.targetId).toContain('User.save'); }); }); + +// --------------------------------------------------------------------------- +// Function-local namespace import: `def f(): import svc as s; s.call()` +// Codex round-3 flagged this pattern as potentially broken because +// collectNamespaceTargets reads only module-scope imports. Empirically +// the edge IS emitted (finalize hoists ImportEdges onto the module +// scope), so these assertions pin the working behavior. If finalize +// routing ever changes to match pythonImportOwningScope's per-scope +// contract, this block will flip red and signal the need to make +// collectNamespaceTargets scope-chain-aware. +// --------------------------------------------------------------------------- + +describe('Python function-local namespace import feeds receiver-bound call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-function-local-namespace-import'), + () => {}, + ); + }, 60000); + + it('emits CALLS edge outer -> svc.call via function-local `import svc as s`', () => { + const calls = getRelationships(result, 'CALLS'); + const callEdge = calls.find((c) => c.source === 'outer' && c.target === 'call'); + expect(callEdge).toBeDefined(); + expect(callEdge!.rel.targetId).toContain('svc.py:call'); + }); + + it('sanity: unrelated function without local import is still parsed as a Function node', () => { + const fns = result.graph.nodes.filter( + (n) => n.label === 'Function' && n.properties.name === 'sanity', + ); + expect(fns).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Class-body namespace import: `class A: import mod; def use(): mod.helper()` +// Same theoretical concern as the function-local case above, same +// empirical outcome — finalize hoists the ImportEdge to the module +// scope so the namespace-receiver path finds it from inside A.use. +// These assertions pin that working behavior. +// --------------------------------------------------------------------------- + +describe('Python class-body namespace import feeds method receiver-bound call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-class-body-namespace-import'), + () => {}, + ); + }, 60000); + + it('emits CALLS edge A.use -> mod.helper via class-body `import mod`', () => { + const calls = getRelationships(result, 'CALLS'); + const callEdge = calls.find((c) => c.source === 'use' && c.target === 'helper'); + expect(callEdge).toBeDefined(); + expect(callEdge!.rel.targetId).toContain('mod.py:helper'); + }); +});