Skip to content

fix(python): resolve calls through an imported module alias, without guessing past it - #437

Closed
JeglovDev wants to merge 1 commit into
trailhq:mainfrom
JeglovDev:fix/python-module-calls
Closed

JeglovDev wants to merge 1 commit into
trailhq:mainfrom
JeglovDev:fix/python-module-calls

Conversation

@JeglovDev

Copy link
Copy Markdown

Problem

A layered Python codebase imports the module, not each function, so most cross-module
traffic reads credits.reserve(...). Such a call reached the resolver as a member call
with no inferable receiver type and was dropped — correctly under the drop-rather-than-guess
rule of #35, since a bare reserve says nothing about which file it lives in.

The missing half was the import binding: collectImportedSymbols covers TypeScript and PHP
and returns an empty map for Python, so the module an alias names never reached the call site.

Measured on a 375-file FastAPI service: 319 calls through a module alias, of which the graph
knew 1
. Cross-file Python call edges: 2009.

Relation to #375 and #334

#375 reports this exact gap. #334 proposes the same mechanism, written independently — I found
it only after finishing this work, and the two even picked the same field name (importedModules).
I am opening this anyway because it closes three things #334 leaves open; maintainers should of
course take whichever they prefer, or the pieces below on top of #334.

  1. Shadowing. python: resolve calls through an imported module name (M.f() / from a import b as M) #334 reads the module table without checking whether the name still denotes
    the module. Applying its diff to main and running this PR's negative test, a parameter named
    after the module produces a false edge:

    def shadowed(billing):       # a parameter, not the module
        return billing.charge(1, 2)   # → edge into app/services/billing.py#charge

    Same for a local rebind (billing = _Stub()). This PR drops the binding for any name the
    definition rebinds, so both resolve to nothing — the precision rule of map clusters one entry per file instead of per directory #35 holds.

  2. imports edges. Python import edges still carry the raw dotted string (app.db.session)
    rather than a file, because resolveImport only understands relative TS-style specifiers.
    This PR resolves them the way Java and PHP imports already resolve here, by importable path
    suffix, so the import graph stops pointing at external-looking strings.

  3. import a.b as x produced no import edge at all — importSpecifier never looked inside
    an aliased_import, so the statement was recorded as nothing.

A smaller difference in approach: module lookup here is a suffix index requiring a unique
match, mirroring javaFilesBySuffix/phpFilesBySuffix, instead of walking the caller's ancestor
directories nearest-first. Two same-named modules under different roots therefore resolve to
nothing rather than to the nearer one.

Fix

  • extract.ts — a Python module table: import a.b as x, from a.b import c [as x],
    relative from . import c. A member call whose receiver is one of those names carries the
    module as specifier. The table is filtered per definition by the names it rebinds.
  • resolve.ts — resolve that specifier to a file (suffix index for absolute paths, dot-climb
    for relative ones) and look the name up inside that file only; ambiguous or missing name
    drops, no bare-name fallback. The same resolver serves the imports branch.

Tests

test/graph-python-module-calls.test.ts: five import spellings resolve; four negative cases
(parameter shadowing, local rebind, a name absent from the module, an out-of-repo module) stay
unresolved; import edges name in-repo files while an external package stays a truthful string.

Full suite: 1252 tests, 0 failures, 1 pre-existing skip.

Numbers

On the same 375-file service, against pyright's call hierarchy as a compiler-grade oracle:

before this PR --lsp (pyright)
module-alias calls resolved 1 / 319 319 / 319 319 / 319
cross-file Python call edges 2009 3038 3234
full build 5.6s 5.2s 22s

All 3038 edges are confirmed by pyright — zero edges the compiler does not also report. The
196 it finds beyond them are typed member calls (obj.method()), which stay --lsp territory.

A layered Python codebase imports the module, not each function, so most of
its cross-module traffic reads `billing.charge(...)`. Such a call reached the
resolver as a member call with no inferable receiver type and was dropped —
correctly, under the drop-rather-than-guess rule, since a bare `charge` says
nothing about which file it lives in.

