Skip to content

fix(graph): accept dotted Python module imports as endpoint evidence (#903) - #960

Open
Chang-Jin-Lee wants to merge 1 commit into
tirth8205:mainfrom
Chang-Jin-Lee:fix/python-dotted-module-import-evidence
Open

fix(graph): accept dotted Python module imports as endpoint evidence (#903)#960
Chang-Jin-Lee wants to merge 1 commit into
tirth8205:mainfrom
Chang-Jin-Lee:fix/python-dotted-module-import-evidence

Conversation

@Chang-Jin-Lee

Copy link
Copy Markdown
Contributor

Linked issue

Closes #903

What & why

_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 like Odoo's odoo.addons.*. The parser then keeps 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 can never match. supported comes back empty, _select_evidence_backed_candidate returns None, and every edge that depends on the fallback is dropped by the continue. 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.

The fix maps each dotted module back to the file that could define it, built from 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
  • get_transitive_tests — its own fallback maintains a separate import_cache, which is why fixing only the resolver left tests_for still returning 0 on a graph that hadn't been through postprocessing

This 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:

  • 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.

Before/after on a minimal src-layout repro (production in src/mypkg/core.py, test importing from mypkg.core import compute_total):

before after
resolve_bare_tested_by_sources() 0 1
TESTED_BY source compute_total (bare) .../src/mypkg/core.py::compute_total
tests_for result_count 0 1

How it was tested

uv run pytest tests/ --tb=short -q
#   2991 passed, 9 skipped, 2 xpassed in 65:46
uv run ruff check code_review_graph/
#   All checks passed!
uv run mypy code_review_graph/graph.py --ignore-missing-imports --no-strict-optional
#   Success: no issues found in 1 source file

Six regression tests in TestPythonDottedModuleImportEvidence, written before the fix:

  • test_dotted_module_import_backs_bare_tested_by_source — the resolver
  • test_tests_for_finds_test_behind_dotted_module_import — the query path end to end
  • test_package_import_maps_to_package_initimport mypkgmypkg/__init__.py, not mypkg.__init__
  • test_ambiguous_dotted_module_stays_bare — two files answering one module is not evidence
  • test_unrelated_dotted_module_is_not_evidence — a module matching no indexed file
  • test_partial_segment_match_is_not_evidencemypkg.core vs .../notmypkg/core.py

The first three fail on main and 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 --check reports graph.py and test_graph.py as needing reformatting, but that is pre-existing — both files report the same on unmodified main, and CI runs ruff check, not ruff format. I left it alone rather than mix unrelated reformatting into this diff.

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • Docs updated where behavior changed — N/A, internal resolution behavior with no user-facing surface change

…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
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.40 (MEDIUM) — 17 changed function(s)/class(es), 7 affected flow(s), 4 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.40 medium code_review_graph/graph.py::GraphStore._python_module_file_index code_review_graph/graph.py:732 no
0.35 low code_review_graph/graph.py::GraphStore._candidate_for_context code_review_graph/graph.py:641 no
0.30 low code_review_graph/graph.py::GraphStore code_review_graph/graph.py:185 yes
0.30 low code_review_graph/graph.py::GraphStore.get_transitive_tests code_review_graph/graph.py:541 yes
0.15 low code_review_graph/graph.py::GraphStore._resolve_bare_endpoints code_review_graph/graph.py:1009 no
0.15 low tests/test_graph.py::TestPythonDottedModuleImportEvidence._func tests/test_graph.py:1314 yes
0.15 low tests/test_graph.py::TestPythonDottedModuleImportEvidence._edge tests/test_graph.py:1326 yes
0.15 low tests/test_graph.py::TestPythonDottedModuleImportEvidence._endpoints tests/test_graph.py:1335 yes
0.05 low tests/test_graph.py::TestPythonDottedModuleImportEvidence tests/test_graph.py:1293 no
0.05 low tests/test_graph.py::TestPythonDottedModuleImportEvidence.setup_method tests/test_graph.py:1305 yes

Affected execution flows

  • edge_to_dict — criticality 0.41, 2 node(s) across 1 file(s)
  • load_flow_adjacency — criticality 0.33, 4 node(s) across 1 file(s)
  • get_subgraph — criticality 0.32, 9 node(s) across 1 file(s)
  • get_config_consumers — criticality 0.32, 3 node(s) across 1 file(s)
  • __init__ — criticality 0.31, 3 node(s) across 1 file(s)
  • ...and 2 more affected flow(s)

Test gaps

  • code_review_graph/graph.py::GraphStore._candidate_for_context (code_review_graph/graph.py:641)
  • code_review_graph/graph.py::GraphStore._python_module_file_index (code_review_graph/graph.py:732)
  • code_review_graph/graph.py::GraphStore._resolve_bare_endpoints (code_review_graph/graph.py:1009)
  • tests/test_graph.py::TestPythonDottedModuleImportEvidence (tests/test_graph.py:1293)

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.

@tirth8205

Copy link
Copy Markdown
Owner

Integrated on integration/token-efficiency-hardening as 3d6e303. Unique indexed Python module suffixes now provide conservative evidence for bare endpoint resolution, including src layouts and package initialisers. Positive and ambiguous/unrelated negative cases passed; this does not claim complete runtime import resolution or every framework-specific layout from #903.

@Chang-Jin-Lee

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: tests_for returns 0 for Python — bare-name fallback compares file paths against dotted module names

2 participants