Skip to content

feat(kotlin): extract annotations into decorators on functions and classes - #1596

Merged
Shashankss1205 merged 8 commits into
CodeGraphContext:mainfrom
rrodriguesNutrium:feat/kotlin-decorators-pr
Aug 13, 2026
Merged

feat(kotlin): extract annotations into decorators on functions and classes#1596
Shashankss1205 merged 8 commits into
CodeGraphContext:mainfrom
rrodriguesNutrium:feat/kotlin-decorators-pr

Conversation

@rrodriguesNutrium

@rrodriguesNutrium rrodriguesNutrium commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Addresses the first slice of #1595.

kotlin.py extracted no annotations, so @Composable, @HiltViewModel, @Preview, @Entity, @Dao and @Test were invisible to the graph. This populates decorators on Kotlin Function and Class nodes with raw annotation text, which immediately feeds the three consumers that already read the field — find_dead_code's exclude_decorated_with, inheritance resolution, and the SCIP pipeline.

No schema change. decorators STRING[] is already declared for Function and Class (database_embedded_kuzu.py:177-178) and already in the property allow-list (:823-824). The write path is generic, so nothing outside kotlin.py changes except tests and docs.

What it does

One helper, _get_node_annotations (kotlin.py:211), reads the modifiers child of a declaration and collects the text of its direct annotation children. Two call sites consume it: _parse_functions and _parse_classes.

Values are raw annotation text including the @ and all arguments — "@Preview(showBackground = true)" — which matches python.py:347 and typescript.py:286. This works with bare-name exclusion patterns because find_dead_code filters with d CONTAINS '<pattern>' (code_finder.py:816), not equality, and keeping the arguments preserves information later slices need (Room @Query SQL, Hilt @InstallIn components).

Two details worth a reviewer's attention

1. "Direct children" is load-bearing. In annotation class Fancy(val id: Int), the keyword annotation also produces a node of type annotation — but nested one level deeper, under class_modifier:

class_declaration  |annotation class Fancy(val id: Int)|
  modifiers
    class_modifier            <- keyword path
      annotation  |annotation|

class_declaration  |@Fancy(1) class Foo { … }|
  modifiers
    annotation  |@Fancy(1)|   <- real annotation, direct child

A recursive walk would emit the string "annotation" as a decorator on every annotation class. test_annotation_class_keyword_is_not_a_decorator covers this.