The missing half was the import binding: collectImportedSymbols covers
TypeScript and PHP and returned an empty map for Python, so the module an
alias names never reached the call site. With a module table, the call states
both halves — the file (the import) and the symbol (the attribute) — which is
the same evidence a named import already gives a `references` edge, and
resolves exactly. No bare-name fallback is added.

Measured on a 375-file FastAPI service: 319 module-alias calls, of which the
graph knew 1; now all 319. Cross-file Python call edges go 2009 -> 3038, and
every one of them is confirmed by pyright's call hierarchy — zero edges the
compiler does not also report. Build time is unchanged (5.2s), where
`--lsp` on the same repo costs 22s.

Two supporting fixes fall out of the same gap: dotted and relative imports now
resolve to in-repo files instead of staying external strings (suffix matching,
as Java and PHP already do here), and `import a.b as x` produces an import
edge at all — importSpecifier never looked inside an `aliased_import`.
@trailhq-graft

trailhq-graft Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 3 areas can be affected. 9 dependent symbols, depth 2.
Tests: Dependency Extraction has tests the diff did not touch.
Tag: @anirudhkumar-nanonets — 4 of 4 areas · @shhdwi — Dependency Extraction, Workspace Graph

flowchart TB
  A0(("Workspace Graph<br/>5 symbols"))
  A1(("Engine Core<br/>2 symbols"))
  A2(("Pull Request Review<br/>2 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Workspace Graph 5 src/graph/build.ts:L151-L410 buildGraph — calls, depth 1 Dependency Extraction
Engine Core 2 src/engine.ts:L91-L101 graph — calls, depth 2 Dependency Extraction
Pull Request Review 2 src/app/brain-build.ts:L251-L358 readRepository — calls, depth 2 Dependency Extraction
Who knows this code — 2 people across 4 areas
Area Who knows it
Dependency Extraction · changed @shhdwi — 12 commits, last 1mo ago · @anirudhkumar-nanonets — 9 commits, last 27d ago
Workspace Graph · affected @anirudhkumar-nanonets — 20 commits, last 25d ago · @shhdwi — 12 commits, last 1mo ago
Engine Core · affected @anirudhkumar-nanonets — 16 commits, last 2mo ago
Pull Request Review · affected @anirudhkumar-nanonets — 9 commits, last 11d ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 9 dependent symbols, grouped by area

Workspace Graph — 5 symbols in 5 files

  • src/graph/build.ts:L151-L410 — buildGraph (calls, depth 1)
    264: ? extractFile(rel, source, lang)
  • src/graph/check.ts:L58-L164 — checkGraph (calls, depth 1)
    119: ? extractFile(rel, source, lang)
  • src/graph/container.ts:L151-L208 — extractContainer (calls, depth 1)
    171: inner = extractFile(rel, script, lang.inner);
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 2)
  • src/graph/workspace.ts:L630-L655 — federateCheck (calls, depth 2)

Engine Core — 2 symbols in 1 file

  • src/engine.ts:L91-L101 — graph (calls, depth 2)
  • src/engine.ts:L82-L84 — checkGraph (calls, depth 2)

Pull Request Review — 2 symbols in 2 files

  • src/app/brain-build.ts:L251-L358 — readRepository (calls, depth 2)
    259: // pull-request walk, the digest — and without the elapsed time on each, the
  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 2)
Test signal per changed area — 1 ⚠

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ⚠ Dependency Extraction — 2 of 10 reached · 10 test files reach it, none changed here
    • not reached: walk, bind, visit, joinModulePath, collectParam, visitBody, importSpecifier, resolvePythonImport
37 test suites also reference this code

46 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/container-extract.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-bindings.test.ts
  • test/graph-cross-language.test.ts
  • test/graph-extract-dedup.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • …17 more

graft blast · origin/main...HEAD · depth 2 · 3 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 21, 2026
@ryanduguid

ryanduguid commented Sep 26, 2026 •

Copy link
Copy Markdown

I reproduced all three cases in #465 on main 80692e5, PR #334 at 802a13d, and this PR at 6d60e1e. Neither PR resolves the named function alias, imported class method or explicitly imported duplicate function. Both resolve the module-alias control; #334 also incorrectly resolves a call through a shadowing parameter, while #437 drops it.

