Skip to content

Java extractor

codegraph-java 0.1.0 — the Spoon-based Java extractor. A separate Maven project in extractors/java/, running Spoon in noClasspath mode, so the sources need not compile and no dependency jars are needed. It emits model.jsonl and nothing else: all trait, profile and validation logic lives in @codegraph/core.

The released form is a GraalVM native image that carries the Java API distilled from ct.sym, so the machine needs no JDK:

brew install defsquare/tap/codegraph-java     # macOS, Linux; or the release's codegraph-java-<platform>
codegraph-java --src <dir> --out model.jsonl

From a clone, the jar is the same extractor and takes the same flags; building and running it needs a JDK 17+ on PATH (non-interactive shells do not source sdkman):

export JAVA_HOME=$HOME/.sdkman/candidates/java/25.0.4-tem
export PATH=$JAVA_HOME/bin:$PATH
cd extractors/java && ./mvnw -B package     # `mvn` is not installed; use the wrapper
java -jar target/codegraph-java.jar --src <dir> --out model.jsonl

Synopsis

codegraph-java [--src <dir>…] [--out <file>]

Options

OptionMeaningDefault
--src <dir>source root to analyze; repeatablethe current directory
--out <file>where to write the model<current-dir>-codegraph.jsonl
--progress <m>auto (a bar on a terminal, silence when piped), plain (one line per phase, no control characters), or noneauto
--no-progresssame as --progress none—
--helpprint the help and exit—

Repository provenance

Copied verbatim into the header; the extractor runs no git — whoever invokes it supplies the facts, as snapshots does.

OptionMeaning
--repo-remote <url>normalized https clone URL, no .git suffix
--repo-commit <sha>the sha this tree is at — a permalink, not a branch
--repo-root <path>the analyzed root RELATIVE to the repository root (default: empty — they are the same directory)
--repo-provider <p>github | gitlab, only when the hostname does not say

Streams

stdout carries one thing: the finished run’s source: roots and model: file, absolute. Progress and the resolution summary — how many type references Spoon resolved in noClasspath mode, how many entities and stubs were emitted, and how many edges — go to stderr, and progress never reaches a stream that is not a terminal unless asked for.

The Java profile

The mapping the extractor must respect, rendered from codegraph profiles --lang java. required(kind) ⊆ traits ⊆ required(kind) ∪ optional(kind).

KindRequired traitsOptional traits
annotationTNamed, TType, TWithChildren, TChildOf, TSourceAnchorTComment, TMetrics
attributeTNamed, TStructural, TTypedEntity, TChildOf, TSourceAnchorTComment, TWithValue
classTNamed, TType, TWithInheritances, TWithImplements, TWithChildren, TChildOf, TSourceAnchorTComment, TMetrics
constructorTInvocable, TWithChildren, TWithParameters, TWithLocalVariables, TWithInvocations, TWithAccesses, TChildOf, TSourceAnchorTComment, TMetrics
enumTNamed, TType, TWithImplements, TWithChildren, TChildOf, TSourceAnchorTComment, TMetrics
interfaceTNamed, TType, TWithInheritances, TWithChildren, TChildOf, TSourceAnchorTComment, TMetrics
lambdaTInvocable, TWithChildren, TWithParameters, TWithLocalVariables, TWithInvocations, TWithAccesses, TChildOf, TSourceAnchorTTypedEntity, TMetrics
localVariableTNamed, TStructural, TTypedEntity, TChildOfTSourceAnchor
methodTNamed, TInvocable, TWithChildren, TWithParameters, TWithLocalVariables, TWithInvocations, TWithAccesses, TTypedEntity, TChildOf, TSourceAnchorTComment, TMetrics, TWithValue
packageTNamed, TModule, TWithChildrenTComment, TChildOf
parameterTNamed, TStructural, TTypedEntity, TChildOfTSourceAnchor
recordTNamed, TType, TWithImplements, TWithChildren, TChildOf, TSourceAnchorTComment, TMetrics

Edge kinds emitted: import · inheritance · interfaceImplementation · invocation · access · reference · annotationUse · throws

Java constructKind
packagepackage
classclass
interfaceinterface
enumenum
recordrecord
annotation typeannotation
methodmethod
constructorconstructor — no TNamed, no TTypedEntity
lambda / anonymous class member ownerlambda — TInvocable without TNamed
field, enum constantattribute
parameterparameter
local variablelocalVariable

Ids for invocables carry erased fully-qualified parameter types: …/OrderService.archive(java.util.List) and …/OrderService.archive(com.acme.order.legacy.List) are different entities. Nameless entities are disambiguated by file:line:column.

Stub discipline

Spoon in noClasspath mode invents plausible fully-qualified names. The rule:

  1. Build a whitelist of corpus-declared ids in a first pass.
  2. Anything referenced but not in the whitelist becomes a stub — {kind: "class", traits: ["TNamed","TType"], isStub: true} for a type, {kind: "package", traits: ["TNamed","TModule","TWithChildren"], definedIn: [], isStub: true} for a package.
  3. Never filter by package prefix. Invented FQNs look exactly like real ones, and a prefix filter would launder them into facts.