2. The key is emitted unconditionally as [], set inside the func_data literal so it can never be absent. This matches what the ten extractors that already populate decorators do (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 decorators unset would make NOT ANY(d IN func.decorators WHERE …) evaluate NULL and silently drop un-annotated functions from find_dead_code results. I have since tested that against Kuzu directly and it is not true: ANY over a NULL list yields false, so NOT 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: Function and Class only

_parse_classes emits three categories through one shared append, but only Class has a decorators column:

Label decorators column
Function yes — :177
Class yes — :178
Interface no:181, allow-list :827
Object no:193, allow-list :839

Unknown 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 on category == "classes", and test_interface_does_not_carry_decorators fails if that gate is ever removed.

Honest costs of the gate: @Dao interface UserDao loses @Dao, though its @Query/@Insert methods are function_declaration nodes and are captured — so the dead-code payoff survives. @Module object AppModule loses @Module outright. Both want the Interface/Object columns, which is slice 1b in #1595.

Also out of scope, each for a structural reason: constructor annotations (@Inject constructor is a primary_constructor, not a function_declaration), parameter annotations (parameter_modifiers, inside function_value_parameters), and property annotations (@field:, @get: — these target Variable, which has no such column).

Deliberate divergences

  • Whitespace is collapsed with " ".join(text.split()), so a wrapped multi-line annotation stores on one line instead of embedding newlines in a STRING[]. python.py stores 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 for CONTAINS, but worth knowing before the Room slice.
  • _get_node_annotations returns List[str] where java.py:307's same-named method returns List[Tuple[str, str]]. Java splits name from arguments because it routes them to labels and http_method; Kotlin returns raw text because that is what decorators stores. 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.py covering: empty list present (not missing, not None), single and stacked annotations with source order preserved, arguments retained verbatim, the annotation class grammar trap, private/inline/suspend not 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 via GraphWriter.add_file_to_graph, and then runs find_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.kt declares its @Composable/@Preview/@HiltViewModel/@Entity/@Dao/@Query stubs locally, so no Android SDK, Hilt, Room or Compose compiler is needed on the test path.

./tests/run_tests.sh fast12 failed, 1145 passed, 19 skipped. All 12 failures reproduce on c274436 (this PR's base) — verified by checking the base out and re-running them:

  • 3 × test_kotlin_parser.py::TestKotlinFunctionCallResolution — a macOS /var/private/var tempdir symlink artifact (tempfile returns /var/…, the parser resolves /private/var/…)
  • 3 × tests/unit/api/test_mcp_sse_disconnect.py, 6 × tests/unit/tools/test_scip_pipeline_*.py

Unclaimed side effect

Because indexing/resolution/inheritance.py:370 reads decorators, Kotlin now also produces DECORATED_BY edges. Running build_decorated_by_links over 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.

rrodriguesNutrium and others added 7 commits August 12, 2026 14:47
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>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

@rrodriguesNutrium

Copy link
Copy Markdown
Contributor Author

Correcting a claim I made in the original description, rather than editing it away silently.

I wrote that emitting decorators unconditionally as [] was a correctness requirement — that leaving it unset would make NOT ANY(d IN func.decorators WHERE …) evaluate NULL and silently drop un-annotated functions from find_dead_code results. I tested that against Kuzu directly and it is wrong: ANY over a NULL list yields false, so NOT ANY(...) is true and the row is correctly retained.

decorators = NULL        -> NOT ANY(d IN f.decorators WHERE d CONTAINS 'Preview')  -> row KEPT

So the unconditional [] is consistency with the ten extractors that already populate the field, and a cleaner contract for consumers — not a bug fix. The code is unchanged; only my justification for it was wrong.

Worth noting the trap is real for a different Cypher shape, which is what misled me: 'x' IN <NULL> does evaluate NULL, and a predicate built that way does silently drop rows. Just not this one.

@rrodriguesNutrium

Copy link
Copy Markdown
Contributor Author

Qualifying one thing I claimed above, having since tested it end to end.

I wrote that Kotlin now also produces DECORATED_BY edges as a free side effect. That is true for function-level decorations, and I verified the builder output. It is not true for class-level ones — not because of anything in this PR, but because write_decorated_by_links hardcodes :Function on both endpoints and so can never write the FROM Class TO Function pair that DECORATED_BY declares.

On this PR's own fixture, build_decorated_by_links emits 7 links; 5 land, and UserViewModel -> HiltViewModel and UserEntity -> Entity are silently dropped at write time.

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

Copy link
Copy Markdown
Contributor Author

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 — tests/unit/parsers/test_kotlin_parser.py, on test_plain_function_emits_empty_decorators_list. It told the reader that NOT ANY(d IN func.decorators …) evaluates NULL against an unset property and silently drops un-annotated functions. It doesn't, and I'd corrected the description without noticing the comment repeated it.

The comment now says what is actually true: the key is always present as [] so consumers can rely on it, matching the ten extractors that already populate the field. No assertion changed, no code changed — 3 failed, 85 passed before and after, the 3 being the pre-existing macOS tempdir-symlink failures in TestKotlinFunctionCallResolution noted in the description.

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 Shashankss1205 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@Shashankss1205
Shashankss1205 merged commit 717fcf3 into CodeGraphContext:main Aug 13, 2026
1 check failed
@github-project-automation github-project-automation Bot moved this from Backlog tasks to Done in CGC Progress Board Aug 13, 2026
Shashankss1205 added a commit that referenced this pull request Aug 13, 2026
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>
Shashankss1205 added a commit that referenced this pull request Aug 13, 2026
…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>
Shashankss1205 added a commit that referenced this pull request Aug 13, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants