Skip to content

feat(java): wire annotation usage as references edges (#89) - #207

Open
Frankie-Xu wants to merge 2 commits into
trailhq:mainfrom
Frankie-Xu:feat/89-java-annotation-edges
Open

feat(java): wire annotation usage as references edges (#89)#207
Frankie-Xu wants to merge 2 commits into
trailhq:mainfrom
Frankie-Xu:feat/89-java-annotation-edges

Conversation

@Frankie-Xu

Copy link
Copy Markdown
Contributor

Summary

  • Extract Java marker_annotation / annotation on already-noded definitions (class / interface / enum / record / method / constructor) and emit references edges from the annotated symbol to the annotation type.
  • Resolve in-repo @interface hits via resolveName (same-file, then globally unique interface kind). Unresolved targets keep the bare name — heritage's contract, not PHP feat(php): wire attribute usage as references edges (#144 part 1) #155's drop.
  • Annotation types that are classes of the same name are not a match, so @Entity cannot collapse onto an in-repo class Entity (fix: Java constructor calls lose their target when the type is generic #103).
  • Arguments are ignored: @MyAnno(value = "x") emits the same edge as marker @MyAnno. Field annotations are not edges (fields are not nodes).

Mirrors #155 (PHP attributes → references). Does not touch the #176 anonymous-class path.

Scope

Slice C as aligned on #89 (Frankie-Xu 2026-08-21, endorsed by @dbianco: "Your C is a better slice than my A… The #155 precedent settles the design questions… Nothing left to adjudicate."):

Do Don't
class / method (and other already-noded) annotations → references parse annotation arguments
source = annotated symbol, target = annotation type mint nodes for external library types
keep the bare name when unresolved (heritage) field nodes / field-annotation edges
relation already in WALK_RELATIONS framework-special-case JPA (option B)

Yield

Measured by @dbianco on current main (issue comment):

annotation usages distinct types in-repo @interface
spring-petclinic 268 63 0
gson 3,184 34 8

Usages that point at a type the repo actually declares (gson):

@JsonAdapter      93
@SerializedName   45
@Expose           19
@Since            11
@Until             9
@Intercept         3
@Foo               1
@IgnoreJRERequirement 1
                 ---
                 182  of 3,184 usages  (~6%)

Petclinic is 0 of 268 — every annotation there is Spring / JPA / Jackson, so every edge targets an unresolved name. That is still a navigable Owner → Entity string, same as heritage.

Honest headline: a name-level annotation graph, mostly to external types; gson is where the resolved slice is visible. Those 182 counts include field usages; this PR only wires class/method-level ones (@JsonAdapter on a type, a method @SerializedName, …). Field annotations stay in signature / graft grep.

Test plan

  • node --import tsx --test test/graph-java.test.ts — 35/35, including:
    • in-repo @interface MyAnno on class + method → references to the annotation node
    • @MyAnno(value = "x") same edges as marker @MyAnno
    • external @Override / @Entity keep the bare name
    • @Entity does not false-match an in-repo class Entity
    • field @Transient is not a class-level edge
  • npm run build clean
  • npm test — 913 pass; 5 failures were load flakes (PageRank 3s budget, MCP stdio timeout, viz --tabs against a worktree with no graft/). Isolated re-run of those files + graph-php / graph-references / graph-invariants is green except the pre-existing viz --tabs cwd-graph assumption.
  • Gson-style fixture (in-repo @JsonAdapter / @SerializedName / @Expose, class + method + field usages):
Person          -> …/JsonAdapter.java#JsonAdapter
Person.id       -> …/SerializedName.java#SerializedName

Field @SerializedName / @Expose produced 0 extra class-level edges.

Closes #89

Made with Cursor

Co-authored-by: Cursor <cursoragent@cursor.com>
@dbianco

dbianco commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Thanks for taking this up — the shape is right, the bare-name fallback matches heritage's contract, and the field-annotation exclusion is sound even for imported in-repo annotations (the annotation-name identifier is suppressed from the imported-symbol path via isDeclarationName, so fields genuinely produce nothing).

One finding worth addressing before merge: @Service can collapse onto an in-repo plain interface of the same name — the #103 hazard in interface form.

resolveName(..., ["interface"], ...) can't tell @interface MyAnno from interface MyAnno — both mint kind interface. So an external annotation whose bare name collides with a non-annotation interface in the repo resolves to it at inferred:

public interface Service {}   // Service.java — plain interface, not an annotation
@Service public class Foo {}  // @Service is the external (Spring) annotation

→ emits Foo → Service.java#Service as references/inferred. That edge is false. The current guard only covers the class case (@Entity vs class Entity) and the test only pins that case; the interface case is the same family, at the same inferred confidence the codebase says to drop rather than guess.

The fix is cheap because the discriminator is reliable: restrict resolution to annotation-type nodes. E.g. filter the candidate by its signature containing the literal @interface — a meta-annotated annotation type's header is @Documented @Retention(...) @Target(...) public @interface JsonAdapter, so startsWith("@interface") fails there but includes("@interface") works, and a plain interface header can never contain @interface. Alternatively add an annotationType marker to NodeV1. A test like @Service + in-repo interface Service → stays bare would pin it.

Minor, non-blocking:

  • Every @Override/@Deprecated emits a bare references edge to that string. Harmless for ranking (target isn't a node) but it's edge-count noise in JDK-heavy repos — worth a deliberate keep-or-drop decision (e.g. an allowlist of the well-known JDK meta-annotations).
  • The invariants/graph-quality relaxation of references to external targets is language-agnostic. No other producer leaves an unresolved references target today, so the net isn't weakened in practice, but a future bug elsewhere would be masked. Fine as-is; a one-line note would help.

@Frankie-Xu

Copy link
Copy Markdown
Contributor Author

Thanks for the catch — @Service collapsing onto a plain in-repo interface Service was the same #103 hole, in interface form.

Blocking: @Service vs in-repo interface Service

Fixed in resolve.ts: after resolveName(..., ["interface"], ...), we only accept a candidate whose signature includes("@interface"). includes rather than startsWith, because a meta-annotated header is @Documented @Retention(...) public @interface JsonAdapter; a plain interface header never contains that literal. Unresolved names still keep the bare-name fallback (heritage), rather than dropping the way PHP attributes do. No annotationType flag on NodeV1 — the signature check is the smaller diff.

Pinned by Java extraction: external @Service does not collapse onto a plain interface Service (#89):

public interface Service {}   // in-repo, not an annotation
@Service public class Foo {}  // external Spring annotation

Foo references "Service", not Service.java#Service. The existing @Entity vs class Entity guard is unchanged and still green, as is in-repo @interface MyAnno resolution.

Non-blocking: @Override / @Deprecated bare references edges — keep

Keeping them. The heritage contract this PR already follows (and you flagged as matching) is keep-the-bare-name rather than drop; @Override is the same unresolved-external case as @Entity. Dropping every unresolved annotation would also drop @Entity/@Service. An allowlist of JDK meta-annotations is extra policy that's easy to get incomplete, and these edges don't hurt ranking (the target isn't a node). The existing test Java extraction: external annotation keeps its bare name (#89) already pins @Override as a bare references edge.

Non-blocking: invariants relaxation is language-agnostic

Added a note on TARGET_MAY_BE_EXTERNAL that the set is language-agnostic and that a future bug in another producer would be masked here. No code change.

github-actions Bot added a commit that referenced this pull request Aug 24, 2026
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🌱 graft blast radius

2 areas changed → 2 areas can be affected. 3 dependent symbols, depth 2.
Tests: 1 area updated its tests.

flowchart TB
  A0(("Graph Building<br/>2 symbols"))
  A1(("Engine<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Graph Building 2 src/graph/build.ts:L147-L403 buildGraph — calls, depth 1 Graph Resolution
Engine 1 src/engine.ts:L86-L95 graph — calls, depth 2 Graph Resolution
All 3 dependent symbols, grouped by area

Graph Building — 2 symbols in 2 files

  • src/graph/build.ts:L147-L403 — buildGraph (calls, depth 1)
    284: const edges = resolveEdges(nodes, rawEdges, { goModules: readGoModules(root, repoFiles) });
  • src/graph/refresh.ts:L150-L223 — ensureFreshGraph (calls, depth 2)

Engine — 1 symbol in 1 file

  • src/engine.ts:L86-L95 — graph (calls, depth 2)
Test signal per changed area — 1 ✓ · 1 –

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • Graph Resolution — 1 of 4 reached · 1 test file changed here: test/graph-invariants.test.ts
    • not reached: walk, javaAnnotationReferenceEdges, javaAnnotationTypeName
  • Graph Quality — no function, method or class changed here
28 test suites also reference this code

29 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/container-extract.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-languages.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • test/graph-r-phase4.test.ts
  • test/graph-r-phase5.test.ts
  • test/graph-r.test.ts
  • test/graph-references.test.ts
  • test/graph-refresh.test.ts
  • test/graph-resolve-typed.test.ts
  • test/graph-scopes.test.ts
  • …8 more

graft blast · origin/main...HEAD · depth 2 · 6 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Aug 25, 2026
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.

Java annotations: the text is already retrievable — the gap is a traversable edge (scope question before a PR)

2 participants