Skip to content

Treat every symbol in a test file as test code - #1023

Merged
tirth8205 merged 4 commits into
stagingfrom
fix/test-node-detection
Sep 17, 2026
Merged

tirth8205 merged 4 commits into
stagingfrom
fix/test-node-detection

Conversation

@tirth8205

Copy link
Copy Markdown
Owner

Problem

The PR review comment this project posts on its own pull requests asks authors to write tests for their tests. On PR #1014 it listed tests/test_upgrade_path.py::_check, _base_env, _venv_python and _venv_script as test gaps.

Root cause

Two, fixed at their own level.

The parser never set is_test on anything but functions. is_test was passed at the Function/Test construction sites only, so every Class in a test file was stored with is_test = 0, and so was every private helper and fixture whose name did not match a test pattern. changes.py pulls Class into the changed-symbol set and filtered only on node.is_test, so those rows all survived into test_gaps.

Test-file detection was three regexes in three modules that disagreed. flows.py knew nothing about Go, refactor.py knew nothing about tests/ directories, and the parser's directory patterns were unanchored, so src/latest/ read as test/ and contests/ read as tests/.

Fix

  • parser.parse_bytes marks every node it returns from a test file, whatever its kind, in one pass after extraction. Running it after extraction leaves TESTED_BY generation, which keys off the test functions alone, unchanged.
  • One definition of a test file, parser.is_test_file, anchored on path separators and extended to the conventions it was missing: Ruby spec/ and *_spec.rb, C# *Tests.cs, conftest.py, Java and Kotlin *Tests, and the e2e/test-utils directories refactor.py already knew about. flows.py and refactor.py use it.
  • changes.py and tools/review.py exclude a node whose file is a test file regardless of what is_test says on the row, so a graph built before this change stops producing the bad report without a rebuild.

is_test now means "this symbol lives in test code". kind still carries what the name/annotation detector decided, so @Test-annotation and name-prefix coverage is unchanged; the tests that pinned it now assert on kind.

Measured on real diffs from this repository

Graph rebuilt from scratch at each measured commit.

Diff Metric Before After
PR #1014 test gaps 20 0
PR #1014 of which test-file artefacts 20 0
--base HEAD~6, 46 files test gaps 55 2
--base HEAD~6, 46 files of which test-file artefacts 53 0
--base HEAD~6, 46 files production gaps 2 2
PR #997, production files only test gaps 107 98
PR #997, production files only of which test-file artefacts 9 0
PR #997, production files only production gaps 98 98

No production gap is lost: the 98 names are identical sets before and after, including code_review_graph/parser.py::_collect_python_receiver_evidence, which has no TESTED_BY edge of any kind. The two production gaps on the HEAD~6 diff, MissingOptionalDependencyError and main, are the same before and after.

Eval suite

All 42 benchmarks over all 6 repositories, before and after.

  • impact_accuracy, search_quality, agent_baseline, multi_hop_retrieval: unchanged cell for cell.
  • token_efficiency: ratios unchanged, graph_tokens moves by under 0.2%.
  • build_performance: timing columns only.
  • flow_completeness: entry-point recall unchanged (fastapi 1.0, gin 1.0); detected_flows drops (fastapi 401 to 374, gin 186 to 161, crg 92 to 65) because test-file entry points are no longer traced as production flows, which is what include_tests=False asks for. Average flow depth rises on crg (2.5 to 3.0): the flows that went away were shallow test-helper chains.

Still wrong, not fixed here

  • changes.py looks tests up with get_edges_by_source(node.qualified_name) and has no bare-name fallback. 10769 of the 15291 TESTED_BY edges in a full build of this repository are stored with a bare source, so a function whose edge landed that way is called untested. detect_changes_func has three TESTED_BY edges, all bare-sourced, and is reported untested. 10 of the 98 production gaps above are this, not real gaps.
  • The gate is satisfiable by a test that never runs or never asserts. A @pytest.mark.skip test and an assertion-free test both emit CALLS, so both produce TESTED_BY and clear the gap.

Gates

pytest tests/ -q: 3428 passed, 10 skipped, 2 xpassed (was 3383 passed, 10 skipped, 2 xpassed). ruff check code_review_graph/: clean. mypy code_review_graph/ --ignore-missing-imports --no-strict-optional: clean, 72 source files.

🤖 Generated with Claude Code

