Skip to content

Treat @Override and JUnit annotations as entry points in dead-code - #1035

Open
aala-conga wants to merge 2 commits into
tirth8205:stagingfrom
aala-conga:fix/deadcode-override-junit
Open

aala-conga wants to merge 2 commits into
tirth8205:stagingfrom
aala-conga:fix/deadcode-override-junit

Conversation

@aala-conga

Copy link
Copy Markdown

Fixes #1034.

dead-code reports two families of method whose caller is a contract or a test runner, not a call
site in the repository:

  • @Override in production code. An override implements a supertype contract, so its caller is
    that contract — a supertype-typed reference, a framework, or the runtime itself for
    Object.hashCode/equals/toString. No hierarchy walk can rescue these, because the base is
    often the JDK and therefore 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 Treat every symbol in a test file as test code #1023's test-file exclusion does not cover it.

Both annotations are already on the node — the parser stores them in extra['decorators'] — and
_is_entry_point already consults them through _has_framework_decorator. The patterns list covered
Spring, Django, Click, Celery, Angular, Express and pytest.fixture, but had no entry for Override
and none for JUnit or TestNG. This adds two patterns, both anchored so they cannot match unrelated
names such as override_settings, which is handled by its own pattern further down the list.

Relationship to #1023

Against stock 2.3.8 this looked like a four-item problem; on current staging it is a two-item one,
because #1023 already removed everything in the family that lives in a test-named file. What is left
is @Override in production code, and JUnit annotations on methods outside such files.

Measured

A 125-file multi-module Java library, built on staging and again with this patch:

staging with the patch
symbols reported dead 174 120 (−31 %)
of those, carrying @Override 54 0
findings added by the patch 0

The removal set is exactly the 54 @Override methods, nothing else moved. Spot-checking them:
getHeader and getStatusCode implement the Azure Functions HttpResponseMessage interface,
getSupportedAnnotationTypes overrides javax.annotation.processing.AbstractProcessor — all called
by a framework, none from the repository.

On this library the JUnit patterns change nothing, because its tests all live in test-named files.
The second reproducer in the issue is the case they cover.

Tests

Seven cases, in the two modules that share the predicate — detect_entry_points in flows.py and
find_dead_code in refactor.py, which both reach it through _has_framework_decorator.

Five of them fail on staging and pass with the patch:

tests/test_flows.py::TestFlows::test_detect_entry_points_java_override
tests/test_flows.py::TestFlows::test_detect_entry_points_junit_lifecycle
tests/test_flows.py::TestFlows::test_detect_entry_points_junit_test_annotation
tests/test_refactor.py::TestFindDeadCodeJavaAnnotations::test_override_is_not_dead_code
tests/test_refactor.py::TestFindDeadCodeJavaAnnotations::test_junit_lifecycle_outside_a_test_file_is_not_dead_code

The other two pass in both directions on purpose — they are guards rather than proofs:
test_override_pattern_is_anchored (a decorator named Overridable must not match ^Override$) and
test_a_genuinely_unreferenced_method_is_still_reported (the patch must narrow the false positives,
not silence the query).

Each annotated method in the flows tests is given an incoming CALLS edge, so that rule 1 of
detect_entry_points — no callers — cannot carry the assertion on its own and the annotation is the
only thing that can make the method an entry point. Note that detect_entry_points skips test files
unless include_tests=True; the JUnit cases are therefore written in ordinary files, which is also
the case the patch is for.

Full suite, fresh clone of staging at f0e4eb7, Python 3.13, pip install -e ".[dev]":

run result
staging, untouched — baseline 3963 passed, 773 skipped, 2 xfailed, 2 xpassed (349 s)
+ the patch + these tests ``3970 passed, 773 skipped, 2 xfailed, 2 xpassed (339 s)

Not addressed here

Two further false positives the reproducers surface, both out of scope for this change: the harness
class is still reported, because the class-level exclusion only checks _has_framework_decorator
and not whether every method inside is an entry point; and constructors are reported, because a
new Widget(...) edge targets the bare name Widget rather than the constructor node, which
therefore has no incoming edge at all.

🤖 Generated with Claude Code

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>
@tirth8205

Copy link
Copy Markdown
Owner

A dead @Override on a base type outside the repo is no longer reportable at all, and that out-of-repo case is the rationale the PR body gives.

public class MultiHandler extends com.external.BaseHandler {
    @Override public void handleLive()  { doWork(); }
    @Override public void handleStale() { doWork(); }
}
// App.main constructs MultiHandler and calls handleLive() only
staging:  [Class] App
          [Function] handleStale (MultiHandler.java:12)
this PR:  [Class] App

handleStale is dead and nothing reports it. App references the class so the class is alive, and the base is out of graph so there is no declaration side to fall back on. An in-repo base still surfaces the interface declaration, so that half is mitigated. The "Not addressed here" section should say this.

Half of the JUnit alternation cannot fire. Test, ParameterizedTest, RepeatedTest and TestFactory are already in parser._TEST_ANNOTATIONS, so the parser sets is_test=1 and both consumers drop those nodes before _has_framework_decorator runs. refactor.py has if node.is_test or _is_test_file(node.file_path): continue, and flows.py has the same guard under the default include_tests=False. A bare @Test method in src/main is already absent from dead-code on staging, so there was no false positive to remove. The one JUnit form that does reach the predicate is excluded by the anchor. The parser strips the leading @ and keeps the arguments, so @Test(expected = IllegalStateException.class) is stored as Test(expected = IllegalStateException.class) and ^Test$ misses it. It is still reported dead with the patch. ^Test\b would catch it, and the same anchoring misses @org.junit.jupiter.api.BeforeEach and @DataProvider(name = "rows"). The comment says TestNG, but BeforeMethod, AfterMethod, BeforeSuite, AfterSuite, BeforeTest, AfterTest and DataProvider are not in the pattern. Only Test, BeforeClass and AfterClass overlap with TestNG.