Edges to stubs are kept; the internal-only view is the analyzer-side filter. isStub is contributed by TModule as well as TType, because the import graph is module-level: import java.util.List yields an edge to java:java.util, a package no corpus file declares.

A dangling member id is refused and reported, not stubbed: a method stub would need a degraded-member concept core does not have.

Primitives, void and <nulltype> are not entities. declaredType is omitted for them rather than letting java:<unnamed>/int be fabricated into a stub class named int. TTypedEntity’s value is optional even when the trait is declared, so omitting is legal and lossless.

See Stubs and Extracting without compiling.

Measures emitted

Carried in the TMetrics map. See Measures and literals.

KeyOnDefinition
slocevery type and invocablelines of its own span that are neither blank nor comment-only, counted by a scanner that knows string and text-block literals, so a "/*" in the source does not swallow the rest of the file
cyclomaticinvocables only1 + if / for / foreach / while / do / non-default case label / catch / ternary / short-circuit && and || / switch pattern guard

Purely syntactic, hence immune to the noClasspath resolution ceiling. A nested lambda or anonymous class does NOT contribute to its enclosing method: it is its own invocable and carries its own count. A type’s complexity is therefore not stored — it is the sum over its members, which the consumer computes with sum:cyclomatic.

Literal and annotation facts emitted

FactCarried byNotes
a written annotation with its argumentsan annotationUse edge, arguments: NamedArgument[]the implicit value = is normalized explicit; an empty argument list is omitted on the wire and restored on read
a constant field initializerTWithValue on the attributeJLS compile-time constant expressions only
an annotation element defaultTWithValue on the method
a class literal in an argumentthe Literal {k:"type"} and its own reference edgea value never replaces a dependency

A field that is final but whose initializer is CODE (new StringBuilder()) carries nothing: absence means “not constant”. On commons-lang, SAFE_MAX_ARRAY_LENGTH (static final) states 2147483639, while SOFT_MAX_ARRAY_LENGTH — the same Integer.MAX_VALUE - 8 initializer, but not final — states nothing.

Profile notes

The documented blind spots, verbatim from the profile.

  • Measures (TMetrics, METAMODEL.md §3.8): sloc on every type and invocable — lines of its own span that are neither blank nor comment-only, counted by a scanner that knows string and text-block literals, so a "/*" in the source does not swallow the rest of the file. cyclomatic on invocables only: 1 + if / for / foreach / while / do / non-default case label / catch / ternary / short-circuit && and || / switch pattern guard. Purely syntactic, hence immune to the noClasspath resolution ceiling. A nested lambda or anonymous class does NOT contribute to its enclosing method: it is its own invocable and carries its own count. A type’s complexity is therefore not stored — it is the sum over its members, which the consumer computes.
  • Spoon in noClasspath mode invents plausible fully-qualified names for unresolved types. Corpus membership is therefore decided by a whitelist of ids actually declared by the corpus (built in a first pass); anything else is emitted as an isStub entity. Never decide membership by package or name prefix — invented FQNs look exactly like real ones and a prefix filter would launder them into facts.
  • Reflection is invisible: Class.forName, Method.invoke, proxies, Spring XML/annotation wiring, ServiceLoader/META-INF/services, and JNDI lookups produce no edge. The import/invocation graph of a reflection-heavy corpus is a lower bound.
  • Lombok-generated members (getters, setters, @Builder, @Data constructors) are emitted with provenance “generated” when the expansion is visible to Spoon, and are missing entirely when it is not.
  • An interface’s extends list is emitted as inheritance edges between types; interfaceImplementation is reserved for a class/enum/record implements clause, always with provenance “declared”.
  • A throws edge is emitted per written throw statement whose static exception type resolves, anchored at the throw site — the evidence a guard clause (if (x) throw new E(...)) leaves in the model. A method’s throws CLAUSE stays a plain reference edge: it declares propagation, not a failure exit of this body. A rethrow (throw e;) targets the caught variable’s static type; a throw whose type Spoon cannot name is dropped and counted, like any unidentifiable target.
  • Static and on-demand (import x.y.*) imports are folded to module-level import edges; the wildcard case names the package, not the individual types it brings in.
  • Overloads are distinguished by the id’s signature disambiguator, so an unresolved parameter type changes the id — a resolution failure shows up as a stub target, never as a merged entity.
  • Java generics are erased in the model: type arguments are emitted as reference edges from the declaring entity, and declaredType carries the raw type.
  • noClasspath resolution rate, measured (M2 audit): 100.0% on apache/commons-lang (263 files, 133478 type references, 0 unresolved) and 77.8% on spring-petclinic (30 files, 2189 references, 487 unresolved, all of them Spring and Jakarta types whose jars are absent). The rate is a property of the corpus’s DEPENDENCY SURFACE, not of the extractor: commons-lang is self-contained and depends on nothing but the JDK, which resolves against the runner’s own classpath. A jar that is not on the classpath cannot be resolved by any extractor, so a corpus with third-party dependencies has a structural ceiling well below 100% and a single cross-corpus target number is not meaningful. The stub discipline, not the resolution rate, is the property worth asserting.
  • The resolution rate counts neither type variables nor <nulltype>: neither names anything that could have a declaration, so counting them measures the corpus’s writing style. This is not cosmetic — <nulltype>, Spoon’s static type for the null literal, was ALL 1466 of commons-lang’s originally-reported unresolved references, making the headline number a function of how many return null; statements the corpus contains.
  • In noClasspath Spoon promotes an unresolvable RECEIVER to a type in the enclosing package: typeHint -> typeHint.with(...) and cm.setStatisticsEnabled(...) produced stub classes named typeHint and cm inside the corpus’s own package on spring-petclinic. They are correctly stubbed (membership is the declared-id whitelist), and they are why the whitelist exists — but the stub set of a real corpus therefore contains a tail of entities named after local variables, and a stub count is not a count of external types.
  • An array type is not an entity, and neither is its component where a member is concerned. xs.length and int[]::new declare their member on int[] / Money[]; folding that up names the component and states a fact the source never wrote. Such facts are dropped, not degraded — the dependency on the component is already carried by the written type reference that mentions it.
  • An anonymous class has exactly one id, the #file:line:column form pass 1 declared. Spoon names it Outer$N, which renders as a plausible nested-type id in the corpus’s own package; a reference resolved through that name gives one declared class two ids and launders the second into a stub. Type references must be resolved to their declaration before being named.
  • Spoon materializes the implicit constructor of an anonymous class with synthetic parameters named $anonymousN. They are emitted (implicit members are, deliberately, so that new Foo() does not dangle), so the model contains a handful of parameter entities nobody wrote, carrying no anchor of their own — 5 of 15338 entities on commons-lang.