The PR review comment this project posts on its own pull requests was
asking authors to write tests for their tests. On PR #1014 it listed
tests/test_upgrade_path.py::_check, _base_env, _venv_python and
_venv_script as test gaps.

Two causes, fixed at their own level.

The parser only ever passed is_test when it built a Function or Test
node, so every Class in a test file was stored with is_test = 0, and so
was every private helper and fixture whose name did not match a test
pattern. parse_bytes now marks each node it returns from a test file,
whatever its kind, in one pass after extraction. Running it after
extraction leaves TESTED_BY generation, which keys off the test
functions alone, unchanged.

Test-file detection itself was three regexes in three modules, each
with a different idea of what a test file is: flows.py knew nothing
about Go, refactor.py knew nothing about tests/ directories, and the
parser's directory patterns matched anywhere in the string, so
src/latest/ read as test/ and contests/ read as tests/. There is now
one definition in parser.is_test_file, anchored on path separators and
extended to the conventions it was missing: Ruby spec/ and *_spec.rb,
C# *Tests.cs, conftest.py, Java and Kotlin *Tests, and the e2e and
test-utils directories refactor.py already knew about. flows.py and
refactor.py use it.

changes.py and tools/review.py exclude a node whose file is a test file
regardless of what is_test says on the row, so a graph built before
this change stops producing the bad report without a rebuild.

Measured on real diffs from this repository, rebuilding the graph each
time:

- PR #1014's diff: 20 test gaps, all 20 test-file artefacts, to 0.
- detect-changes --base HEAD~6 at that commit, a test-heavy 46-file
  diff: 55 gaps of which 53 were test-file artefacts, down to 2. The
  two production gaps, MissingOptionalDependencyError and main, are
  the same before and after.
- PR #997's production files (graph.py, parser.py, tools/query.py):
  107 gaps to 98. The 9 dropped are all test-file artefacts; the 98
  production gaps are identical sets before and after, including
  parser.py::_collect_python_receiver_evidence.

The eval suite runs all 42 benchmarks over 6 repositories before and
after. impact_accuracy, search_quality, agent_baseline and
multi_hop_retrieval are unchanged cell for cell. token_efficiency
ratios are unchanged. flow_completeness keeps its entry-point recall
but reports fewer flows (fastapi 401 to 374, gin 186 to 161) because
test-file entry points are no longer traced as production flows, which
is what include_tests=False asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.65 (MEDIUM) — 108 changed function(s)/class(es), 28 affected flow(s), 42 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.65 medium code_review_graph/incremental.py::_store_vcs_metadata code_review_graph/incremental.py:830 no
0.65 medium code_review_graph/parser.py::_strip_prefix code_review_graph/parser.py:2039 no
0.65 medium code_review_graph/parser.py::_repo_relative_path code_review_graph/parser.py:2051 no
0.55 medium code_review_graph/flows.py::detect_entry_points code_review_graph/flows.py:156 yes
0.55 medium code_review_graph/graph.py::GraphStore code_review_graph/graph.py:251 yes
0.55 medium code_review_graph/jedi_resolver.py::enrich_jedi_calls code_review_graph/jedi_resolver.py:27 no
0.40 medium code_review_graph/refactor.py::_in_test_file code_review_graph/refactor.py:289 no
0.40 medium code_review_graph/refactor.py::_is_plausible_caller code_review_graph/refactor.py:330 no
0.40 medium code_review_graph/tools/review.py::_generate_review_guidance code_review_graph/tools/review.py:375 yes
0.35 low code_review_graph/parser.py::_repo_root_prefixes code_review_graph/parser.py:2010 no

Affected execution flows

  • main — criticality 0.65, 49 node(s) across 6 file(s)
  • main — criticality 0.53, 50 node(s) across 3 file(s)
  • dump_database — criticality 0.49, 11 node(s) across 3 file(s)
  • run — criticality 0.47, 26 node(s) across 3 file(s)
  • populate_stub_embeddings — criticality 0.43, 11 node(s) across 3 file(s)
  • ...and 23 more affected flow(s)

Test gaps

  • code_review_graph/incremental.py::_store_vcs_metadata (code_review_graph/incremental.py:830)
  • code_review_graph/jedi_resolver.py::enrich_jedi_calls (code_review_graph/jedi_resolver.py:27)
  • code_review_graph/parser.py::_repo_root_prefixes (code_review_graph/parser.py:2010)
  • code_review_graph/parser.py::_resolved_dir (code_review_graph/parser.py:2031)
  • code_review_graph/parser.py::_strip_prefix (code_review_graph/parser.py:2039)
  • ...and 37 more without direct tests

