From 01964068698492fd1ea42d34169681f7f5ab3678 Mon Sep 17 00:00:00 2001 From: JacquesBLR Date: Thu, 6 Aug 2026 10:19:15 +0200 Subject: [PATCH] fix(resolution): resolve line-wrapped Python parenthesized import lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractPythonImports re-parses raw source text with a regex, separate from the tree-sitter AST. The regex `[^#\n]+` stopped at the first newline, so a PEP 8 line-wrapped `from pkg import (a,\n b as c)` list lost every name after the statement's first physical line entirely — not just aliased ones, the mapping never existed at all. Found while running CodeGraph on a real project whose imports wrap this way; reproduces with as few as two names split across two lines. --- __tests__/resolution.test.ts | 53 +++++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 12 +++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..b3a531e8a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1243,6 +1243,59 @@ def external_caller(): expect(externalCalls).toHaveLength(0); }); + it('resolves Python imports inside a line-wrapped `from pkg import (...)` list', async () => { + // extractPythonImports re-parses raw source with a regex, separately from + // the tree-sitter AST. That regex was `from\s+([\w.]+)\s+import\s+([^#\n]+)` + // — the `[^#\n]+` capture stops at the first newline. PEP 8 wraps a long + // parenthesized import list across multiple physical lines, so every name + // after line one of the statement fell outside the match and got no + // ImportMapping at all. Reproduces with as few as two names split across + // two lines; both names below are checked so a fix that only recovers the + // last name (rather than every name after line one) still fails this. + fs.mkdirSync(path.join(tempDir, 'services')); + fs.writeFileSync(path.join(tempDir, 'services', '__init__.py'), ''); + fs.writeFileSync( + path.join(tempDir, 'services', 'rentabilite.py'), + 'def compute():\n return 42\n' + ); + fs.writeFileSync( + path.join(tempDir, 'services', 'echeancier.py'), + 'def upcoming():\n return []\n' + ); + fs.writeFileSync( + path.join(tempDir, 'main.py'), + `from .services import (echeancier, + rentabilite) + + +def etudes(): + return rentabilite.compute() + + +def dashboard(): + return echeancier.upcoming() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const etudes = cg.getNodesByKind('function').filter((n) => n.name === 'etudes')[0]; + expect(etudes).toBeDefined(); + const etudesCalls = cg.getOutgoingEdges(etudes!.id).filter((e) => e.kind === 'calls'); + expect(etudesCalls).toHaveLength(1); + const etudesTarget = cg.getNode(etudesCalls[0]!.target); + expect(etudesTarget?.name).toBe('compute'); + expect(etudesTarget?.filePath.replace(/\\/g, '/')).toBe('services/rentabilite.py'); + + const dashboard = cg.getNodesByKind('function').filter((n) => n.name === 'dashboard')[0]; + expect(dashboard).toBeDefined(); + const dashboardCalls = cg.getOutgoingEdges(dashboard!.id).filter((e) => e.kind === 'calls'); + expect(dashboardCalls).toHaveLength(1); + const dashboardTarget = cg.getNode(dashboardCalls[0]!.target); + expect(dashboardTarget?.name).toBe('upcoming'); + expect(dashboardTarget?.filePath.replace(/\\/g, '/')).toBe('services/echeancier.py'); + }); + it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => { // In Go a type's methods are commonly declared in a different file from the // `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..967708493 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -902,12 +902,18 @@ function extractJSImports(content: string): ImportMapping[] { function extractPythonImports(content: string): ImportMapping[] { const mappings: ImportMapping[] = []; - // from X import Y - const fromImportRegex = /from\s+([\w.]+)\s+import\s+([^#\n]+)/g; + // from X import Y — either a parenthesized list, which PEP 8 line-wrapping + // routinely spreads across multiple physical lines (`from pkg import (\n a,\n b as c,\n)`), + // or a single-line list. `[^#\n]+` alone stops at the first line break, so a + // wrapped list silently lost every name after line one — including aliased + // ones, which is why real trees (which wrap) kept reporting no callers + // for names imported anywhere but a statement's first line. + const fromImportRegex = /from\s+([\w.]+)\s+import\s+(?:\(([\s\S]*?)\)|([^#\n]+))/g; let match; while ((match = fromImportRegex.exec(content)) !== null) { - const [, source, imports] = match; + const [, source, parenImports, plainImports] = match; + const imports = parenImports !== undefined ? parenImports : plainImports; const names = imports!.split(',').map((s) => s.trim()); for (const name of names) {