The reference corpus

fixtures/java/ is a small order-management corpus (13 files, ~250 lines) that exercises every extraction hazard, and the acceptance corpus for the extractor: the snapshot in fixtures/java/expected/ is produced from that tree.

java -jar target/codegraph-java.jar --src ../../fixtures/java/src --out model.jsonl

It does not compile, on purpose. javac reports 12 errors, all in two files, and every one is cannot find symbol / package does not exist for a type the corpus deliberately never declares:

Missing typeWhereWhy it is missing
com.megacorp.ledger.LedgerClientimported by OrderService, extended by LedgerAdapteran external library that is not on the classpath — the ordinary legacy case
com.acme.order.Invoiceused by OrderService.bill, never importedSpoon must invent this FQN from the enclosing package
com.acme.order.adapter.AuditTrailused by LedgerAdapter, never importedthe same invention, in a different package, proving it follows the enclosing package

What each file pins:

FilePins
OrderService.javathe id scheme’s parameter-type rule (two archive overloads whose parameter types are java.util.List and com.acme.order.legacy.List); fabricated FQNs; member-level references into a stub type
LedgerAdapter.javainheritance from an unresolvable external type, plus a second fabrication site in another package
legacy/List.javarecursion (from == to) and the simple-name collision partner
Basket.javanested types invisible to getAllTypes(); an implicit default constructor with no valid source position; field read, write and compound assignment
Order.javaoverloaded constructors with this(…)/super(…) delegation, and an access to an inherited field
AbstractOrder.javaimplements between corpus types
Discountable.javainterface extends interface — an inheritance edge, not interfaceImplementation
Money.javarecord implementing a corpus interface; the synthesised component field, accessor and canonical constructor
Channel.javaenum implementing a corpus interface; enum constants as attribute entities
Audited.javathe annotation kind, and an annotation use with an enum argument
Notifications.javalambdas and an anonymous class — TInvocable without TNamed; members of an anonymous class
Reporting.javathree import forms (normal, on-demand, static); generic erasure, arrays, varargs

Measured Spoon behaviour on this corpus (Spoon 11.5.0, setNoClasspath(true), setComplianceLevel(17), setCommentEnabled(true)):

  • getAllTypes() returns 13 top-level types; Basket.Line, Basket.Line.Discount and Basket.Cursor are reachable only via getNestedTypes().
  • Exactly 4 references have getTypeDeclaration() == null: the three genuinely-missing types above and <nulltype>, Spoon’s type for the null literal.
  • 36 referenced types resolve, JDK types included — resolvability is not corpus membership, which is why the whitelist is the only membership test.
  • 4 lambdas, 1 anonymous class, 9 implicit constructors with no valid position.

Gotcha

Point --src at a single source root. A repository with several source roots for one package — main and test, or split across modules — confuses the resolver.

Last updated on