Token savings: this graph-backed report used ~299,464 fewer tokens (~84%) 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 and others added 3 commits September 16, 2026 23:15
is_test_file was applied to the absolute path the graph stores, so its
directory rules matched parent directories above the checkout. A repository
cloned into a directory named test, tests, spec or specs had every one of its
production symbols classified as test code. Measured on this repository
checked out under a directory named "test": 399 test gaps became 0, 210 entry
points became 0, 76 dead-code candidates became 0, all 6589 nodes were marked
is_test, and the review-guidance coverage warning disappeared. A CI workspace
or job directory named "test" was enough to make the tool report a clean bill
of health for an entire repository.

Test-ness is now read from the path relative to the repository root. Directory
rules need that root; filename rules do not, so they are separated and only
the directory rules require it. When the root is unknown, or the path lies
outside it, the directory rules are dropped rather than guessed: that
under-reports test files but never hides a production gap. Every call site
passes a root - CodeParser through _is_test_path and _is_test_function,
changes/flows/refactor/review through the root recorded at build time, and
jedi_resolver through the root it already had. Builds record repo_root in
metadata; a graph built before that falls back to the default database
location.

Four narrower false negatives, each one a production file read as test code:

- specs?/ matched by directory name alone. "spec/" and "specs/" hold design
  documents and generated API specs as often as RSpec files, so a Ruby source
  file under the directory is now required as evidence.
- Test.php was unanchored on the stem, so src/ABTest.php, an A/B testing
  feature, was test code. The character before "Test" must now be lowercase
  or a digit, which makes it a class-name boundary. Same fix for the C#,
  Java, Kotlin, Scala and Groovy rules, which had the same defect.
- test-utils/ and test_utils/ were new to the parser and so to changes.py and
  tools/review.py. A shared library published from such a directory is
  production code. The rule is now opt-in and only dead-code detection asks
  for it, where a false "dead" report costs more than a missed one.
- A library that ships a test package to its users, the django/test/client.py
  shape, was excluded from gaps. A singular test/ directory that carries an
  __init__.py and sits inside another package is now production code.

find_dead_code had the same absolute-path defect in its package-alias
heuristic: directory segments were taken from the absolute path, so a
workspace directory named "test" matched import specifiers mentioning "test"
and suppressed candidates that the same tree elsewhere reported. It now reads
segments from the repository-relative path.

Measured on this repository, same tree, before and after: production test-gap
sets are identical (402), production entry points identical (210), production
dead-code candidates identical (76), eval accuracy identical, and a checkout
under test/, spec/ or prod/ now produces byte-identical reports.

The HCL test that filtered REFERENCES targets on "path" matched the absolute
file half of the target, so any checkout path containing "path" failed it. It
now inspects the symbol half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tirth8205
tirth8205 merged commit 5d1a4fe into staging Sep 17, 2026
22 checks passed
@tirth8205
tirth8205 deleted the fix/test-node-detection branch September 17, 2026 00:18
gowrishacv pushed a commit to gowrishacv/code-review-graph that referenced this pull request Nov 6, 2026
dead-code reports two families of method whose caller is a contract or a
test runner rather than a call site in the repository:

- @OverRide in production code. An override implements a supertype
  contract, so its caller is that contract. No hierarchy walk can rescue
  these, because the base is often the JDK and outside the graph.
- JUnit/TestNG annotations outside a test-named file. A shared harness
  deliberately lives in main so several test modules can depend on it, so
  the test-file exclusion from tirth8205#1023 does not cover it.

Both annotations are already stored on the node in extra['decorators'],
and _is_entry_point already consults them through
_has_framework_decorator; the patterns list simply had no entry for
Override, nor any for JUnit or TestNG. Both new patterns are anchored so
they cannot match unrelated names such as override_settings, which has
its own pattern further down the list.

Measured on a 125-file multi-module Java library: symbols reported dead
174 -> 120, the removal set being exactly the 54 @OverRide methods, with
nothing added.

Seven tests across the two modules that share the predicate. Five fail on
staging; the remaining two are guards that hold in both directions — the
anchoring of ^Override$, and the requirement that a genuinely
unreferenced method still be reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant