Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 + '.')) {
Expand Down Expand Up @@ -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 `<source>.<exportedName>`
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. */
Expand Down