feat(kotlin): extract annotations into decorators on functions and classes - #1596
Conversation
Kotlin was the only language extractor never populating `decorators`, despite three consumers reading it. Reads the `modifiers` child and collects direct `annotation` children as raw text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gated to the `classes` category: Interface and Object node tables have no decorators column and Kuzu drops unknown properties silently.
Compose, Hilt and Room annotations declared as local stubs so the fixture needs no Android SDK on the test path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parser-level tests do not prove the property survives _sanitize_props and the property allow-list. This test validates the actual feature claim: that find_dead_code(exclude_decorated_with=...) works for Kotlin by asserting the predicate that filters decorated functions. Before this change, un-annotated Kotlin functions stored NULL and were silently dropped from dead-code results; now they persist and are retained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
exclude_decorated_with matches by substring, which is what makes bare names like 'Preview' work against '@Preview(showBackground = true)'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- README: the Kotlin exclusion example listed `Inject`, which sits on constructors and fields -- neither of which this change records -- while the next sentence said so. Swapped for annotations that actually work. - Fixture: drop the unused `annotation class Inject` stub, which advertised a capability the fixture could not demonstrate. - test_kotlin_parser.py: use the file's `next(f for f in ...)` lookup idiom instead of a dict comprehension, so a future fixture entry with a duplicate name fails loudly rather than silently checking the wrong row. No assertion values changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
find_dead_code matches (func:Function) only (code_finder.py:826), so a class-level annotation like @hiltviewmodel can never appear in the list it filters on. The four remaining examples are all function-level. 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. |
|
Correcting a claim I made in the original description, rather than editing it away silently. I wrote that emitting So the unconditional Worth noting the trap is real for a different Cypher shape, which is what misled me: |
|
Qualifying one thing I claimed above, having since tested it end to end. I wrote that Kotlin now also produces On this PR's own fixture, Filed as a separate pre-existing bug in #1595 — it affects every language with class-level decorators, not just Kotlin. Nothing in this PR changes that behaviour either way; I just did not want the side-effect claim to read as broader than it is. |
The comment said find_dead_code's NOT ANY(d IN func.decorators ...) evaluates NULL against an unset property and silently drops un-annotated functions. Tested against Kuzu, that is not true: ANY over a NULL list yields false, so NOT ANY(...) is true and the row is retained. The unconditional [] is a consistency/contract choice, matching the ten extractors that already populate the field -- not a correctness fix. The assertion and the code are unchanged; only the stated reason was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed one more correction, closing out the retraction above. The same false claim was sitting in a test comment, not just in the PR description — The comment now says what is actually true: the key is always present as Worth flagging because a wrong comment outlives a wrong PR description: the description gets read once during review, the comment gets read by whoever next touches that test. |
Shashankss1205
left a comment
There was a problem hiding this comment.
Verified locally: trial-merged onto main, full unit suite (1113 passed) and integration suite (44 passed) green. The _get_node_annotations guard against annotation class producing a bare "annotation" decorator is a nice catch, and gating decorators on the classes category (rather than the shared dict) matches the Kuzu schema. Thanks!
Follows #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 #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 #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> Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
…codebases (#1622) * feat(kotlin): extract visibility, modifiers and enum kind Follows #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 #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 #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> * feat(dead-code): make find_dead_code usable on Android codebases 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> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
* feat(kotlin): extract visibility, modifiers and enum kind Follows #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 #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 #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> * feat(dead-code): make find_dead_code usable on Android codebases 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> * feat(hilt): resolve @BINDS and @provides into BINDS edges In a Hilt codebase, `@Binds abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository` is the only link between an interface and its implementation. Without it every call resolves to the interface, so `analyze callers` on the impl returns nothing, the impl looks like dead code, and impact analysis dead-ends at every DI boundary -- which in a clean-architecture Android app is every layer boundary. Adds a BINDS relationship and resolves both Hilt mechanisms into it: - @BINDS: source is the declared return type, target the single parameter type. Skipped if the arity is not exactly one. - @provides: source is the return type, target the type constructed in the body, read from the function's recorded calls. BINDS is declared as a REL TABLE GROUP rather than reusing INJECTS, which is a single-binding REL TABLE (FROM Class TO Class). Hilt binds an Interface to a Class, so reusing it would mean converting an existing table to a group -- a migration hazard on databases already built. Honest limits, stated in full because this is text-level resolution with no type checker: - Ambiguity is skipped rather than guessed. A @provides body with more than one type-resolvable call emits no row: neither first-call nor last-call is correct in general, since sequential construction wants the last and nested construction puts the wanted call first. A missing edge is visibly missing; a wrong one silently misdirects the very queries this exists to answer. - Qualifiers (@nAmed) are not distinguished, generics resolve on erased names, multibindings are not modelled, and @provides bodies that delegate to a factory resolve to the factory call rather than the produced type. - Where two label pairs both match, priority order decides, because a row carries names and paths but no labels. Measured on a real 2,861-file Android codebase: 77 of 77 @BINDS declarations resolved. @provides yields less (46 of 210) and legitimately so -- those bodies are dominated by Room DAO accessors and static factories, which have no first-party implementation node to point at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Addresses the first slice of #1595.
kotlin.pyextracted no annotations, so@Composable,@HiltViewModel,@Preview,@Entity,@Daoand@Testwere invisible to the graph. This populatesdecoratorson KotlinFunctionandClassnodes with raw annotation text, which immediately feeds the three consumers that already read the field —find_dead_code'sexclude_decorated_with, inheritance resolution, and the SCIP pipeline.No schema change.
decorators STRING[]is already declared forFunctionandClass(database_embedded_kuzu.py:177-178) and already in the property allow-list (:823-824). The write path is generic, so nothing outsidekotlin.pychanges except tests and docs.What it does
One helper,
_get_node_annotations(kotlin.py:211), reads themodifierschild of a declaration and collects the text of its directannotationchildren. Two call sites consume it:_parse_functionsand_parse_classes.Values are raw annotation text including the
@and all arguments —"@Preview(showBackground = true)"— which matchespython.py:347andtypescript.py:286. This works with bare-name exclusion patterns becausefind_dead_codefilters withd CONTAINS '<pattern>'(code_finder.py:816), not equality, and keeping the arguments preserves information later slices need (Room@QuerySQL, Hilt@InstallIncomponents).Two details worth a reviewer's attention
1. "Direct children" is load-bearing. In
annotation class Fancy(val id: Int), the keywordannotationalso produces a node of typeannotation— but nested one level deeper, underclass_modifier:A recursive walk would emit the string
"annotation"as a decorator on everyannotation class.test_annotation_class_keyword_is_not_a_decoratorcovers this.2. The key is emitted unconditionally as
[], set inside thefunc_dataliteral so it can never be absent. This matches what the ten extractors that already populatedecoratorsdo (python.py:206,250,go.py:311,441,506,ruby.py:381), and it means consumers can rely on the key existing rather than testing for it.Correction: an earlier revision of this description claimed this was a correctness fix — that leaving
decoratorsunset would makeNOT ANY(d IN func.decorators WHERE …)evaluate NULL and silently drop un-annotated functions fromfind_dead_coderesults. I have since tested that against Kuzu directly and it is not true:ANYover a NULL list yields false, soNOT ANY(...)is true and the row is correctly retained. The unconditional[]is consistency and API cleanliness, not a bug fix. Apologies for the incorrect claim.Scope:
FunctionandClassonly_parse_classesemits three categories through one shared append, but onlyClasshas adecoratorscolumn:decoratorscolumnFunction:177Class:178Interface:181, allow-list:827Object:193, allow-list:839Unknown properties are dropped silently on Kuzu (
:865), so setting the field on interfaces or objects would work on Neo4j and vanish on the default backend. The assignment is therefore gated oncategory == "classes", andtest_interface_does_not_carry_decoratorsfails if that gate is ever removed.Honest costs of the gate:
@Dao interface UserDaoloses@Dao, though its@Query/@Insertmethods arefunction_declarationnodes and are captured — so the dead-code payoff survives.@Module object AppModuleloses@Moduleoutright. Both want theInterface/Objectcolumns, which is slice 1b in #1595.Also out of scope, each for a structural reason: constructor annotations (
@Inject constructoris aprimary_constructor, not afunction_declaration), parameter annotations (parameter_modifiers, insidefunction_value_parameters), and property annotations (@field:,@get:— these targetVariable, which has no such column).Deliberate divergences
" ".join(text.split()), so a wrapped multi-line annotation stores on one line instead of embedding newlines in aSTRING[].python.pystores raw text, so this differs; happy to match Python instead if you prefer. Note it also collapses whitespace inside string literals, so a Room@Query("""multi-line SQL""")stores single-line — harmless forCONTAINS, but worth knowing before the Room slice._get_node_annotationsreturnsList[str]wherejava.py:307's same-named method returnsList[Tuple[str, str]]. Java splits name from arguments because it routes them to labels andhttp_method; Kotlin returns raw text because that is whatdecoratorsstores. Different classes, so no conflict, but it will show up in a side-by-side diff.Tests
11 new tests in
tests/unit/parsers/test_kotlin_parser.pycovering: empty list present (not missing, notNone), single and stacked annotations with source order preserved, arguments retained verbatim, theannotation classgrammar trap,private/inline/suspendnot leaking in, multi-line collapsing, and interfaces not carrying the property.1 new test in
tests/unit/core/test_database_kuzu_kotlin_metadata.py— the only one that exercises the write-and-query path rather than the parser's return dict. It parses Kotlin, writes viaGraphWriter.add_file_to_graph, and then runsfind_dead_code's actual predicate to assert the annotated function is excluded and the un-annotated one retained. That is the claim this PR makes, so it seemed worth testing directly rather than asserting on the stored value.New fixture
AndroidAnnotations.ktdeclares its@Composable/@Preview/@HiltViewModel/@Entity/@Dao/@Querystubs locally, so no Android SDK, Hilt, Room or Compose compiler is needed on the test path../tests/run_tests.sh fast→ 12 failed, 1145 passed, 19 skipped. All 12 failures reproduce onc274436(this PR's base) — verified by checking the base out and re-running them:test_kotlin_parser.py::TestKotlinFunctionCallResolution— a macOS/var→/private/vartempdir symlink artifact (tempfilereturns/var/…, the parser resolves/private/var/…)tests/unit/api/test_mcp_sse_disconnect.py, 6 ×tests/unit/tools/test_scip_pipeline_*.pyUnclaimed side effect
Because
indexing/resolution/inheritance.py:370readsdecorators, Kotlin now also producesDECORATED_BYedges. Runningbuild_decorated_by_linksover the new fixture emits 7 correctly resolved links (Greeting → Composable,Label → Composable,findAll → Query,GreetingPreview → Composable, …). Not something this PR set out to do, but it falls out for free.Not attempted
No semantic resolution. CGC is tree-sitter-based with no type checker, so Kotlin extension functions, generics and overload resolution stay approximate — this PR does not change that, and does not make the call graph sound. No recomposition or state-flow analysis for Compose.