From 8921690d7286ba858337d2df478ef01772d6d055 Mon Sep 17 00:00:00 2001 From: Giancarlo Erra Date: Tue, 5 May 2026 00:54:22 +0100 Subject: [PATCH] fix(graph): resolve Python sibling-flat imports in service-style monorepos Resolves #46. Reported by @mrsuit92. Python projects where each top-level directory is a runnable application root (a common service-style monorepo layout) had `import config` from `service-a/main.py` produce 0 dependency edges, even when `service-a/config.py` sits next to the importer. At runtime Python resolves this correctly because the importer's directory is sys.path[0] when the file is run as `python main.py` from inside its own directory. The static resolver did not check that path. The Python case in graph-resolution.ts only tried: /.py /src/.py /lib/.py It did not try `/.py`, so non-relative sibling imports never resolved. Relative imports (`from .config import ...`) already used sourceDir and worked. Fix: add `/.py` as the LAST fallback, after the existing project-root and src/lib checks. Tried last to preserve project-root precedence, so any layout that resolved before this PR continues to resolve to the same file. resolveRelativePath also handles the `//__init__.py` package case via its built-in Python init fallback, so package-style sibling imports work too. Tests: 5 new cases in tests/unit/graph-resolution.test.ts covering sibling-flat resolution, dotted module paths, package via __init__.py, project-root precedence preservation, and the negative case (no match anywhere). Existing 730 tests continue to pass; total now 735. typecheck, biome, and CodeRabbit local review all clean. Co-authored-by: mrsuit92 --- src/services/graph-resolution.ts | 13 ++++ tests/unit/graph-resolution.test.ts | 97 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/services/graph-resolution.ts b/src/services/graph-resolution.ts index c160a13..28f9702 100644 --- a/src/services/graph-resolution.ts +++ b/src/services/graph-resolution.ts @@ -189,6 +189,19 @@ export function resolveImport( ); if (inSrc) return inSrc; } + + // Sibling-flat fallback (issue #46). Common in service-style monorepos + // where each top-level directory is a runnable Python application root + // and `import config` from `service-a/main.py` means + // `service-a/config.py` because the file is run via `python main.py` + // from inside its own directory. Tried last to preserve existing + // project-root precedence: any layout that already resolved before + // this PR continues to resolve to the same file. resolveRelativePath + // also handles the `//__init__.py` package case + // via its built-in Python init fallback. + const sibling = resolveRelativePath(modulePath, sourceDir, projectPath, fileSet, [".py"]); + if (sibling) return sibling; + return null; } diff --git a/tests/unit/graph-resolution.test.ts b/tests/unit/graph-resolution.test.ts index 3f80b8c..836824e 100644 --- a/tests/unit/graph-resolution.test.ts +++ b/tests/unit/graph-resolution.test.ts @@ -268,6 +268,103 @@ describe("graph-resolution", () => { expect(result).toBe("src/mypackage/utils.py"); }); + + it("resolves sibling-flat imports in service-style monorepos (#46)", () => { + // service-a/main.py runs as `python main.py` from inside service-a/, + // so `import config` resolves to service-a/config.py at runtime. + project = createTempProject({ + "service-a/main.py": "", + "service-a/config.py": "", + "service-b/main.py": "", + "service-b/config.py": "", + }); + + const result = resolveImport( + "config", + path.join(project.root, "service-a/main.py"), + project.root, + project.fileSet, + "python", + ); + + expect(result).toBe("service-a/config.py"); + }); + + it("resolves sibling-flat imports for dotted module paths", () => { + // `import shared.utils` from service-a/main.py with shared/utils.py + // sitting next to main.py. + project = createTempProject({ + "service-a/main.py": "", + "service-a/shared/utils.py": "", + }); + + const result = resolveImport( + "shared.utils", + path.join(project.root, "service-a/main.py"), + project.root, + project.fileSet, + "python", + ); + + expect(result).toBe("service-a/shared/utils.py"); + }); + + it("resolves sibling packages via __init__.py", () => { + // `import config` resolves to service-a/config/__init__.py when no + // bare service-a/config.py exists. + project = createTempProject({ + "service-a/main.py": "", + "service-a/config/__init__.py": "", + }); + + const result = resolveImport( + "config", + path.join(project.root, "service-a/main.py"), + project.root, + project.fileSet, + "python", + ); + + expect(result).toBe("service-a/config/__init__.py"); + }); + + it("preserves project-root precedence when the same name exists at root and as a sibling", () => { + // Backward-compat guarantee: `import config` from service-a/main.py + // still resolves to the project-root config.py if one exists, so + // existing layouts do not change after this PR. The sibling fallback + // only fires when the project-root lookup fails. + project = createTempProject({ + "config.py": "", + "service-a/main.py": "", + "service-a/config.py": "", + }); + + const result = resolveImport( + "config", + path.join(project.root, "service-a/main.py"), + project.root, + project.fileSet, + "python", + ); + + expect(result).toBe("config.py"); + }); + + it("returns null when neither project-root, src/, lib/, nor sibling have the module", () => { + project = createTempProject({ + "service-a/main.py": "", + }); + + const result = resolveImport( + "config", + path.join(project.root, "service-a/main.py"), + project.root, + project.fileSet, + "python", + ); + + expect(result).toBeNull(); + }); }); describe("Rust resolution", () => {