test_detect_entry_points_junit_test_annotation is green against a node the parser cannot produce. _add_func defaults is_test=False while setting decorators=["Test"], but a real build stores that method as kind=Test, is_test=1. So one of the five "fails on staging, passes with the patch" proofs shows nothing about real behaviour. The Override and BeforeEach tests are fine, both are genuinely is_test=0.

The flows-side cost is real and the body does not size it. Every @Override becomes a flow root even when it has callers. On apache/commons-lang flows go from 897 to 955 and flow_memberships from 5,666 to 6,264, with 58 new roots of which 32 are strict sub-paths of flows that already existed. In the default top-50 list_flows view, 8 real library entry points are evicted, among them ConcurrentUtils.initializeUnchecked, ExceptionUtils.hasCause and ThreadUtils.getAllThreads, replaced by GenericArrayTypeImpl.equals, StrTokenizer.hasNext and similar. On jackson-databind flows go from 1,909 to 2,270. detect-changes on a one-line edit to ToStringStyle.java returns 19 affected flows instead of 16, with appendDetail, append and appendFieldStart each listed twice under the same name and nothing in the output to tell them apart. The JSON payload grows 14.7%.

flows.py:62 still carries re.compile(r"@(Override|OnLifecycleEvent|Composable)", re.IGNORECASE), which can never match because the parser strips the @ before storing. The file now has two Override patterns and one of them is dead.

The fix is Java-annotation-only while the body frames the problem generically. Kotlin override fun, C# public override string, TypeScript override and Python's @override produce identical dead-code output on both sides. The first three store no decorator at all and Python stores lowercase override.

The core claim reproduces well outside your own tests. On google/gson dead-code drops 8 unique symbols and adds none. On commons-lang it goes from 233 to 203, 30 removed and 0 added, and every removal opens to a real @Override. Non-JVM collateral is zero and this repo's own graph is byte-identical on both sides. It merges clean into staging with ruff, mypy and the full suite green, and reverting only flows.py in a merged tree makes exactly the five tests you name fail, so the patch is what moves them.

To get this mergeable: either drop Test|ParameterizedTest|RepeatedTest|TestFactory or loosen the anchor to \b so @Test(expected=...) is covered, and either add the TestNG names or drop TestNG from the comment. Rebuild test_detect_entry_points_junit_test_annotation from a parsed node instead of a hand-built one, or remove it. State in the body that an @Override on an out-of-repo base is no longer reportable at all, and that flows grow roughly 6% to 19% on JVM repos. Delete the dead @(Override|OnLifecycleEvent|Composable) line while you are in the file.

@github-actions

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.15 (LOW) — 13 changed function(s)/class(es), 3 affected flow(s), 3 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.15 low tests/test_refactor.py::TestFindDeadCodeJavaAnnotations._dead tests/test_refactor.py:1157 yes
0.15 low tests/test_refactor.py::TestFindDeadCodeJavaAnnotations.test_override_is_not_dead_code tests/test_refactor.py:1160 (test)
0.15 low tests/test_refactor.py::TestFindDeadCodeJavaAnnotations.test_junit_lifecycle_outside_a_test_file_is_not_dea... tests/test_refactor.py:1165 (test)
0.15 low tests/test_refactor.py::TestFindDeadCodeJavaAnnotations.test_a_genuinely_unreferenced_method_is_still_reported tests/test_refactor.py:1168 (test)
0.10 low tests/test_refactor.py::TestFindDeadCodeJavaAnnotations._seed tests/test_refactor.py:1127 no
0.05 low tests/test_flows.py::TestFlows tests/test_flows.py:19 no
0.05 low tests/test_flows.py::TestFlows.test_detect_entry_points_java_override tests/test_flows.py:175 (test)
0.05 low tests/test_flows.py::TestFlows.test_detect_entry_points_junit_lifecycle tests/test_flows.py:196 (test)
0.05 low tests/test_flows.py::TestFlows.test_detect_entry_points_junit_test_annotation tests/test_flows.py:219 (test)
0.05 low tests/test_flows.py::TestFlows.test_override_pattern_is_anchored tests/test_flows.py:237 (test)

Affected execution flows

  • main — criticality 0.65, 52 node(s) across 6 file(s)
  • run — criticality 0.47, 26 node(s) across 3 file(s)
  • run — criticality 0.38, 8 node(s) across 2 file(s)

Test gaps

  • tests/test_flows.py::TestFlows (tests/test_flows.py:19)
  • tests/test_refactor.py::TestFindDeadCodeJavaAnnotations (tests/test_refactor.py:1104)
  • tests/test_refactor.py::TestFindDeadCodeJavaAnnotations._seed (tests/test_refactor.py:1127)

Token savings: this graph-backed report used ~16,476 fewer tokens (~63%) 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.

This branch has not been deployed

No deployments
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]: dead-code reports @Override methods as dead, and JUnit lifecycle methods outside test-named files

2 participants