The patch below extends #437 using its module-path resolver. It resolves named imports and class methods within the imported file, and drops external, missing, ambiguous and shadowed bindings. Support is limited to unconditional module-level named imports. Conditional imports that replace a binding are also excluded, so they cannot create a false call to the original import. A class-method call creates an edge to the method without inventing a constructor call.

The build and all 18 focused Python tests pass on Windows. A fresh graph build resolves all four positive probes. The full suite has 1,242 passes, 16 failures and 5 skips, compared with 1,231 passes and the same failures and skips on the unchanged PR. All 11 added tests pass. The remaining failures concern Git fixture commits and .gitignore expectations. Linux/macOS and the Node 20 CI matrix were not run.

The complete patch applies cleanly to 6d60e1e7b428368ced9c9140827fecae845a758f and is available to incorporate into this PR.

Named-import extension and regression tests
diff --git a/src/graph/extract.ts b/src/graph/extract.ts
index bd12834..280e926 100644
--- a/src/graph/extract.ts
+++ b/src/graph/extract.ts
@@ -342,6 +342,7 @@ export interface WalkCtx {
    * attribute names the symbol, so resolve.ts can resolve it exactly instead of
    * dropping it for want of a receiver type. Empty for every other language. */
   importedModules: ReadonlyMap<string, string>;
+  pythonImportedNames: ReadonlySet<string>;
   // R6 (Phase 2): which list we're inside while walking an `R6Class(...)` call's
   // arguments — set only for the direct span of a `public =`/`private =`/
   // `active =` `list(...)`'s own entries (see walk()'s special-cased `argument`
@@ -395,8 +396,11 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac
   parser.setLanguage(GRAMMARS[lang] as never);
   const root = parseSource(source);
   const bindings = collectBindings(root, lang);
-  const importedSymbols = collectImportedSymbols(root, lang);
-  const importedModules = lang === "python" ? collectPythonImportedModules(root) : EMPTY_MODULE_MAP;
+  const pythonSymbols = new Map<string, { name: string; specifier: string }>();
+  const importedModules = lang === "python" ? collectPythonImportedModules(root, pythonSymbols) : EMPTY_MODULE_MAP;
+  const importedSymbols = lang === "python"
+    ? withoutPythonShadowedModules(pythonSymbols, root)
+    : collectImportedSymbols(root, lang);
   const rGenerics = lang === "r" ? collectRGenerics(root) : EMPTY_SET;
 
   const nodes: NodeV1[] = [
@@ -431,6 +435,7 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac
     goReceiverVar: null,
     importedSymbols,
     importedModules,
+    pythonImportedNames: new Set(pythonSymbols.keys()),
     rR6Access: null,
     rGenerics,
     rSuperClass: null,
@@ -649,7 +654,9 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
       goReceiverVar: isGoMethod ? goReceiverVarOf(node) : ctx.goReceiverVar,
       importedSymbols:
         desc.kind === "function" || desc.kind === "method"
-          ? withoutShadowedImports(ctx.importedSymbols, node)
+          ? ctx.lang === "python"
+            ? withoutPythonShadowedModules(ctx.importedSymbols, node)
+            : withoutShadowedImports(ctx.importedSymbols, node)
           : ctx.importedSymbols,
       importedModules:
         ctx.lang === "python" && (desc.kind === "function" || desc.kind === "method")
@@ -783,13 +790,29 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
         // two-halves evidence a named import gives a `references` edge. Carry the
         // module as the specifier so resolve.ts can look the name up in that file
         // alone — no bare-name guessing, and no receiver type to infer.
+        const importedName = callee.viaMember ? callee.receiver : callee.name;
+        const imported = ctx.lang === "python" && importedName
+          ? ctx.importedSymbols.get(importedName)
+          : undefined;
+        const shadowed = ctx.lang === "python" && importedName && ctx.pythonImportedNames.has(importedName)
+          && (!imported || pythonCallHasLocalBinding(node, importedName));
         const moduleSpec =
           ctx.lang === "python" && callee.viaMember && callee.receiver
             ? ctx.importedModules.get(callee.receiver)
             : undefined;
         const recvType = moduleSpec ? undefined : resolveRecvType(callee.receiver, ctx);
-        if (moduleSpec) edges.push({ ...callEdge, specifier: moduleSpec });
-        else edges.push(recvType ? { ...callEdge, recvType } : callEdge);
+        if (imported && !shadowed) {
+          edges.push({
+            ...callEdge,
+            name: callee.viaMember ? callee.name : imported.name,
+            specifier: imported.specifier,
+            ...(callee.viaMember ? { recvType: imported.name } : {}),
+          });
+        } else if (!shadowed) {
+          // A shadowed bare import must not reconnect through global name lookup.
+          if (moduleSpec) edges.push({ ...callEdge, specifier: moduleSpec });
+          else edges.push(recvType ? { ...callEdge, recvType } : callEdge);
+        }
       }
     }
   } else if (ctx.lang === "php" && node.type === "use_declaration") {
@@ -885,7 +908,10 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
  * TOP package name, so the call site reads `a.b.f()` — a two-hop receiver Python's
  * callee reader does not report — and is therefore left out rather than guessed at.
  */
-function collectPythonImportedModules(root: Parser.SyntaxNode): Map<string, string> {
+function collectPythonImportedModules(
+  root: Parser.SyntaxNode,
+  symbols: Map<string, { name: string; specifier: string }>,
+): Map<string, string> {
   const out = new Map<string, string>();
   const bind = (aliasNode: Parser.SyntaxNode, path: string): void => {
     const names = aliasNode.namedChildren;
@@ -913,8 +939,17 @@ function collectPythonImportedModules(root: Parser.SyntaxNode): Map<string, stri
       if (!moduleNode) return;
       const modulePath = moduleNode.text;
       for (const child of node.namedChildren) {
-        if (child === moduleNode) continue;
-        if (child.type === "aliased_import" || child.type === "dotted_name") bind(child, modulePath);
+        if (sameSyntaxNode(child, moduleNode)) continue;
+        if (child.type === "aliased_import" || child.type === "dotted_name") {
+          bind(child, modulePath);
+          // ponytail: index unconditional module imports here. Local and
+          // conditional imports need per-scope binding tables before inclusion.
+          if (node.parent?.type === "module") {
+            const name = child.childForFieldName("name")?.text ?? child.text;
+            const local = child.childForFieldName("alias")?.text ?? name;
+            symbols.set(local, { name, specifier: modulePath });
+          }
+        }
       }
       return;
     }
@@ -930,28 +965,74 @@ function joinModulePath(prefix: string, tail: string): string {
   return prefix.endsWith(".") ? `${prefix}${tail}` : `${prefix}.${tail}`;
 }
 
-/**
- * Python module aliases minus the names this definition rebinds — a parameter or
- * an assignment named like an imported module shadows it for the whole body, and
- * a call on the local value is not a call into that module.
- */
-function withoutPythonShadowedModules(
-  modules: ReadonlyMap<string, string>,
+/** Binding patterns exclude type annotations, default values and attribute names. */
+function collectPythonBindingNames(node: Parser.SyntaxNode, names: Set<string>): void {
+  if (node.type === "identifier") names.add(node.text);
+  else if (node.type === "default_parameter" || node.type === "typed_default_parameter") {
+    const name = node.childForFieldName("name");
+    if (name) collectPythonBindingNames(name, names);
+  } else if (node.type !== "attribute" && node.type !== "subscript") {
+    const type = node.childForFieldName("type");
+    for (const child of node.namedChildren) {
+      if (!sameSyntaxNode(child, type)) collectPythonBindingNames(child, names);
+    }
+  }
+}
+
+function pythonCallHasLocalBinding(call: Parser.SyntaxNode, name: string): boolean {
+  for (let scope = call.parent; scope && scope.type !== "function_definition"; scope = scope.parent) {
+    const names = new Set<string>();
+    if (scope.type === "lambda") {
+      const params = scope.childForFieldName("parameters");
+      if (params) collectPythonBindingNames(params, names);
+    } else if (scope.type.endsWith("comprehension") || scope.type === "generator_expression") {
+      for (const child of scope.namedChildren) {
+        if (child.type !== "for_in_clause") continue;
+        const left = child.childForFieldName("left");
+        if (left) collectPythonBindingNames(left, names);
+      }
+    }
+    if (names.has(name)) return true;
+  }
+  return false;
+}
+
+/** Remove imports rebound in this scope, retaining bindings used by nested scopes. */
+function withoutPythonShadowedModules<T>(
+  modules: ReadonlyMap<string, T>,
   definition: Parser.SyntaxNode,
-): ReadonlyMap<string, string> {
+): ReadonlyMap<string, T> {
   if (modules.size === 0) return modules;
   const shadowed = new Set<string>();
   const params = definition.childForFieldName("parameters");
-  const collectParam = (node: Parser.SyntaxNode): void => {
-    if (node.type === "identifier") shadowed.add(node.text);
-    else for (const child of node.namedChildren) collectParam(child);
-  };
-  if (params) for (const p of params.namedChildren) collectParam(p);
-  const body = definition.childForFieldName("body");
+  if (params) collectPythonBindingNames(params, shadowed);
+  const body = definition.type === "module" ? definition : definition.childForFieldName("body");
   const visitBody = (node: Parser.SyntaxNode): void => {
-    if (node.type === "assignment") {
+    if (node.type === "function_definition" || node.type === "class_definition" || node.type === "lambda") {
+      const name = node.childForFieldName("name");
+      if (name) shadowed.add(name.text);
+      return;
+    }
+    if (node.type === "assignment" || node.type === "augmented_assignment" || node.type === "for_statement") {
       const left = node.childForFieldName("left");
-      if (left?.type === "identifier") shadowed.add(left.text);
+      if (left) collectPythonBindingNames(left, shadowed);
+    }
+    if (node.type === "as_pattern" || node.type === "named_expression") {
+      const target = node.childForFieldName(node.type === "as_pattern" ? "alias" : "name");
+      if (target) collectPythonBindingNames(target, shadowed);
+    }
+    if (node.type === "delete_statement") {
+      collectPythonBindingNames(node, shadowed);
+    }
+    if (node.parent?.type !== "module" && (node.type === "import_statement" || node.type === "import_from_statement")) {
+      for (const child of node.namedChildren) {
+        if (child.type === "aliased_import") {
+          const alias = child.childForFieldName("alias");
+          if (alias) shadowed.add(alias.text);
+        } else if (child.type === "dotted_name" && !sameSyntaxNode(child, node.childForFieldName("module_name"))) {
+          shadowed.add(child.text.split(".")[0]);
+        }
+      }
     }
     for (const child of node.namedChildren) visitBody(child);
   };
diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts
index 86f3416..2575078 100644
--- a/src/graph/resolve.ts
+++ b/src/graph/resolve.ts
@@ -282,20 +282,36 @@ export function resolveEdges(
         if (hit && hit.id !== e.source) add(e.source, hit.id, "references", hit.confidence);
       }
     } else if (e.relation === "calls") {
-      // A Python call through an imported module alias (`credits.reserve()`)
-      // carries the module as its specifier. That is the same two-halves evidence
-      // a named import gives — the file to look in, and the name to look for — so
-      // it resolves inside that file alone. `from a.b import c` is ambiguous in
-      // Python's grammar (submodule or symbol?); an unresolved module means it
-      // was the symbol reading, and the call falls through to the paths below
-      // rather than guessing.
+      // Python imports identify the target module. A named receiver can be a
+      // class or a submodule; both resolve within their explicit import path.
       if (e.specifier && e.name && e.file.endsWith(".py")) {
         const moduleFile = resolvePythonImport(e.specifier, e.file, byId, pythonFilesByModule);
-        const candidates = byId.has(moduleFile) ? (perFileName.get(moduleFile)?.get(e.name) ?? []) : [];
+        let targetFile = moduleFile;
+        let candidates: NodeV1[];
+        if (e.viaMember && e.recvType) {
+          const owners = (perFileName.get(moduleFile)?.get(e.recvType) ?? [])
+            .filter((n) => n.id.replace(/~\d+$/, "") === `${moduleFile}#${e.recvType}`);
+          if (owners.length === 1 && owners[0].kind === "class") {
+            candidates = (ownerMethod.get(`${e.recvType}.${e.name}`) ?? [])
+              .filter((n) => n.path === moduleFile && n.id.replace(/~\d+$/, "") === `${owners[0].id}.${e.name}`);
+          } else if (owners.length === 0) {
+            // `from pkg import submodule` shares the syntax of a named import.
+            const spec = e.specifier.endsWith(".") ? `${e.specifier}${e.recvType}` : `${e.specifier}.${e.recvType}`;
+            targetFile = resolvePythonImport(spec, e.file, byId, pythonFilesByModule);
+            candidates = (perFileName.get(targetFile)?.get(e.name) ?? [])
+              .filter((n) => n.id.replace(/~\d+$/, "") === `${targetFile}#${e.name}` && (n.kind === "function" || n.kind === "class"));
+          } else {
+            candidates = [];
+          }
+        } else {
+          candidates = (perFileName.get(targetFile)?.get(e.name) ?? [])
+            .filter((n) => n.id.replace(/~\d+$/, "") === `${targetFile}#${e.name}` && (n.kind === "function" || n.kind === "class"));
+        }
         if (candidates.length === 1) {
           add(e.source, candidates[0].id, "calls", "extracted");
-          continue;
         }
+        // Explicit imports must not fall through to an unrelated global name.
+        continue;
       }
       if (e.viaMember) {
         if (!e.recvType) continue;
diff --git a/test/graph-python-named-imports.test.ts b/test/graph-python-named-imports.test.ts
new file mode 100644
index 0000000..4033667
--- /dev/null
+++ b/test/graph-python-named-imports.test.ts
@@ -0,0 +1,163 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { extractFile } from "../src/graph/extract.js";
+import { resolveEdges } from "../src/graph/resolve.js";
+
+const definitions = `def validate_result(value):
+    return value
+
+class Workspace:
+    @classmethod
+    def open(cls):
+        return cls()
+`;
+
+function calls(files: Record<string, string>, source = "consumer.py#run"): string[] {
+  const extracted = Object.entries(files).map(([path, text]) => extractFile(path, text, "python"));
+  return resolveEdges(extracted.flatMap((x) => x.nodes), extracted.flatMap((x) => x.rawEdges))
+    .filter((e) => e.source === source && e.relation === "calls")
+    .map((e) => e.target);
+}
+
+test("Python named imports resolve aliases, class methods and duplicate function names", () => {
+  const files = {
+    "validators.py": definitions,
+    "other.py": definitions,
+    "first/helpers.py": "def parse_value(value):\n    return value\n",
+    "second/helpers.py": "def parse_value(value):\n    return value\n",
+    "consumer.py": `from validators import validate_result as validate_equity_result
+from validators import Workspace as Project
+from first.helpers import parse_value
+
+def run():
+    validate_equity_result(1)
+    Project.open()
+    return parse_value(1)
+`,
+  };
+  assert.deepEqual(calls(files), [
+    "validators.py#validate_result", "validators.py#Workspace.open", "first/helpers.py#parse_value",
+  ]);
+});
+
+test("Python named imports resolve relative modules and package initialisers", () => {
+  assert.deepEqual(calls({
+    "pkg/validators.py": definitions,
+    "pkg/tools/__init__.py": "def parse_value(value):\n    return value\n",
+    "pkg/consumer.py": `from .validators import Workspace
+from .tools import parse_value
+def run():
+    Workspace.open()
+    return parse_value(1)
+`,
+  }, "pkg/consumer.py#run"), ["pkg/validators.py#Workspace.open", "pkg/tools/__init__.py#parse_value"]);
+});
+
+test("Python named imports do not guess past an external, absent or ambiguous target", () => {
+  for (const module of ["external", "empty", "shared"]) {
+    assert.deepEqual(calls({
+      "validators.py": definitions,
+      "empty.py": "def unrelated():\n    pass\n",
+      "a/shared.py": definitions,
+      "b/shared.py": definitions,
+      "consumer.py": `from ${module} import validate_result, Workspace\ndef run():\n    validate_result(1)\n    Workspace.open()\n`,
+    }), [], module);
+  }
+});
+
+test("Python named imports stay shadowed by parameters and local bindings", () => {
+  const bodies = [
+    "def run(validate_result):\n    return validate_result(1)\n",
+    "def run():\n    validate_result = callback\n    return validate_result(1)\n",
+    "def run():\n    validate_result, other = callbacks\n    return validate_result(1)\n",
+    "def run():\n    for validate_result in callbacks:\n        validate_result(1)\n",
+    "def run():\n    from external import other as validate_result\n    return validate_result(1)\n",
+    "def run(Workspace):\n    return Workspace.open()\n",
+    "def run():\n    with manager() as validate_result:\n        return validate_result(1)\n",
+    "def run():\n    try:\n        pass\n    except Error as validate_result:\n        validate_result(1)\n",
+    "def run():\n    (validate_result := callback)\n    return validate_result(1)\n",
+    "def run():\n    del validate_result\n    return validate_result(1)\n",
+  ];
+  for (const body of bodies) {
+    assert.deepEqual(calls({
+      "validators.py": definitions,
+      "consumer.py": "from validators import validate_result, Workspace\n" + body,
+    }), [], body);
+  }
+});
+
+test("Python lambda and comprehension bindings shadow imported names locally", () => {
+  for (const expression of [
+    "lambda validate_result: validate_result(1)",
+    "[validate_result(1) for validate_result in callbacks]",
+    "(validate_result(1) for validate_result in callbacks)",
+  ]) {
+    assert.deepEqual(calls({
+      "validators.py": definitions,
+      "consumer.py": `from validators import validate_result\ndef run():\n    return ${expression}\n`,
+    }), [], expression);
+  }
+});
+
+test("Python nested scope assignments do not shadow an outer imported caller", () => {
+  assert.deepEqual(calls({
+    "validators.py": definitions,
+    "consumer.py": `from validators import validate_result
+def run():
+    def inner():
+        validate_result = callback
+    return validate_result(1)
+`,
+  }), ["validators.py#validate_result"]);
+});
+
+test("Python module assignments suppress named import resolution", () => {
+  assert.deepEqual(calls({
+    "validators.py": definitions,
+    "consumer.py": "from validators import validate_result\nvalidate_result = callback\ndef run():\n    return validate_result(1)\n",
+  }), []);
+});
+
+test("Python conditional imports suppress overwritten module bindings", () => {
+  for (const statement of [
+    "from external import validate_result",
+    "from external import other as validate_result",
+    "import external as validate_result",
+  ]) {
+    assert.deepEqual(calls({
+      "validators.py": definitions,
+      "external.py": "def validate_result(value):\n    return value\ndef other(value):\n    return value\n",
+      "consumer.py": `from validators import validate_result
+if True:
+    ${statement}
+def run():
+    return validate_result(1)
+`,
+    }), [], statement);
+  }
+});
+
+test("Python annotations, default values and attribute assignments do not rebind imports", () => {
+  assert.deepEqual(calls({
+    "validators.py": definitions,
+    "consumer.py": `from validators import validate_result, Workspace
+def run(value: Workspace = Workspace):
+    value.validate_result = callback
+    return validate_result(1)
+`,
+  }), ["validators.py#validate_result"]);
+});
+
+test("Python function-local imports do not become named imports in another function", () => {
+  assert.deepEqual(calls({
+    "validators.py": definitions,
+    "consumer.py": "def load():\n    from validators import validate_result as validate\ndef run():\n    return validate(1)\n",
+  }), []);
+});
+
+test("Python named imports do not select the first duplicate definition", () => {
+  assert.deepEqual(calls({
+    "validators.py": definitions + definitions,
+    "consumer.py": "from validators import validate_result, Workspace\ndef run():\n    validate_result(1)\n    Workspace.open()\n",
+  }), []);
+});

@shhdwi

shhdwi commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks! Your module-alias commit is included unchanged (same patch-id, your authorship) as the first commit of #490. On our test fixture, m.f(), b.charge() and both same-basename util.run() calls resolve correctly, and none of the 4 parameter/local shadowing traps get linked. We are consolidating on #490 and will merge it in a way that keeps your commit and credit, so closing this as covered.

@shhdwi shhdwi closed this Sep 29, 2026
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants