fix(graph): accept dotted Python module imports as endpoint evidence (#903) - #960
Conversation
…irth8205#903) `_resolve_python_module_in_repo` only finds a module by walking up from the importing file, so it gives up on the standard `src` layout and on framework-mediated packages such as Odoo's `odoo.addons.*`. The parser then stores the raw dotted module as the IMPORTS_FROM target — 78.3% of them in the reporter's 9.9k-file codebase. Both bare-endpoint gates key import evidence by file path, so comparing an absolute path against a dotted module could never match. `supported` came back empty, `_select_evidence_backed_candidate` returned None, and every edge depending on the fallback was dropped. `tests_for` returned 0 for 2,712 symbols that do have TESTED_BY edges, while `callers_of` on the same target returned the test flagged `is_test=True` — a silent false negative reporting covered code as uncovered. Map each dotted module back to the file that could define it, using the indexed `.py` paths rather than the filesystem so it also works on a graph queried away from the source tree. Applied in both places that build import evidence: `_resolve_bare_endpoints` (the postprocess pass) and `get_transitive_tests`'s own fallback, which maintains a separate `import_cache`. This is the Python counterpart of the C# fix in tirth8205#799, which had the identical namespace-vs-path mismatch. Two guards against manufacturing evidence, since the reporter measured naive suffix matching fabricating 20 of 326 resolutions: - A module maps to a file only when exactly one indexed file answers to it. Ambiguous modules are dropped, matching the existing `len(supported) == 1` conservatism. - Matching is on whole path segments, so `mypkg.core` cannot resolve against `.../notmypkg/core.py`. The index is built on first use in the query path — most queries never reach this fallback, and it scans every indexed `.py` path. `_resolve_bare_endpoints` already returns early when there are no bare edges, so the cost is only paid when there is work to do. Six regression tests: resolution and `tests_for` end to end, `pkg/__init__.py` mapping to `pkg`, and three negative guards (ambiguous module, unknown module, partial-segment match) that all pass before the fix and must keep passing. Closes tirth8205#903
code-review-graph reviewOverall risk: 0.40 (MEDIUM) — 17 changed function(s)/class(es), 7 affected flow(s), 4 test gap(s) Risk-scored changes
Affected execution flows
Test gaps
Token savings: this graph-backed report used ~32,341 fewer tokens (~85%) than reading every changed file in full (estimated, chars/4 approximation). Powered by code-review-graph — local-first analysis; no code leaves the CI runner. |
|
Integrated on |
|
Thanks, and the scoping is the part I'd have wanted stated. Unique suffix indexing is evidence, not resolution: it answers where exactly one candidate exists and declines otherwise, so the ambiguous cases stay unresolved rather than being guessed at. The framework-specific layouts in #903 need real import machinery, which this deliberately isn't. |
Linked issue
Closes #903
What & why
_resolve_python_module_in_repoonly finds a module by walking up from the importing file, so it gives up on the standardsrclayout and on framework-mediated packages like Odoo'sodoo.addons.*. The parser then keeps the raw dotted module as theIMPORTS_FROMtarget — 78.3% of them in the reporter's 9.9k-file codebase.Both bare-endpoint gates key import evidence by file path, so comparing an absolute path against a dotted module can never match.
supportedcomes back empty,_select_evidence_backed_candidatereturnsNone, and every edge that depends on the fallback is dropped by thecontinue.tests_forreturned 0 for 2,712 symbols that do haveTESTED_BYedges, whilecallers_ofon the same target returned the test flaggedis_test=True— a silent false negative reporting covered code as uncovered.The fix maps each dotted module back to the file that could define it, built from the indexed
.pypaths rather than the filesystem so it also works on a graph queried away from the source tree. Applied in both places that build import evidence:_resolve_bare_endpoints— the postprocess passget_transitive_tests— its own fallback maintains a separateimport_cache, which is why fixing only the resolver lefttests_forstill returning 0 on a graph that hadn't been through postprocessingThis is the Python counterpart of #799, which fixed the identical namespace-vs-path mismatch for C#.
Two guards against manufacturing evidence, since the reporter measured naive suffix matching fabricating 20 of 326 resolutions:
len(supported) == 1conservatism.mypkg.corecannot resolve against.../notmypkg/core.py.The index is built on first use in the query path — most queries never reach this fallback, and it scans every indexed
.pypath._resolve_bare_endpointsalready returns early when there are no bare edges, so the cost is only paid when there is work to do.Before/after on a minimal
src-layout repro (production insrc/mypkg/core.py, test importingfrom mypkg.core import compute_total):resolve_bare_tested_by_sources()TESTED_BYsourcecompute_total(bare).../src/mypkg/core.py::compute_totaltests_forresult_countHow it was tested
Six regression tests in
TestPythonDottedModuleImportEvidence, written before the fix:test_dotted_module_import_backs_bare_tested_by_source— the resolvertest_tests_for_finds_test_behind_dotted_module_import— the query path end to endtest_package_import_maps_to_package_init—import mypkg→mypkg/__init__.py, notmypkg.__init__test_ambiguous_dotted_module_stays_bare— two files answering one module is not evidencetest_unrelated_dotted_module_is_not_evidence— a module matching no indexed filetest_partial_segment_match_is_not_evidence—mypkg.corevs.../notmypkg/core.pyThe first three fail on
mainand pass after; the three negative guards pass both before and after, so they hold the fix to not over-resolving.One note on scope:
ruff format --checkreportsgraph.pyandtest_graph.pyas needing reformatting, but that is pre-existing — both files report the same on unmodifiedmain, and CI runsruff check, notruff format. I left it alone rather than mix unrelated reformatting into this diff.Checklist
uv run pytest tests/ --tb=short -quv run ruff check code_review_graph/uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional