feat(dead-code): exclude framework entry points and overrides on JVM codebases - #1622
Conversation
Follows CodeGraphContext#1596, which added `decorators`. The same `modifiers` node also carries visibility (public/private/internal/protected) and the class kind (data/sealed/value/annotation), plus abstract/open/override and suspend/inline -- none of which reached the graph. Adds two properties on Function and Class, and completes the two columns CodeGraphContext#1596 deferred on Interface and Object: Function/Class visibility STRING, modifiers STRING[] Interface/Object visibility STRING, modifiers STRING[], decorators STRING[] Each in all three required places -- node-table declaration, SCHEMA_MAP allow-list, and simple_migrations so pre-existing databases get them via ALTER TABLE. Without the last, CREATE NODE TABLE throws "already exists" on an existing database and is swallowed, so the columns never arrive. Two grammar details worth knowing: `enum` is not a modifier. `enum class C` produces no `modifiers` node at all -- the keyword is a direct child of class_declaration, exactly like `interface`. It is derived with the pattern _parse_classes already uses for interface detection, so `modifiers` is the single place to ask what kind of class this is. visibility defaults to the string "public" rather than null, matching Kotlin's own default, so consumers need no null handling. Since CodeGraphContext#1596 gated `decorators` behind `category == "classes"` only because Interface/Object had no column, that gate is removed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Almost nothing in an Android app is called by another Kotlin function: the framework invokes lifecycle methods, the manifest declares components, Hilt supplies dependencies, the Compose runtime calls composables, annotation processors generate Room implementations, test runners call @test, and `override fun` is reached through its supertype. So the tool reports thousands of false positives. Three changes: - `override` functions are treated as live, using the `modifiers` property added in the parent commit. Guarded with IS NOT NULL, because 'override' IN NULL is NULL rather than false in Cypher -- unguarded it would silently drop every function lacking the property from results. - Android/JVM lifecycle names (onCreate, onBind, doWork, ...) are treated as entry points, scoped to func.lang IN ['kotlin','java'] so other languages keep the original global list untouched. - ANDROID_DECORATOR_PRESET: a documented tuple of annotations meaning "something other than project code calls this" -- Compose, test runners, Hilt, Room, and @TypeConverter. Measured on a real 2,861-file Android codebase: 7,141 findings without the preset, 1,055 with it -- an 85% reduction, dominated by @test methods and Hilt providers. Two honest limits, both documented: The override exemption is inert for Java, because only kotlin.py emits `modifiers`; the lifecycle-name half does work for Java. And four preset entries (Dao, HiltViewModel, AndroidEntryPoint, Serializable) annotate classes rather than functions, so they cannot match a query that does MATCH (func:Function) -- kept, with a comment, because the data exists and it is the query's scope that makes them inert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@rrodriguesNutrium is attempting to deploy a commit to the shashankss1205's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Android/Kotlin series — merge order Each PR targets Happy to split, reorder, or squash any of these differently if it suits review better. |
Conflict in tests/unit/core/test_database_kuzu_kotlin_metadata.py: main is a strict superset (this branch's tests all landed via CodeGraphContext#1620, plus main has CodeGraphContext#1617's three probe tests), so main's version is taken whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shashankss1205
left a comment
There was a problem hiding this comment.
Approving. This is the one I most wanted from your staging plan, and it delivers — verified end to end on a small Android-shaped fixture:
no filter -> ['GreetingPreview', 'alsoDead', 'testSomething', 'trulyDead']
ANDROID preset -> ['alsoDead', 'trulyDead']
alsoDead and trulyDead are the only genuinely dead functions in that fixture, so this is a clean result. Two details I checked specifically:
onCreate/onResumeare excluded without needing the preset, via the lifecycle-name list and theoverridemodifier check — so the default behaviour improves for anyone who never reads the docs. That's the right default.Greetingcorrectly survives as not dead becauseGreetingPreviewcalls it, rather than being swept up by the@Composableexclusion. The preset excludes the annotated function itself, not its callees.
Scoping the Android names to func.lang IN ['kotlin', 'java'] is the right call — it keeps every other language on the original global list, so this can't regress Python or Go dead-code results.
The override exclusion depending on func.modifiers from #1620 is why the stack ordering matters; good that you sequenced it that way rather than duplicating the extraction.
I resolved the conflict with main and pushed. It was straightforward: main is now a strict superset of this branch's copy of test_database_kuzu_kotlin_metadata.py (your tests landed via #1620, and main additionally has #1617's probe tests), so I took main's whole.
test_dead_code_android.py 5 passed
tests/unit/ 1224 passed
tests/integration/ 45 passed
Between this, #1596 and #1609, cgc analyze dead-code is finally usable on Android — which was the original point of #1595.
find_dead_codeis close to unusable on an Android codebase: almost everything it reports is framework-invoked and therefore alive. This PR teaches the query the two ways JVM code gets called without any project function calling it.The problem
Nothing in an Android app calls
onCreate. The framework does. Same foronBindViewHolder,doWork,onReceive, and everyoverrideof a superclass or interface member — the call site is a supertype reference, which the graph does not resolve to the override.On a real multi-module Android repo the report was dominated by these, which is the failure mode where a dead-code tool stops being read at all.
The change
Two
AND NOTclauses, plus one exported constant.Framework entry points, scoped to
lang IN ['kotlin', 'java']so every other language keeps the existing global behaviour:29 lifecycle names, lowercased on both sides. Scoping by language matters —
onCreateis not special in Python, and a global list would silently change results for existing users.Overrides, using the
modifiersarray this stack's first PR adds:The
IS NOT NULLguard is required, not defensive noise. In Cypher'override' IN nullevaluates to null, and a null predicate drops the row — so without the guard every function whosemodifiersis unset (i.e. every function in every other language) would vanish from the report. I verified this behaviour directly rather than assuming it.ANDROID_DECORATOR_PRESET— 22 annotation names to pass asexclude_decorated_withwhen analysing an Android project:@Composable,@Preview,@Test,@Inject,@Provides,@Dao,@TypeConverterand so on. Each means "something other than project code calls this". It's exported rather than hardcoded into the query because the choice is the caller's — a project that wants its unused@Testfunctions reported can simply not pass it. Matching is by substring, so the bare names match annotations carrying arguments.Verification
pytest tests/unit -q→12 failed, 1191 passed, 19 skipped.Same 12 failures on unmodified
origin/main(12 failed, 1176 passed, 19 skipped) — pre-existing and unrelated.The new tests assert both directions: a lifecycle method and an
overrideare excluded, and a genuinely uncalled ordinary function is still reported. A one-directional test here would pass trivially against a query that returns nothing at all.Scope
code_finder.py+39, one new test file, two doc lines. No schema change.Second of five. Requires the
modifierscolumn from #1620 — the override clause is inert without it. Review only the last commit; the earlier one is that PR, carried along because GitHub can't host a stacked base across a fork boundary.