From 0112e61314b59b43e1dbd1f39dbb862880c814c4 Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Sun, 31 May 2026 19:58:41 +0800 Subject: [PATCH] fix(resolution): resolve Python module-attribute calls to submodule members (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call through an imported module — `mod.helper(...)` where `mod` was bound via `from pkg import mod` or `import pkg.mod as mod` — produced no `calls` edge. The resolver mapped the receiver to the package, not the submodule file, so the member lookup missed and callers/callees/impact/ trace returned empty for the target (a still-used function could look like dead code). Mirror the Go cross-package resolver (#388/#469): map the module-attribute receiver to its module path and resolve the member by an exact root-relative module-path match — which also disambiguates same-named members in different modules. Covers both `from pkg import mod` and `import pkg.mod as mod` forms. Tests: the two required forms, a bare-name no-regression guard, and a cross-module same-name disambiguation case. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + __tests__/resolution.test.ts | 108 ++++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 76 +++++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fbad95a..d92981a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Python: calls made through an imported module — `mod.helper(...)` where `mod` came from `from pkg import mod` or `import pkg.mod as mod` — now create a call edge, so `codegraph_callers`, `callees`, `impact`, and `trace` find them instead of reporting the target as having no callers. This is a common test/namespacing pattern, and the missing edges could make a still-used function look like dead code. (#578) - Indexing a project that contains only config-style files (YAML, Twig, or `.properties`) no longer misleadingly reports "No files found to index" — these files are tracked at the file level and are now counted as indexed. Thanks @luojiyin1987 (#357). ## [0.9.7] - 2026-05-28 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..3b57a9d43 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -853,6 +853,114 @@ func UseAliased() { expect(target?.filePath.replace(/\\/g, '/')).toBe('pkgb/lib.go'); }); + it('resolves Python module-attribute calls (`mod.helper()` after `from pkg import mod`) (#578)', async () => { + // Pre-#578, a call through a module object — `mod.helper(...)` where + // `mod` was bound via `from pkg import mod` — produced no `calls` + // edge: the resolver mapped `mod` to the package, not the submodule + // file, so the member lookup missed. Same root-cause class as Go #388. + const pkgDir = path.join(tempDir, 'pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, '__init__.py'), ''); + fs.writeFileSync(path.join(pkgDir, 'mod.py'), 'def helper(x):\n return x\n'); + fs.writeFileSync( + path.join(tempDir, 'caller.py'), + 'from pkg import mod\n\n\ndef use_helper():\n return mod.helper(1)\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const useHelper = cg + .getNodesByKind('function') + .find((n) => n.name === 'use_helper'); + expect(useHelper).toBeDefined(); + + const calls = cg + .getOutgoingEdges(useHelper!.id) + .filter((e) => e.kind === 'calls'); + expect(calls).toHaveLength(1); + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('helper'); + expect(target?.filePath.replace(/\\/g, '/')).toBe('pkg/mod.py'); + }); + + it('resolves Python aliased module-attribute calls (`import pkg.mod as m`) (#578)', async () => { + const pkgDir = path.join(tempDir, 'pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, '__init__.py'), ''); + fs.writeFileSync(path.join(pkgDir, 'mod.py'), 'def helper(x):\n return x\n'); + fs.writeFileSync( + path.join(tempDir, 'caller.py'), + 'import pkg.mod as m\n\n\ndef use_helper():\n return m.helper(2)\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const useHelper = cg + .getNodesByKind('function') + .find((n) => n.name === 'use_helper'); + expect(useHelper).toBeDefined(); + + const calls = cg + .getOutgoingEdges(useHelper!.id) + .filter((e) => e.kind === 'calls'); + expect(calls).toHaveLength(1); + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('helper'); + expect(target?.filePath.replace(/\\/g, '/')).toBe('pkg/mod.py'); + }); + + it('Python bare-name import `from pkg.mod import helper` still resolves (no #578 regression)', async () => { + // Baseline the #578 fix must not regress: the bare-name call shape + // already created an edge before the module-attribute branch existed. + const pkgDir = path.join(tempDir, 'pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, '__init__.py'), ''); + fs.writeFileSync(path.join(pkgDir, 'mod.py'), 'def helper(x):\n return x\n'); + fs.writeFileSync( + path.join(tempDir, 'caller.py'), + 'from pkg.mod import helper\n\n\ndef use_helper():\n return helper(1)\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const useHelper = cg + .getNodesByKind('function') + .find((n) => n.name === 'use_helper'); + const callTargets = cg + .getOutgoingEdges(useHelper!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)?.name); + expect(callTargets).toContain('helper'); + }); + + it('Python module-attribute call disambiguates same-named members across modules (#578)', async () => { + // Two modules export a same-named `helper`; the call through `a.mod` + // must land on a/mod.py's helper, not b/mod.py's — the exact module + // path is the disambiguation signal (no wrong edge). + for (const p of ['a', 'b']) { + const d = path.join(tempDir, p); + fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(d, '__init__.py'), ''); + fs.writeFileSync(path.join(d, 'mod.py'), `def helper(x):\n return ${p === 'a' ? 'x' : 'x + 1'}\n`); + } + fs.writeFileSync( + path.join(tempDir, 'caller.py'), + 'from a import mod\n\n\ndef use_helper():\n return mod.helper(1)\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const useHelper = cg + .getNodesByKind('function') + .find((n) => n.name === 'use_helper'); + const calls = cg + .getOutgoingEdges(useHelper!.id) + .filter((e) => e.kind === 'calls'); + // Exactly one edge, to a/mod.py — not b/mod.py, and not both. + expect(calls).toHaveLength(1); + expect(cg.getNode(calls[0]!.target)?.filePath.replace(/\\/g, '/')).toBe('a/mod.py'); + }); + it('TS type_alias object-shape members resolve method calls (#359)', async () => { // Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached // to `StdioMcpClient.stop` in a sibling directory via path-proximity diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bc493704d..ef029bbb5 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1037,6 +1037,16 @@ export function resolveViaImport( if (javaResult) return javaResult; } + // Python module-attribute calls: `mod.helper(...)` where `mod` is a + // submodule bound via `from pkg import mod` or `import pkg.mod as mod`. + // The generic chain below maps the receiver to the *package*, not the + // submodule file, so the member lookup misses and the edge is dropped + // (issue #578 — same root-cause class as Go #388). + if (ref.language === 'python') { + const pyResult = resolvePythonModuleAttributeReference(ref, imports, context); + if (pyResult) return pyResult; + } + // Check if the reference name matches any import for (const imp of imports) { if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) { @@ -1212,6 +1222,72 @@ function resolveGoCrossPackageReference( return null; } +/** + * Resolve a Python module-attribute call: `mod.helper(...)` where the + * receiver `mod` is a submodule bound by `from pkg import mod` or + * `import pkg.mod as mod`. The member (`helper`) lives in the submodule + * file (`pkg/mod.py`), not in the package the generic chain resolves the + * import source to — so without this the `calls` edge is silently dropped + * (issue #578). Mirrors the Go cross-package resolver (#388): map the + * receiver to a module path, then find the member by name filtered to the + * file at that path. + */ +function resolvePythonModuleAttributeReference( + ref: UnresolvedRef, + imports: ImportMapping[], + context: ResolutionContext +): ResolvedRef | null { + // Qualified call only: receiver before `.`, member after. A bare + // reference (no dot) is an in-file/bare-import call handled elsewhere. + const dotIdx = ref.referenceName.indexOf('.'); + if (dotIdx <= 0) return null; + const receiver = ref.referenceName.substring(0, dotIdx); + const memberName = ref.referenceName.substring(dotIdx + 1); + // Only single-level member access (`mod.helper`). Deeper chains like + // `mod.sub.helper` would need package-walk logic; bail rather than risk + // a wrong edge. + if (!memberName || memberName.includes('.')) return null; + + for (const imp of imports) { + // Keyed on the import binding only. If a local variable / parameter + // shadows the import name (`from pkg import mod; mod = X(); mod.f()`) + // this can't tell the shadow from the module and may over-attribute — + // same assumption the Go/Java resolvers make; rare in practice. + if (imp.localName !== receiver) continue; + + // The dotted module path the receiver refers to: + // `import pkg.mod as m` → namespace import, source IS the module + // `from pkg import mod` → the submodule is `.` + const moduleDotted = imp.isNamespace + ? imp.source + : `${imp.source}.${imp.exportedName}`; + const stem = moduleDotted.replace(/\./g, '/'); + + // Match the member declared in the file the import resolves to. The + // root-relative file-path stem (drop the `.py` suffix and a trailing + // `/__init__` so `pkg/mod.py` and `pkg/mod/__init__.py` both compare + // as `pkg/mod`) must equal the import's dotted path. The exact match + // is the disambiguation signal: a same-named member in a different + // module won't share the stem, so it can't be picked by mistake. + // + // No `isExported` filter: Python has no export keyword — every + // module-level def is importable (Go gates on capitalization, Python + // doesn't), and `isExported` is false for Python function nodes. + const match = context.getNodesByName(memberName).find((n) => { + if (n.language !== 'python') return false; + const fileStem = n.filePath + .replace(/\\/g, '/') + .replace(/\.py$/, '') + .replace(/\/__init__$/, ''); + return fileStem === stem; + }); + if (match) { + return { original: ref, targetNodeId: match.id, confidence: 0.9, resolvedBy: 'import' }; + } + } + return null; +} + /** Recursive depth cap for re-export chain following. Real codebases * rarely chain barrels more than 2–3 deep; 8 is a generous safety * net that still bounds worst-case work. */