From 9d52b70c8238231ca030295206d891d8146f995c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:35 -0700 Subject: [PATCH] fix(parser/python): an import of a module shipped with a .pyi beside it resolves to the .py The project linker keys modules by qualified name, and a package that ships `impl.pyi` beside `impl.py` declares that name twice. The later file won the map, and `.pyi` sorts after `.py`, so `from .impl import *` and `import pkg` resolved to the stub. The engine refuses a stub declaration as an edge target, so every call into such a package ended at the library boundary and its tests reached nothing in it. A stub no longer replaces a non-stub module of the same name. A stub with no `.py` beside it is still the import's target. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../parsers/python/extractors/python-resolution-linker.ts | 8 ++++++++ parser/src/workflows/python/python-project-analyzer.ts | 1 + .../stub-beside-source-resolves-to-source/case.json | 8 ++++++++ .../stub-beside-source-resolves-to-source/pkg/__init__.py | 1 + .../pkg/__init__.pyi | 1 + .../stub-beside-source-resolves-to-source/pkg/impl.py | 5 +++++ .../stub-beside-source-resolves-to-source/pkg/impl.pyi | 1 + .../stub-beside-source-resolves-to-source/pyproject.toml | 3 +++ .../tests/__init__.py | 0 .../tests/test_chunk.py | 5 +++++ 10 files changed, 33 insertions(+) create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/case.json create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c5485..71d448e5 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,12 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * Whether this module is a `.pyi` stub. A stub shipped beside its `.py` carries the + * same qualified name, and an import must reach the `.py`: that is the module the + * interpreter loads. A stub with no `.py` beside it stays the import's target. + */ + isStub?: boolean; } export interface ProjectResolutionStats { @@ -189,6 +195,8 @@ export class PythonResolutionLinker { const moduleByQualifiedName = new Map(); for (const module of modules) { + const seen = moduleByQualifiedName.get(module.qualifiedName); + if (seen !== undefined && module.isStub && !seen.isStub) continue; moduleByQualifiedName.set(module.qualifiedName, module); } diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a..0ec56d2b 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + isStub: filePath.endsWith('.pyi'), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/case.json b/tests/cases/python/stub-beside-source-resolves-to-source/case.json new file mode 100644 index 00000000..97a74a7f --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/case.json @@ -0,0 +1,8 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a package shipping .pyi stubs beside its .py modules: a call that resolves to the stub's declaration reaches the .py implementation of the same qualified name, the one CPython runs", + "run": ["impact", "pkg/impl.py:4", "--tests"], + "want": ["test_chunk.py"]}, + {"why": "CONTROL: the stub itself is never an edge target", + "run": ["impact", "pkg/impl.pyi:1", "--tests"], + "avoid": ["[resolved] test_chunk"]}]} diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py new file mode 100644 index 00000000..66c63213 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py @@ -0,0 +1 @@ +from .impl import * # noqa diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi new file mode 100644 index 00000000..475cf4ea --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi @@ -0,0 +1 @@ +from .impl import * diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py new file mode 100644 index 00000000..79628415 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py @@ -0,0 +1,5 @@ +__all__ = ["chunk"] + + +def chunk(xs, n): + return [xs[i:i + n] for i in range(0, len(xs), n)] diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi new file mode 100644 index 00000000..3f70ad89 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi @@ -0,0 +1 @@ +def chunk(xs: list, n: int) -> list: ... diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml new file mode 100644 index 00000000..c311e64b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "pkg" +version = "0" diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py new file mode 100644 index 00000000..c459b14b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py @@ -0,0 +1,5 @@ +import pkg as p + + +def test_chunk(): + assert p.chunk([1, 2, 3], 2) == [[1, 2], [3]]