diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c792cd2..bc6e369 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -108,10 +108,10 @@ Repository
│
▼
File Walker ──▶ Extractors ──▶ Fact Store ──▶ Cross-Repo Linker ──▶ Graph Index
- (apply (Go, Kotlin, (indexed by (only with 2+ (bidirectional)
- ignore Python, TS, kind / file / repos loaded) │
- globs) Swift, Ruby, name / repo) ▼
- C++, OpenAPI) Explainers
+ (apply (Go, Java, (indexed by (only with 2+ (bidirectional)
+ ignore Kotlin, Python, kind / file / repos loaded) │
+ globs) TS, Swift, name / repo) ▼
+ Ruby, C++, OpenAPI) Explainers
(cycles, layers,
crossrepo)
│
@@ -299,6 +299,7 @@ ignore:
- "**/*.yaml"
extractors:
- go
+ - java
- kotlin
- openapi
- python
@@ -322,7 +323,7 @@ The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list (
|-------|-------------|---------|
| `repo` | Repository root path | `"."` |
| `ignore` | Glob patterns for files/dirs to skip | vendor, node_modules, .git, tests, build dirs, docs, config data, … |
-| `extractors` | Enabled extractors | `["cpp", "go", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` |
+| `extractors` | Enabled extractors | `["cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` |
| `explainers` | Enabled explainers | `["cycles", "layers", "crossrepo"]` |
| `renderers` | Enabled renderers | `["llm_context"]` |
| `output.dir` | Output directory for artifacts | `".enola"` |
@@ -337,6 +338,7 @@ Each extractor is detected by characteristic project files and then parses what
| Language | Parser | Detected by |
|------------|------------------|-------------|
| Go | `go/ast` | `go.mod` present |
+| Java | tree-sitter | `pom.xml` (Maven) present, or any `.java` source file (a Gradle build file alone does **not** trigger it — Kotlin/Android use Gradle too) |
| Kotlin | tree-sitter | `build.gradle.kts` / `build.gradle` with Kotlin/Android |
| Python | tree-sitter | `pyproject.toml`, `setup.py`, `requirements.txt`, `Pipfile`, `pytest.ini`, `mypy.ini`, `tox.ini`, or `setup.cfg` (root or up to 3 levels deep) |
| TypeScript | tree-sitter | `tsconfig.json`, `tsconfig.base.json`, or `package.json` with TypeScript (root or one level deep) |
@@ -351,6 +353,8 @@ Each extractor is detected by characteristic project files and then parses what
**Python** is parsed with tree-sitter (the concrete syntax tree handles nested classes/methods and docstrings natively, replacing the older indentation scanner). It understands **FastAPI/Starlette** route decorators and **Django** routes — `@api_view([...])` and `urls.py` `path()`/`re_path()` — emitting a `route` fact per endpoint. It emits `storage` facts for **SQLAlchemy** `__tablename__` and **Django models** (table name inferred from the class name), and classifies Django views and serializers via a `django_component` prop. It captures `async def` (`async: true`), decorator props (`@property`, `@staticmethod`, `@classmethod`, `@abstractmethod`, and Celery `@task`/`@shared_task`), and return-type hints. Each class emits an `implements` edge per base class, with generic type parameters stripped (`CRUDBase[Model, Id]` → `CRUDBase`), and both `import` forms become `dependency` facts. Crucially, the Python extractor now walks function and method bodies for call sites, emitting `calls` and `instantiates` edges (filtering out builtins) — so Python code participates in the dependency/call graph and is reachable by `traverse`, `find_path`, and `impact_analysis`. Monorepo detection walks up to 3 levels.
+**Java** (tree-sitter) is framework-aware for the JVM server ecosystem. It emits symbol facts for classes, interfaces, enums, records, and annotation types, plus their methods, constructors, and fields, named with enola's `
.` / `..` convention (nested types are qualified through the enclosing type). `extends`/`implements` become `implements` edges, `new X()` becomes `instantiates`, same-class method calls become `calls`, and both import forms become `dependency` facts split into internal vs. external. Because Java imports are explicit, type-reference edges are resolved through a project-wide fully-qualified-name index built in a second pass — so `implements`/`instantiates`/`injects` targets point at the canonical declaring symbol in another file or module rather than a bare name. Framework specialization covers **Spring MVC** (a `@RestController`/`@Controller` class's `@RequestMapping` base path is combined with method-level `@GetMapping`/`@PostMapping`/`@PutMapping`/`@DeleteMapping`/`@PatchMapping`/`@RequestMapping(method=…)` into one `route` per endpoint, carrying the HTTP method and the handler symbol), **Spring stereotypes** (`@Service`/`@Component`/`@Repository`/`@Controller`/`@Configuration` classified via a `component` prop), **dependency injection** (`@Autowired` fields, constructor injection, and Lombok `@RequiredArgsConstructor` over `final` fields → `injects` edges), and **JPA / Spring Data storage** (`@Entity` → a `storage` fact with `storage_kind: entity`; `@Repository` and `JpaRepository`/`CrudRepository`-style interfaces → `storage_kind: repository`). A `@Table(name = …)` is captured, and when the name is given as a `static final String` constant it is resolved to its literal value — the original identifier is preserved in a `table_constant` prop. **Apache Dubbo** is recognized too: `@SPI`/`@Activate`/`@DubboService` tag the type with `framework: "dubbo"` (`dubbo_spi`, `dubbo_activate`). Detection requires Maven (`pom.xml`) or real `.java` sources, so a pure-Kotlin Gradle project is left to the Kotlin extractor.
+
**Kotlin** is Android-aware: it detects Jetpack Compose (`@Composable`), Hilt DI (`@HiltViewModel`, `@Module`, `@AndroidEntryPoint`), Room (`@Entity`, `@Dao`, `@Database`), ViewModels, Repositories, Use Cases, and Workers.
**Swift** is iOS-aware: SwiftUI views (`View`/`App`/`Scene`), UIKit (`UIViewController`/`UIView` subclasses), Combine view models (`ObservableObject`, `@Observable`), architectural roles (Repositories, Use Cases, Coordinators, Services, DI containers), and `@MainActor`.
diff --git a/README.md b/README.md
index 78ecee7..2120a5d 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,7 @@ Working across several repos? Generate the first, then add the rest with append
| Language | Detected by |
|------------|-------------|
| Go | `go.mod` |
+| Java | `pom.xml` (Maven) or `.java` sources (Spring routes / JPA / Lombok DI / Dubbo SPI aware) |
| TypeScript | `tsconfig.json` / `package.json` with TypeScript (Next.js & monorepo aware) |
| Python | `pyproject.toml`, `requirements.txt`, `setup.py`, … (FastAPI / Django / SQLAlchemy aware) |
| Kotlin | `build.gradle(.kts)` with Kotlin/Android (Compose / Hilt / Room aware) |
diff --git a/examples/full.yaml b/examples/full.yaml
index 4e8bc70..305a8dd 100644
--- a/examples/full.yaml
+++ b/examples/full.yaml
@@ -6,6 +6,7 @@
#
# Supported extractors:
# - go (detection: go.mod)
+# - java (detection: pom.xml, build.gradle, or .java sources)
# - kotlin (detection: build.gradle.kts or build.gradle with Kotlin/Android)
# - typescript (detection: tsconfig.json or package.json with TypeScript)
# - swift (detection: Package.swift, .xcodeproj, or .xcworkspace)
@@ -86,6 +87,7 @@ ignore:
extractors:
- cpp
- go
+ - java
- kotlin
- typescript
- swift
diff --git a/go.mod b/go.mod
index 523029b..602fa96 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0
github.com/tree-sitter/go-tree-sitter v0.24.0
github.com/tree-sitter/tree-sitter-cpp v0.22.4-0.20240818224355-b1a4e2b25148
+ github.com/tree-sitter/tree-sitter-java v0.21.1-0.20240824015150-576d8097e495
github.com/tree-sitter/tree-sitter-python v0.23.6
github.com/tree-sitter/tree-sitter-typescript v0.23.2
gopkg.in/yaml.v3 v3.0.1
diff --git a/internal/config/config.go b/internal/config/config.go
index 83bcb3f..dea7a15 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -57,7 +57,7 @@ func Default() *Config {
"**/Pods/**",
"**/.gradle/**",
},
- Extractors: []string{"cpp", "go", "kotlin", "openapi", "python", "typescript", "swift", "ruby"},
+ Extractors: []string{"cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"},
Explainers: []string{"cycles", "layers", "crossrepo"},
Renderers: []string{"llm_context"},
Output: OutputConfig{
diff --git a/internal/extractors/javaextractor/java.go b/internal/extractors/javaextractor/java.go
new file mode 100644
index 0000000..7b901e6
--- /dev/null
+++ b/internal/extractors/javaextractor/java.go
@@ -0,0 +1,253 @@
+package javaextractor
+
+import (
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/enola-labs/enola/internal/facts"
+)
+
+// JavaExtractor extracts architectural facts from Java source code using
+// tree-sitter AST parsing (see java_ast.go for the walker and spring.go for
+// Spring/JPA/Dubbo framework specialization).
+type JavaExtractor struct{}
+
+// New creates a new JavaExtractor.
+func New() *JavaExtractor {
+ return &JavaExtractor{}
+}
+
+func (e *JavaExtractor) Name() string {
+ return "java"
+}
+
+// Detect returns true if the repository looks like a Java project: a Maven project
+// (pom.xml), or any actual .java source file. A Gradle build file alone is not
+// sufficient — Gradle is equally used by Kotlin, Android, and Groovy projects, so
+// detecting on it would wrongly claim pure-Kotlin repos. Requiring real .java
+// sources keeps the Java extractor off non-Java JVM projects.
+func (e *JavaExtractor) Detect(repoPath string) (bool, error) {
+ if _, err := os.Stat(filepath.Join(repoPath, "pom.xml")); err == nil {
+ return true, nil
+ }
+ return containsJavaSource(repoPath, 8), nil
+}
+
+// Extract parses Java files and emits architectural facts.
+//
+// Two passes: pass 1 walks each file's AST (extractFileAST) to emit declaration,
+// import, route, storage and call-graph facts while indexing every declared type by
+// its fully-qualified name. Pass 2 (canonicalizeTargets) rewrites type-reference
+// edge targets (implements/instantiates/injects) and import targets from FQNs to
+// canonical "." / module-dir names so reverse traversal connects
+// dependents. Module facts are emitted per directory.
+func (e *JavaExtractor) Extract(ctx context.Context, repoPath string, files []string) ([]facts.Fact, error) {
+ var allFacts []facts.Fact
+ modules := make(map[string]bool)
+
+ for _, relFile := range files {
+ select {
+ case <-ctx.Done():
+ return allFacts, ctx.Err()
+ default:
+ }
+
+ if !isJavaFile(relFile) {
+ continue
+ }
+
+ absFile := filepath.Join(repoPath, relFile)
+ src, err := os.ReadFile(absFile)
+ if err != nil {
+ log.Printf("[java-extractor] error reading %s: %v", relFile, err)
+ continue
+ }
+
+ allFacts = append(allFacts, extractFileAST(src, relFile)...)
+ modules[filepath.Dir(relFile)] = true
+ }
+
+ canonicalizeTargets(allFacts)
+ resolveTableConstants(allFacts)
+
+ for dir := range modules {
+ allFacts = append(allFacts, facts.Fact{
+ Kind: facts.KindModule,
+ Name: dir,
+ File: dir,
+ Props: map[string]any{
+ "language": "java",
+ },
+ })
+ }
+
+ return allFacts, nil
+}
+
+// canonicalizeTargets resolves FQN-based edge targets to canonical fact names.
+//
+// - implements/instantiates/injects targets that match a declared type's FQN are
+// rewritten to that type's "." fact name; unresolved targets (external
+// libraries) are left as written.
+// - import dependency facts whose target FQN resolves to a declared type — or whose
+// value names a known source package — are marked source="internal" and pointed at
+// the owning module dir.
+func canonicalizeTargets(allFacts []facts.Fact) {
+ typeIndex := make(map[string]string) // FQN -> "." canonical name
+ typeDir := make(map[string]string) // FQN -> dir
+ packageDir := make(map[string]string)
+ for _, f := range allFacts {
+ if f.Kind != facts.KindSymbol {
+ continue
+ }
+ switch f.Props["symbol_kind"] {
+ case facts.SymbolClass, facts.SymbolInterface, facts.SymbolEnum:
+ fqn, _ := f.Props["fqn"].(string)
+ if fqn == "" {
+ continue
+ }
+ dir := f.File
+ if i := strings.LastIndex(dir, "/"); i >= 0 {
+ dir = dir[:i]
+ } else {
+ dir = "."
+ }
+ typeIndex[fqn] = f.Name
+ typeDir[fqn] = dir
+ if pkg := parentName(fqn); pkg != "" {
+ packageDir[pkg] = dir
+ }
+ }
+ }
+
+ for i := range allFacts {
+ f := &allFacts[i]
+ if f.Kind == facts.KindDependency {
+ resolveImport(f, typeDir, packageDir)
+ continue
+ }
+ for j := range f.Relations {
+ r := &f.Relations[j]
+ switch r.Kind {
+ case facts.RelImplements, facts.RelInstantiates, facts.RelInjects:
+ if canon, ok := typeIndex[r.Target]; ok {
+ r.Target = canon
+ }
+ }
+ }
+ }
+}
+
+func resolveImport(f *facts.Fact, typeDir, packageDir map[string]string) {
+ imp, _ := f.Props["import"].(string)
+ if imp == "" {
+ return
+ }
+ var dir string
+ var ok bool
+ if dir, ok = typeDir[imp]; !ok {
+ // Wildcard / package import (e.g. "com.example.foo").
+ dir, ok = packageDir[imp]
+ }
+ if !ok {
+ return // external dependency
+ }
+ f.Props["source"] = "internal"
+ for j := range f.Relations {
+ if f.Relations[j].Kind == facts.RelImports {
+ f.Relations[j].Target = dir
+ }
+ }
+}
+
+// resolveTableConstants rewrites storage facts whose "table" prop names a string
+// constant (e.g. @Table(name = ADMIN_SETTINGS_TABLE_NAME)) to that constant's
+// literal value. Constants are indexed by simple name across all files, since the
+// table-name constants typically live in a shared ModelConstants class. When the
+// same simple name maps to conflicting values it is left unresolved (ambiguous).
+func resolveTableConstants(allFacts []facts.Fact) {
+ values := make(map[string]string)
+ ambiguous := make(map[string]bool)
+ for _, f := range allFacts {
+ if f.Kind != facts.KindSymbol {
+ continue
+ }
+ v, ok := f.Props["value"].(string)
+ if !ok {
+ continue
+ }
+ simple := f.Name
+ if i := strings.LastIndex(simple, "."); i >= 0 {
+ simple = simple[i+1:]
+ }
+ if existing, seen := values[simple]; seen && existing != v {
+ ambiguous[simple] = true
+ continue
+ }
+ values[simple] = v
+ }
+
+ for i := range allFacts {
+ f := &allFacts[i]
+ if f.Kind != facts.KindStorage {
+ continue
+ }
+ tbl, ok := f.Props["table"].(string)
+ if !ok {
+ continue
+ }
+ if ambiguous[tbl] {
+ continue
+ }
+ if v, ok := values[tbl]; ok {
+ f.Props["table"] = v
+ f.Props["table_constant"] = tbl
+ }
+ }
+}
+
+func parentName(fqn string) string {
+ if i := strings.LastIndex(fqn, "."); i >= 0 {
+ return fqn[:i]
+ }
+ return ""
+}
+
+func isJavaFile(path string) bool {
+ return strings.HasSuffix(strings.ToLower(path), ".java")
+}
+
+// containsJavaSource reports whether any .java file exists under root within
+// maxDepth directory levels. It returns on the first match and skips hidden and
+// common build/dependency directories so it stays cheap on large repos.
+func containsJavaSource(root string, maxDepth int) bool {
+ var search func(dir string, depth int) bool
+ search = func(dir string, depth int) bool {
+ if depth > maxDepth {
+ return false
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return false
+ }
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() {
+ if strings.HasPrefix(name, ".") || name == "build" ||
+ name == "target" || name == "node_modules" {
+ continue
+ }
+ if search(filepath.Join(dir, name), depth+1) {
+ return true
+ }
+ } else if isJavaFile(name) {
+ return true
+ }
+ }
+ return false
+ }
+ return search(root, 0)
+}
diff --git a/internal/extractors/javaextractor/java_ast.go b/internal/extractors/javaextractor/java_ast.go
new file mode 100644
index 0000000..d7f7282
--- /dev/null
+++ b/internal/extractors/javaextractor/java_ast.go
@@ -0,0 +1,842 @@
+package javaextractor
+
+import (
+ "path/filepath"
+ "strings"
+ "unicode"
+
+ "github.com/enola-labs/enola/internal/facts"
+ sitter "github.com/tree-sitter/go-tree-sitter"
+ java "github.com/tree-sitter/tree-sitter-java/bindings/go"
+)
+
+// extractFileAST parses a single Java file with tree-sitter and emits architectural
+// facts: declaration symbols (classes, interfaces, enums, records, methods, fields),
+// import dependencies, and call-graph relations (RelImplements, RelInstantiates,
+// RelInjects, RelCalls).
+//
+// Relation targets for type references (implements/instantiates/injects) are emitted
+// as fully-qualified names — resolved through the file's import map, or assumed to be
+// same-package when no import matches. java.go's canonicalizeTargets rewrites those
+// FQNs to canonical "." fact names once every file has been indexed.
+func extractFileAST(src []byte, relFile string) []facts.Fact {
+ parser := sitter.NewParser()
+ defer parser.Close()
+ if err := parser.SetLanguage(sitter.NewLanguage(java.Language())); err != nil {
+ return nil
+ }
+ tree := parser.Parse(src, nil)
+ if tree == nil {
+ return nil
+ }
+ defer tree.Close()
+
+ w := &astWalker{
+ src: src,
+ relFile: relFile,
+ dir: filepath.Dir(relFile),
+ importMap: make(map[string]string),
+ }
+ root := tree.RootNode()
+ w.pkg = w.findPackage(root)
+ w.walkProgram(root)
+ return w.out
+}
+
+type astWalker struct {
+ src []byte
+ relFile string
+ dir string
+ pkg string // dotted package name, e.g. "com.example.auth" ("" if none)
+
+ out []facts.Fact
+
+ // importMap maps an imported simple type name to its fully-qualified name
+ // (e.g. "Store" -> "com.example.data.Store"). Used to resolve bare type
+ // references in supertypes, constructor calls, and injected parameters.
+ importMap map[string]string
+
+ // typeStack holds the simple names of the enclosing type declarations, so a
+ // method declared in class Foo is named ".Foo.method". methodStack is
+ // parallel and holds the method-name set of each enclosing type, used to
+ // resolve same-class bare calls.
+ typeStack []string
+ methodStack []map[string]bool
+
+ // ownerStack[len-1] is the symbol fact currently being built; call-graph edges
+ // discovered while walking its body attach to it.
+ ownerStack []*facts.Fact
+
+ // routeStack is parallel to typeStack: each entry carries the Spring route
+ // context (whether the enclosing type is a @Controller/@RestController and its
+ // class-level base path) so method handlers can emit route facts.
+ routeStack []routeScope
+}
+
+type routeScope struct {
+ isController bool
+ basePath string
+}
+
+func (w *astWalker) enclosingType() string { return strings.Join(w.typeStack, ".") }
+
+func (w *astWalker) qualify(name string) string {
+ if t := w.enclosingType(); t != "" {
+ return t + "." + name
+ }
+ return name
+}
+
+func (w *astWalker) currentMethods() map[string]bool {
+ if len(w.methodStack) == 0 {
+ return nil
+ }
+ return w.methodStack[len(w.methodStack)-1]
+}
+
+func (w *astWalker) currentRoute() *routeScope {
+ if len(w.routeStack) == 0 {
+ return nil
+ }
+ return &w.routeStack[len(w.routeStack)-1]
+}
+
+func (w *astWalker) pushOwner(f *facts.Fact) { w.ownerStack = append(w.ownerStack, f) }
+func (w *astWalker) popOwner() { w.ownerStack = w.ownerStack[:len(w.ownerStack)-1] }
+func (w *astWalker) currentOwner() *facts.Fact {
+ if len(w.ownerStack) == 0 {
+ return nil
+ }
+ return w.ownerStack[len(w.ownerStack)-1]
+}
+
+// canonicalName is the "." fact name of a declaration.
+func (w *astWalker) canonicalName(qualified string) string { return w.dir + "." + qualified }
+
+// fqn is the fully-qualified "." name of a declaration.
+func (w *astWalker) fqn(qualified string) string {
+ if w.pkg == "" {
+ return qualified
+ }
+ return w.pkg + "." + qualified
+}
+
+func (w *astWalker) findPackage(root *sitter.Node) string {
+ if pd := findChildByKind(root, "package_declaration"); pd != nil {
+ // The package name is the scoped_identifier / identifier child.
+ for i := uint(0); i < uint(pd.ChildCount()); i++ {
+ c := pd.Child(i)
+ if c.Kind() == "scoped_identifier" || c.Kind() == "identifier" {
+ return nodeText(c, w.src)
+ }
+ }
+ }
+ return ""
+}
+
+func (w *astWalker) walkProgram(root *sitter.Node) {
+ for i := uint(0); i < uint(root.ChildCount()); i++ {
+ w.walkTopLevel(root.Child(i))
+ }
+}
+
+func (w *astWalker) walkTopLevel(node *sitter.Node) {
+ switch node.Kind() {
+ case "import_declaration":
+ w.handleImport(node)
+ case "class_declaration":
+ w.handleClassLike(node, facts.SymbolClass)
+ case "interface_declaration":
+ w.handleClassLike(node, facts.SymbolInterface)
+ case "enum_declaration":
+ w.handleClassLike(node, facts.SymbolEnum)
+ case "record_declaration":
+ w.handleClassLike(node, facts.SymbolClass)
+ case "annotation_type_declaration":
+ w.handleClassLike(node, facts.SymbolInterface)
+ }
+}
+
+func (w *astWalker) handleImport(node *sitter.Node) {
+ isStatic := false
+ isWildcard := false
+ var pathNode *sitter.Node
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ switch c.Kind() {
+ case "static":
+ isStatic = true
+ case "asterisk":
+ isWildcard = true
+ case "scoped_identifier", "identifier":
+ pathNode = c
+ }
+ }
+ if pathNode == nil {
+ return
+ }
+ importPath := nodeText(pathNode, w.src)
+
+ w.out = append(w.out, facts.Fact{
+ Kind: facts.KindDependency,
+ Name: w.dir + " -> " + importPath,
+ File: w.relFile,
+ Line: int(node.StartPosition().Row) + 1,
+ Props: map[string]any{
+ "language": "java",
+ "import": importPath,
+ "source": "external", // refined to "internal" in canonicalizeTargets
+ },
+ Relations: []facts.Relation{
+ {Kind: facts.RelImports, Target: importPath},
+ },
+ })
+
+ // Record a non-static, non-wildcard import's simple name so bare type
+ // references resolve to its FQN. Static imports name a member, not a type;
+ // wildcard imports carry no simple name.
+ if isStatic || isWildcard {
+ return
+ }
+ simple := importPath
+ if i := strings.LastIndex(importPath, "."); i >= 0 {
+ simple = importPath[i+1:]
+ }
+ if simple != "" {
+ w.importMap[simple] = importPath
+ }
+}
+
+func (w *astWalker) handleClassLike(node *sitter.Node, kind string) {
+ nameNode := node.ChildByFieldName("name")
+ if nameNode == nil {
+ return
+ }
+ name := nodeText(nameNode, w.src)
+
+ modifiers := findChildByKind(node, "modifiers")
+ modifierText := ""
+ var annotations []javaAnnotation
+ if modifiers != nil {
+ modifierText = nodeText(modifiers, w.src)
+ annotations = parseAnnotations(modifiers, w.src)
+ }
+ // A top-level type is exported when public; nested types inherit visibility
+ // loosely — treat anything not explicitly private as part of the surface.
+ exported := strings.Contains(modifierText, "public") ||
+ (!strings.Contains(modifierText, "private") && len(w.typeStack) > 0)
+
+ f := facts.Fact{
+ Kind: facts.KindSymbol,
+ Name: w.canonicalName(w.qualify(name)),
+ File: w.relFile,
+ Line: int(node.StartPosition().Row) + 1,
+ Props: map[string]any{
+ "symbol_kind": kind,
+ "exported": exported,
+ "language": "java",
+ "fqn": w.fqn(w.qualify(name)),
+ },
+ Relations: []facts.Relation{
+ {Kind: facts.RelDeclares, Target: w.dir},
+ },
+ }
+ if strings.Contains(modifierText, "abstract") {
+ f.Props["abstract"] = true
+ }
+ if node.Kind() == "record_declaration" {
+ f.Props["record"] = true
+ }
+ if node.Kind() == "annotation_type_declaration" {
+ f.Props["annotation_class"] = true
+ }
+
+ // Inheritance: `extends` superclass + `implements`/`extends` interfaces.
+ for _, st := range w.supertypeTargets(node) {
+ f.Relations = append(f.Relations, facts.Relation{Kind: facts.RelImplements, Target: st})
+ }
+
+ // Framework classification (Spring component / JPA / Dubbo SPI) mutates props
+ // and may emit a companion storage fact.
+ classifyComponent(&f, name, annotations, w.supertypeSimpleNames(node))
+ if sf := detectJpaStorage(name, annotations, w.relFile, int(node.StartPosition().Row)+1, w.dir); sf != nil {
+ w.out = append(w.out, *sf)
+ }
+
+ w.out = append(w.out, f)
+ owner := &w.out[len(w.out)-1]
+ w.pushOwner(owner)
+
+ // Enter the type scope.
+ body := classBody(node)
+ w.typeStack = append(w.typeStack, name)
+ w.methodStack = append(w.methodStack, collectMethodNames(body, w.src))
+ w.routeStack = append(w.routeStack, routeScope{
+ isController: isSpringController(annotations),
+ basePath: requestMappingPath(annotations),
+ })
+
+ // Constructor-based DI: a class with a single constructor, or one annotated
+ // @Autowired/@Inject, injects each of that constructor's parameter types.
+ w.handleConstructorInjection(node, body, owner, annotations)
+ // Field-level @Autowired/@Inject and Lombok @RequiredArgsConstructor over
+ // `private final` fields also produce injection edges; emitted while walking
+ // the body below.
+
+ if body != nil {
+ w.walkBody(body, owner)
+ }
+
+ w.routeStack = w.routeStack[:len(w.routeStack)-1]
+ w.typeStack = w.typeStack[:len(w.typeStack)-1]
+ w.methodStack = w.methodStack[:len(w.methodStack)-1]
+ w.popOwner()
+}
+
+// walkBody iterates the direct members of a class/interface/enum body, handling
+// nested declarations, methods, and fields. Non-declaration nodes are scanned for
+// constructor calls attributed to `owner`.
+func (w *astWalker) walkBody(body *sitter.Node, owner *facts.Fact) {
+ for i := uint(0); i < uint(body.ChildCount()); i++ {
+ c := body.Child(i)
+ switch c.Kind() {
+ case "class_declaration":
+ w.handleClassLike(c, facts.SymbolClass)
+ case "interface_declaration":
+ w.handleClassLike(c, facts.SymbolInterface)
+ case "enum_declaration":
+ w.handleClassLike(c, facts.SymbolEnum)
+ case "record_declaration":
+ w.handleClassLike(c, facts.SymbolClass)
+ case "annotation_type_declaration":
+ w.handleClassLike(c, facts.SymbolInterface)
+ case "method_declaration", "constructor_declaration":
+ w.handleMethod(c)
+ case "field_declaration":
+ w.handleField(c, owner)
+ default:
+ // init blocks, enum constants, etc. — scan for constructor calls.
+ w.walkForCalls(c)
+ }
+ }
+}
+
+func (w *astWalker) handleMethod(node *sitter.Node) {
+ nameNode := node.ChildByFieldName("name")
+ if nameNode == nil {
+ return
+ }
+ name := nodeText(nameNode, w.src)
+
+ modifiers := findChildByKind(node, "modifiers")
+ modifierText := ""
+ var annotations []javaAnnotation
+ if modifiers != nil {
+ modifierText = nodeText(modifiers, w.src)
+ annotations = parseAnnotations(modifiers, w.src)
+ }
+ exported := strings.Contains(modifierText, "public")
+
+ f := facts.Fact{
+ Kind: facts.KindSymbol,
+ Name: w.canonicalName(w.qualify(name)),
+ File: w.relFile,
+ Line: int(node.StartPosition().Row) + 1,
+ Props: map[string]any{
+ "symbol_kind": facts.SymbolMethod,
+ "exported": exported,
+ "language": "java",
+ },
+ Relations: []facts.Relation{
+ {Kind: facts.RelDeclares, Target: w.dir},
+ },
+ }
+ if t := w.enclosingType(); t != "" {
+ f.Props["receiver"] = t
+ }
+ if strings.Contains(modifierText, "static") {
+ f.Props["static"] = true
+ }
+
+ // Spring route: a request-mapping annotation on a controller method.
+ if rs := w.currentRoute(); rs != nil && rs.isController {
+ for _, rf := range springRouteFacts(rs.basePath, annotations, w.relFile,
+ int(node.StartPosition().Row)+1, w.dir, w.canonicalName(w.qualify(name))) {
+ w.out = append(w.out, rf)
+ }
+ }
+
+ w.out = append(w.out, f)
+ owner := &w.out[len(w.out)-1]
+ w.pushOwner(owner)
+ if body := node.ChildByFieldName("body"); body != nil {
+ w.walkForCalls(body)
+ }
+ w.popOwner()
+}
+
+func (w *astWalker) handleField(node *sitter.Node, owner *facts.Fact) {
+ modifiers := findChildByKind(node, "modifiers")
+ modifierText := ""
+ var annotations []javaAnnotation
+ if modifiers != nil {
+ modifierText = nodeText(modifiers, w.src)
+ annotations = parseAnnotations(modifiers, w.src)
+ }
+ exported := strings.Contains(modifierText, "public")
+
+ symbolKind := facts.SymbolVariable
+ if strings.Contains(modifierText, "final") {
+ symbolKind = facts.SymbolConstant
+ }
+
+ // Field type — used for DI edges when @Autowired/@Inject is present.
+ typeNode := node.ChildByFieldName("type")
+ typeTarget := w.targetForType(typeNode)
+ injected := hasAnnotation(annotations, "Autowired", "Inject", "Resource", "Reference")
+ // A `static final String FOO = "literal"` constant exposes its value so that
+ // references to it (e.g. @Table(name = FOO)) can be resolved in a later pass.
+ captureValue := strings.Contains(modifierText, "static") &&
+ strings.Contains(modifierText, "final") &&
+ typeFullName(typeNode, w.src) == "String"
+
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ if c.Kind() != "variable_declarator" {
+ continue
+ }
+ nameNode := c.ChildByFieldName("name")
+ if nameNode == nil {
+ continue
+ }
+ name := nodeText(nameNode, w.src)
+ props := map[string]any{
+ "symbol_kind": symbolKind,
+ "exported": exported,
+ "language": "java",
+ }
+ if captureValue && isScreamingSnake(name) {
+ if val, ok := stringLiteralValue(c.ChildByFieldName("value"), w.src); ok {
+ props["value"] = val
+ }
+ }
+ w.out = append(w.out, facts.Fact{
+ Kind: facts.KindSymbol,
+ Name: w.canonicalName(w.qualify(name)),
+ File: w.relFile,
+ Line: int(c.StartPosition().Row) + 1,
+ Props: props,
+ Relations: []facts.Relation{
+ {Kind: facts.RelDeclares, Target: w.dir},
+ },
+ })
+ }
+
+ if injected && typeTarget != "" && owner != nil {
+ owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInjects, Target: typeTarget})
+ }
+ // A field initializer may contain a constructor call (`= new Foo()`).
+ w.walkForCalls(node)
+}
+
+// handleConstructorInjection emits RelInjects edges from `owner` to each parameter
+// type of an injectable constructor: a sole constructor, a constructor annotated
+// @Autowired/@Inject, or (Lombok) when the class carries @RequiredArgsConstructor /
+// @AllArgsConstructor.
+func (w *astWalker) handleConstructorInjection(decl, body *sitter.Node, owner *facts.Fact, classAnns []javaAnnotation) {
+ lombokInject := hasAnnotation(classAnns, "RequiredArgsConstructor", "AllArgsConstructor")
+
+ // record_declaration parameters are constructor parameters too.
+ if decl.Kind() == "record_declaration" {
+ if params := decl.ChildByFieldName("parameters"); params != nil && lombokInject {
+ w.injectParams(params, owner)
+ }
+ }
+
+ if lombokInject {
+ // Inject each `private final` field's type.
+ if body != nil {
+ for i := uint(0); i < uint(body.ChildCount()); i++ {
+ c := body.Child(i)
+ if c.Kind() != "field_declaration" {
+ continue
+ }
+ mods := findChildByKind(c, "modifiers")
+ if mods == nil || !strings.Contains(nodeText(mods, w.src), "final") {
+ continue
+ }
+ if t := w.targetForType(c.ChildByFieldName("type")); t != "" && owner != nil {
+ owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInjects, Target: t})
+ }
+ }
+ }
+ }
+
+ if body == nil {
+ return
+ }
+ var ctors []*sitter.Node
+ for i := uint(0); i < uint(body.ChildCount()); i++ {
+ if c := body.Child(i); c.Kind() == "constructor_declaration" {
+ ctors = append(ctors, c)
+ }
+ }
+ for _, ctor := range ctors {
+ mods := findChildByKind(ctor, "modifiers")
+ annotated := false
+ if mods != nil {
+ annotated = hasAnnotation(parseAnnotations(mods, w.src), "Autowired", "Inject")
+ }
+ if annotated || (len(ctors) == 1 && !lombokInject) {
+ if params := ctor.ChildByFieldName("parameters"); params != nil {
+ w.injectParams(params, owner)
+ }
+ }
+ }
+}
+
+func (w *astWalker) injectParams(params *sitter.Node, owner *facts.Fact) {
+ if owner == nil {
+ return
+ }
+ for i := uint(0); i < uint(params.ChildCount()); i++ {
+ p := params.Child(i)
+ if p.Kind() != "formal_parameter" {
+ continue
+ }
+ if t := w.targetForType(p.ChildByFieldName("type")); t != "" {
+ owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInjects, Target: t})
+ }
+ }
+}
+
+// walkForCalls recursively scans a subtree for object_creation_expression (→
+// RelInstantiates) and method_invocation (→ RelCalls for resolvable same-class
+// calls), attributing each to the current owner. Nested type declarations are
+// dispatched to their own handlers so their calls are attributed correctly.
+func (w *astWalker) walkForCalls(node *sitter.Node) {
+ if node == nil {
+ return
+ }
+ switch node.Kind() {
+ case "class_declaration":
+ w.handleClassLike(node, facts.SymbolClass)
+ return
+ case "interface_declaration":
+ w.handleClassLike(node, facts.SymbolInterface)
+ return
+ case "enum_declaration":
+ w.handleClassLike(node, facts.SymbolEnum)
+ return
+ case "record_declaration":
+ w.handleClassLike(node, facts.SymbolClass)
+ return
+ case "object_creation_expression":
+ if t := w.targetForType(node.ChildByFieldName("type")); t != "" {
+ if owner := w.currentOwner(); owner != nil {
+ owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInstantiates, Target: t})
+ }
+ }
+ case "method_invocation":
+ w.handleInvocation(node)
+ }
+
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ w.walkForCalls(node.Child(i))
+ }
+}
+
+func (w *astWalker) handleInvocation(node *sitter.Node) {
+ owner := w.currentOwner()
+ if owner == nil {
+ return
+ }
+ nameNode := node.ChildByFieldName("name")
+ if nameNode == nil {
+ return
+ }
+ name := nodeText(nameNode, w.src)
+ obj := node.ChildByFieldName("object")
+
+ // Resolve bare `foo()` and `this.foo()` calls against the enclosing class's
+ // own methods. Calls on other receivers are left unresolved (the receiver's
+ // type is not tracked), matching the Kotlin extractor's conservative model.
+ isThis := obj != nil && nodeText(obj, w.src) == "this"
+ if obj == nil || isThis {
+ if methods := w.currentMethods(); methods[name] {
+ owner.Relations = append(owner.Relations, facts.Relation{
+ Kind: facts.RelCalls,
+ Target: w.dir + "." + w.enclosingType() + "." + name,
+ })
+ }
+ }
+}
+
+// targetForType returns a relation target for a `_type` node: the rightmost simple
+// name resolved through the import map to an FQN, a same-package FQN when not
+// imported, or the written FQN when the reference is already qualified. Returns ""
+// for primitive/void/unresolvable types.
+func (w *astWalker) targetForType(typeNode *sitter.Node) string {
+ if typeNode == nil {
+ return ""
+ }
+ full := typeFullName(typeNode, w.src)
+ if full == "" {
+ return ""
+ }
+ if isPrimitiveType(full) {
+ return ""
+ }
+ simple := full
+ if i := strings.LastIndex(full, "."); i >= 0 {
+ // Already qualified in source — use as written.
+ return full
+ }
+ if fqn, ok := w.importMap[simple]; ok {
+ return fqn
+ }
+ if javaLangTypes[simple] {
+ return ""
+ }
+ if w.pkg != "" {
+ return w.pkg + "." + simple
+ }
+ return simple
+}
+
+// supertypeTargets returns canonicalization targets (FQNs) for a type's superclass
+// and implemented/extended interfaces.
+func (w *astWalker) supertypeTargets(node *sitter.Node) []string {
+ var out []string
+ for _, n := range w.supertypeNodes(node) {
+ if t := w.targetForType(n); t != "" {
+ out = append(out, t)
+ }
+ }
+ return out
+}
+
+// supertypeSimpleNames returns the simple names of a type's supertypes (used by
+// component classification, e.g. detecting Spring Data repository interfaces).
+func (w *astWalker) supertypeSimpleNames(node *sitter.Node) []string {
+ var out []string
+ for _, n := range w.supertypeNodes(node) {
+ if s := lastTypeComponent(typeFullName(n, w.src)); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func (w *astWalker) supertypeNodes(node *sitter.Node) []*sitter.Node {
+ var out []*sitter.Node
+ if sc := node.ChildByFieldName("superclass"); sc != nil {
+ out = append(out, firstTypeChild(sc))
+ }
+ // `interfaces` field (class/enum/record) or `extends_interfaces` child (interface).
+ if iface := node.ChildByFieldName("interfaces"); iface != nil {
+ out = append(out, typeListChildren(iface)...)
+ }
+ if ext := findChildByKind(node, "extends_interfaces"); ext != nil {
+ out = append(out, typeListChildren(ext)...)
+ }
+ // Filter nils.
+ kept := out[:0]
+ for _, n := range out {
+ if n != nil {
+ kept = append(kept, n)
+ }
+ }
+ return kept
+}
+
+// --- tree-sitter / type helpers ---
+
+func classBody(node *sitter.Node) *sitter.Node {
+ if b := node.ChildByFieldName("body"); b != nil {
+ return b
+ }
+ return nil
+}
+
+// typeListChildren returns the concrete `_type` children of a super_interfaces /
+// extends_interfaces node, which wrap a single type_list.
+func typeListChildren(node *sitter.Node) []*sitter.Node {
+ tl := findChildByKind(node, "type_list")
+ if tl == nil {
+ return nil
+ }
+ var out []*sitter.Node
+ for i := uint(0); i < uint(tl.ChildCount()); i++ {
+ c := tl.Child(i)
+ if c.IsNamed() && c.Kind() != "annotation" && c.Kind() != "marker_annotation" {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// firstTypeChild returns the first named, non-annotation child of a wrapper node
+// (e.g. the `_type` under a superclass node).
+func firstTypeChild(node *sitter.Node) *sitter.Node {
+ if node == nil {
+ return nil
+ }
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ if c.IsNamed() && c.Kind() != "annotation" && c.Kind() != "marker_annotation" {
+ return c
+ }
+ }
+ return nil
+}
+
+// typeFullName returns the source-written dotted name of a `_type` node, stripping
+// generic arguments and array dimensions. For `java.util.List` it returns
+// "java.util.List"; for `Map` it returns "Map".
+func typeFullName(node *sitter.Node, src []byte) string {
+ if node == nil {
+ return ""
+ }
+ switch node.Kind() {
+ case "type_identifier", "scoped_type_identifier":
+ return nodeText(node, src)
+ case "generic_type":
+ // First named child is the base type (type_identifier or scoped_type_identifier).
+ if base := firstNamedChild(node); base != nil {
+ return typeFullName(base, src)
+ }
+ case "annotated_type":
+ // The type follows the annotations.
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ if c.IsNamed() && c.Kind() != "annotation" && c.Kind() != "marker_annotation" {
+ return typeFullName(c, src)
+ }
+ }
+ case "array_type":
+ if el := node.ChildByFieldName("element"); el != nil {
+ return typeFullName(el, src)
+ }
+ }
+ // Primitive / void / fallback: take the raw text, stripped of generics/arrays.
+ t := nodeText(node, src)
+ if i := strings.IndexAny(t, "<[ "); i >= 0 {
+ t = t[:i]
+ }
+ return strings.TrimSpace(t)
+}
+
+func lastTypeComponent(full string) string {
+ if i := strings.LastIndex(full, "."); i >= 0 {
+ return full[i+1:]
+ }
+ return full
+}
+
+func collectMethodNames(body *sitter.Node, src []byte) map[string]bool {
+ methods := make(map[string]bool)
+ if body == nil {
+ return methods
+ }
+ for i := uint(0); i < uint(body.ChildCount()); i++ {
+ c := body.Child(i)
+ if c.Kind() != "method_declaration" {
+ continue
+ }
+ if nameNode := c.ChildByFieldName("name"); nameNode != nil {
+ methods[nodeText(nameNode, src)] = true
+ }
+ }
+ return methods
+}
+
+// isScreamingSnake reports whether a name is an UPPER_SNAKE_CASE constant
+// identifier (the convention for table/column name constants).
+func isScreamingSnake(s string) bool {
+ if s == "" {
+ return false
+ }
+ hasLetter := false
+ for _, r := range s {
+ switch {
+ case r >= 'A' && r <= 'Z':
+ hasLetter = true
+ case r >= '0' && r <= '9', r == '_':
+ default:
+ return false
+ }
+ }
+ return hasLetter
+}
+
+// stringLiteralValue returns the unquoted contents of a string_literal node.
+func stringLiteralValue(node *sitter.Node, src []byte) (string, bool) {
+ if node == nil || node.Kind() != "string_literal" {
+ return "", false
+ }
+ return strings.Trim(nodeText(node, src), `"`), true
+}
+
+func isPrimitiveType(name string) bool {
+ switch name {
+ case "void", "boolean", "byte", "short", "int", "long", "char", "float", "double", "var":
+ return true
+ }
+ return false
+}
+
+// javaLangTypes are implicitly imported java.lang types that appear as bare names
+// without an import statement; resolving them would create dangling edges.
+var javaLangTypes = map[string]bool{
+ "Object": true, "String": true, "Integer": true, "Long": true, "Short": true,
+ "Byte": true, "Character": true, "Boolean": true, "Float": true, "Double": true,
+ "Number": true, "Math": true, "System": true, "Thread": true, "Runnable": true,
+ "Exception": true, "RuntimeException": true, "Throwable": true, "Error": true,
+ "IllegalArgumentException": true, "IllegalStateException": true,
+ "NullPointerException": true, "UnsupportedOperationException": true,
+ "Class": true, "Enum": true, "Iterable": true, "Comparable": true, "CharSequence": true,
+ "StringBuilder": true, "StringBuffer": true, "Void": true, "Override": true,
+ "Deprecated": true, "SuppressWarnings": true, "FunctionalInterface": true,
+ "AutoCloseable": true, "Cloneable": true, "ClassLoader": true, "Process": true,
+}
+
+func isCapitalized(s string) bool {
+ if s == "" {
+ return false
+ }
+ return unicode.IsUpper([]rune(s)[0])
+}
+
+func findChildByKind(node *sitter.Node, kind string) *sitter.Node {
+ if node == nil {
+ return nil
+ }
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ if c.Kind() == kind {
+ return c
+ }
+ }
+ return nil
+}
+
+func firstNamedChild(node *sitter.Node) *sitter.Node {
+ if node == nil {
+ return nil
+ }
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ if c.IsNamed() {
+ return c
+ }
+ }
+ return nil
+}
+
+func nodeText(node *sitter.Node, src []byte) string {
+ if node == nil {
+ return ""
+ }
+ return string(src[node.StartByte():node.EndByte()])
+}
diff --git a/internal/extractors/javaextractor/java_ast_test.go b/internal/extractors/javaextractor/java_ast_test.go
new file mode 100644
index 0000000..3f0a5a7
--- /dev/null
+++ b/internal/extractors/javaextractor/java_ast_test.go
@@ -0,0 +1,295 @@
+package javaextractor
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/enola-labs/enola/internal/facts"
+)
+
+// extractAll writes the given files to a temp repo and runs the full extractor
+// (including the two-pass canonicalization), returning all emitted facts.
+func extractAll(t *testing.T, files map[string]string) []facts.Fact {
+ t.Helper()
+ dir := t.TempDir()
+ var relFiles []string
+ for rel, content := range files {
+ abs := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ relFiles = append(relFiles, rel)
+ }
+ ff, err := New().Extract(context.Background(), dir, relFiles)
+ if err != nil {
+ t.Fatalf("Extract: %v", err)
+ }
+ return ff
+}
+
+func findFact(ff []facts.Fact, name string) (facts.Fact, bool) {
+ for _, f := range ff {
+ if f.Name == name {
+ return f, true
+ }
+ }
+ return facts.Fact{}, false
+}
+
+func findFactKind(ff []facts.Fact, kind, name string) (facts.Fact, bool) {
+ for _, f := range ff {
+ if f.Kind == kind && f.Name == name {
+ return f, true
+ }
+ }
+ return facts.Fact{}, false
+}
+
+func factsByKind(ff []facts.Fact, kind string) []facts.Fact {
+ var out []facts.Fact
+ for _, f := range ff {
+ if f.Kind == kind {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+func hasRelation(f facts.Fact, kind, target string) bool {
+ for _, r := range f.Relations {
+ if r.Kind == kind && r.Target == target {
+ return true
+ }
+ }
+ return false
+}
+
+func TestExtract_ClassMethodField(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "src/main/java/pkg/Order.java": `package pkg;
+
+public class Order {
+ private final double total;
+ public static final int MAX = 100;
+
+ public Order(double total) {
+ this.total = total;
+ }
+
+ public double calculate() {
+ return total * 1.2;
+ }
+}
+`,
+ })
+
+ cls, ok := findFact(ff, "src/main/java/pkg.Order")
+ if !ok {
+ t.Fatalf("missing class fact; got %v", names(ff))
+ }
+ if cls.Props["symbol_kind"] != facts.SymbolClass {
+ t.Errorf("Order symbol_kind = %v, want class", cls.Props["symbol_kind"])
+ }
+ if cls.Props["exported"] != true {
+ t.Errorf("Order exported = %v, want true", cls.Props["exported"])
+ }
+ if cls.Props["fqn"] != "pkg.Order" {
+ t.Errorf("Order fqn = %v, want pkg.Order", cls.Props["fqn"])
+ }
+
+ m, ok := findFact(ff, "src/main/java/pkg.Order.calculate")
+ if !ok {
+ t.Fatalf("missing method fact; got %v", names(ff))
+ }
+ if m.Props["symbol_kind"] != facts.SymbolMethod {
+ t.Errorf("calculate symbol_kind = %v, want method", m.Props["symbol_kind"])
+ }
+ if m.Props["receiver"] != "Order" {
+ t.Errorf("calculate receiver = %v, want Order", m.Props["receiver"])
+ }
+
+ field, ok := findFact(ff, "src/main/java/pkg.Order.total")
+ if !ok {
+ t.Fatalf("missing field fact total; got %v", names(ff))
+ }
+ if field.Props["symbol_kind"] != facts.SymbolConstant {
+ t.Errorf("total symbol_kind = %v, want constant (final)", field.Props["symbol_kind"])
+ }
+}
+
+func TestExtract_InterfaceEnumRecord(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "a/Shape.java": "package a;\npublic interface Shape { double area(); }\n",
+ "a/Color.java": "package a;\npublic enum Color { RED, GREEN, BLUE }\n",
+ "a/Point.java": "package a;\npublic record Point(int x, int y) {}\n",
+ })
+
+ iface, _ := findFact(ff, "a.Shape")
+ if iface.Props["symbol_kind"] != facts.SymbolInterface {
+ t.Errorf("Shape symbol_kind = %v, want interface", iface.Props["symbol_kind"])
+ }
+ en, _ := findFact(ff, "a.Color")
+ if en.Props["symbol_kind"] != facts.SymbolEnum {
+ t.Errorf("Color symbol_kind = %v, want enum", en.Props["symbol_kind"])
+ }
+ rec, ok := findFact(ff, "a.Point")
+ if !ok || rec.Props["record"] != true {
+ t.Errorf("Point record fact missing or not marked record: %+v", rec.Props)
+ }
+}
+
+func TestExtract_ImplementsExtends(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "p/Animal.java": "package p;\npublic abstract class Animal {}\n",
+ "p/Pet.java": "package p;\npublic interface Pet {}\n",
+ "p/Dog.java": `package p;
+
+public class Dog extends Animal implements Pet {}
+`,
+ })
+
+ dog, ok := findFact(ff, "p.Dog")
+ if !ok {
+ t.Fatalf("missing Dog; got %v", names(ff))
+ }
+ // Same-package supertypes resolve to canonical "." names.
+ if !hasRelation(dog, facts.RelImplements, "p.Animal") {
+ t.Errorf("Dog should implement/extend p.Animal; got %+v", dog.Relations)
+ }
+ if !hasRelation(dog, facts.RelImplements, "p.Pet") {
+ t.Errorf("Dog should implement p.Pet; got %+v", dog.Relations)
+ }
+}
+
+func TestExtract_ImportsInternalVsExternal(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "app/svc/Service.java": `package app.svc;
+
+import app.data.Repo;
+import java.util.List;
+
+public class Service {
+ private Repo repo;
+}
+`,
+ "app/data/Repo.java": "package app.data;\npublic class Repo {}\n",
+ })
+
+ var internalOK, externalOK bool
+ for _, f := range factsByKind(ff, facts.KindDependency) {
+ switch f.Props["import"] {
+ case "app.data.Repo":
+ if f.Props["source"] == "internal" && hasRelation(f, facts.RelImports, "app/data") {
+ internalOK = true
+ }
+ case "java.util.List":
+ if f.Props["source"] == "external" {
+ externalOK = true
+ }
+ }
+ }
+ if !internalOK {
+ t.Error("app.data.Repo import should be internal and target module app/data")
+ }
+ if !externalOK {
+ t.Error("java.util.List import should be external")
+ }
+}
+
+func TestExtract_InstantiatesAndCalls(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "m/Widget.java": "package m;\npublic class Widget {}\n",
+ "m/Factory.java": `package m;
+
+public class Factory {
+ public Widget make() {
+ helper();
+ return new Widget();
+ }
+
+ private void helper() {}
+}
+`,
+ })
+
+ mk, ok := findFact(ff, "m.Factory.make")
+ if !ok {
+ t.Fatalf("missing Factory.make; got %v", names(ff))
+ }
+ if !hasRelation(mk, facts.RelInstantiates, "m.Widget") {
+ t.Errorf("make should instantiate m.Widget; got %+v", mk.Relations)
+ }
+ if !hasRelation(mk, facts.RelCalls, "m.Factory.helper") {
+ t.Errorf("make should call m.Factory.helper; got %+v", mk.Relations)
+ }
+}
+
+func TestExtract_JdkBuiltinsSuppressed(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "x/Foo.java": `package x;
+
+public class Foo {
+ public void run() {
+ String s = new String("hi");
+ Object o = new Object();
+ }
+}
+`,
+ })
+ run, _ := findFact(ff, "x.Foo.run")
+ for _, r := range run.Relations {
+ if r.Kind == facts.RelInstantiates && (r.Target == "String" || r.Target == "Object" ||
+ r.Target == "java.lang.String" || r.Target == "x.String") {
+ t.Errorf("java.lang type should not produce instantiate edge: %+v", r)
+ }
+ }
+}
+
+func TestExtract_NestedTypeQualified(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "n/Outer.java": `package n;
+
+public class Outer {
+ public static class Inner {
+ public void ping() {}
+ }
+}
+`,
+ })
+ if _, ok := findFact(ff, "n.Outer.Inner"); !ok {
+ t.Errorf("missing nested type n.Outer.Inner; got %v", names(ff))
+ }
+ if _, ok := findFact(ff, "n.Outer.Inner.ping"); !ok {
+ t.Errorf("missing nested method n.Outer.Inner.ping; got %v", names(ff))
+ }
+}
+
+func TestExtract_ModuleFacts(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "a/A.java": "package a;\npublic class A {}\n",
+ "b/B.java": "package b;\npublic class B {}\n",
+ })
+ for _, dir := range []string{"a", "b"} {
+ m, ok := findFactKind(ff, facts.KindModule, dir)
+ if !ok {
+ t.Errorf("missing module fact %q", dir)
+ continue
+ }
+ if m.Props["language"] != "java" {
+ t.Errorf("module %q language = %v", dir, m.Props["language"])
+ }
+ }
+}
+
+func names(ff []facts.Fact) []string {
+ var out []string
+ for _, f := range ff {
+ out = append(out, f.Kind+":"+f.Name)
+ }
+ return out
+}
diff --git a/internal/extractors/javaextractor/java_test.go b/internal/extractors/javaextractor/java_test.go
new file mode 100644
index 0000000..c312634
--- /dev/null
+++ b/internal/extractors/javaextractor/java_test.go
@@ -0,0 +1,48 @@
+package javaextractor
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDetect(t *testing.T) {
+ tests := []struct {
+ name string
+ setup map[string]string
+ want bool
+ }{
+ {"maven pom", map[string]string{"pom.xml": ""}, true},
+ {"gradle java", map[string]string{"build.gradle": "plugins {}", "src/main/java/A.java": "class A {}"}, true},
+ {"bare java source", map[string]string{"src/main/java/A.java": "class A {}"}, true},
+ {"gradle kotlin only", map[string]string{"build.gradle.kts": "plugins {}", "app/src/main/java/A.kt": "class A"}, false},
+ {"no java", map[string]string{"main.go": "package main"}, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ for rel, content := range tt.setup {
+ abs := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := New().Detect(dir)
+ if err != nil {
+ t.Fatalf("Detect: %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("Detect = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestName(t *testing.T) {
+ if got := New().Name(); got != "java" {
+ t.Errorf("Name = %q, want java", got)
+ }
+}
diff --git a/internal/extractors/javaextractor/spring.go b/internal/extractors/javaextractor/spring.go
new file mode 100644
index 0000000..aa62c88
--- /dev/null
+++ b/internal/extractors/javaextractor/spring.go
@@ -0,0 +1,342 @@
+package javaextractor
+
+import (
+ "strings"
+
+ "github.com/enola-labs/enola/internal/facts"
+ sitter "github.com/tree-sitter/go-tree-sitter"
+)
+
+// javaAnnotation is a parsed annotation with its simple name and (string-valued)
+// arguments. Non-string argument values (e.g. RequestMethod.GET) are kept as their
+// trailing identifier.
+type javaAnnotation struct {
+ name string // simple name, e.g. "RequestMapping"
+ positional []string // positional argument values, e.g. @GetMapping("/x")
+ named map[string]string // key=value arguments, e.g. value="/x", method="GET"
+}
+
+// parseAnnotations extracts the annotations from a `modifiers` node.
+func parseAnnotations(modifiers *sitter.Node, src []byte) []javaAnnotation {
+ if modifiers == nil {
+ return nil
+ }
+ var out []javaAnnotation
+ for i := uint(0); i < uint(modifiers.ChildCount()); i++ {
+ c := modifiers.Child(i)
+ switch c.Kind() {
+ case "marker_annotation":
+ if n := annotationSimpleName(c, src); n != "" {
+ out = append(out, javaAnnotation{name: n})
+ }
+ case "annotation":
+ ann := javaAnnotation{name: annotationSimpleName(c, src), named: map[string]string{}}
+ if args := findChildByKind(c, "annotation_argument_list"); args != nil {
+ parseAnnotationArgs(args, src, &ann)
+ }
+ if ann.name != "" {
+ out = append(out, ann)
+ }
+ }
+ }
+ return out
+}
+
+func annotationSimpleName(node *sitter.Node, src []byte) string {
+ nameNode := node.ChildByFieldName("name")
+ if nameNode == nil {
+ return ""
+ }
+ return lastTypeComponent(nodeText(nameNode, src))
+}
+
+func parseAnnotationArgs(args *sitter.Node, src []byte, ann *javaAnnotation) {
+ for i := uint(0); i < uint(args.ChildCount()); i++ {
+ c := args.Child(i)
+ switch c.Kind() {
+ case "element_value_pair":
+ key := c.ChildByFieldName("key")
+ val := c.ChildByFieldName("value")
+ if key != nil && val != nil {
+ ann.named[nodeText(key, src)] = annotationValue(val, src)
+ }
+ case "string_literal", "field_access", "identifier", "element_value_array_initializer", "scoped_identifier":
+ ann.positional = append(ann.positional, annotationValue(c, src))
+ }
+ }
+}
+
+// annotationValue renders an annotation argument value as a string: string literals
+// are unquoted; everything else keeps its trailing identifier (so RequestMethod.GET
+// becomes "GET").
+func annotationValue(node *sitter.Node, src []byte) string {
+ switch node.Kind() {
+ case "string_literal":
+ return strings.Trim(nodeText(node, src), `"`)
+ case "field_access", "scoped_identifier":
+ return lastTypeComponent(nodeText(node, src))
+ case "element_value_array_initializer", "array_initializer":
+ // Join the trailing identifiers of each element, e.g. {GET, POST}.
+ var parts []string
+ for i := uint(0); i < uint(node.ChildCount()); i++ {
+ c := node.Child(i)
+ if c.IsNamed() {
+ parts = append(parts, annotationValue(c, src))
+ }
+ }
+ return strings.Join(parts, ",")
+ default:
+ return strings.TrimSpace(nodeText(node, src))
+ }
+}
+
+func hasAnnotation(anns []javaAnnotation, names ...string) bool {
+ for _, a := range anns {
+ for _, n := range names {
+ if a.name == n {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func findAnnotation(anns []javaAnnotation, name string) *javaAnnotation {
+ for i := range anns {
+ if anns[i].name == name {
+ return &anns[i]
+ }
+ }
+ return nil
+}
+
+// --- component classification ---
+
+// classifyComponent tags a type symbol fact with framework/component props for
+// Spring stereotypes and Dubbo SPI. It mutates f.Props in place.
+func classifyComponent(f *facts.Fact, name string, annotations []javaAnnotation, supertypes []string) {
+ switch {
+ case hasAnnotation(annotations, "RestController"):
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "controller"
+ case hasAnnotation(annotations, "Controller"):
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "controller"
+ case hasAnnotation(annotations, "Service"):
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "service"
+ case hasAnnotation(annotations, "Repository"):
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "repository"
+ case hasAnnotation(annotations, "Configuration"):
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "configuration"
+ case hasAnnotation(annotations, "Component"):
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "component"
+ }
+
+ // Dubbo SPI extension mechanism.
+ if hasAnnotation(annotations, "SPI") {
+ f.Props["framework"] = "dubbo"
+ f.Props["dubbo_spi"] = true
+ }
+ if hasAnnotation(annotations, "Activate") {
+ f.Props["dubbo_activate"] = true
+ if f.Props["framework"] == nil {
+ f.Props["framework"] = "dubbo"
+ }
+ }
+ if hasAnnotation(annotations, "DubboService") {
+ f.Props["framework"] = "dubbo"
+ f.Props["component"] = "service"
+ }
+
+ // Spring Data repository interface (extends JpaRepository/CrudRepository/...).
+ if isSpringDataRepository(supertypes) {
+ f.Props["framework"] = "spring"
+ f.Props["component"] = "repository"
+ }
+}
+
+func isSpringDataRepository(supertypes []string) bool {
+ for _, s := range supertypes {
+ switch s {
+ case "JpaRepository", "CrudRepository", "PagingAndSortingRepository",
+ "ReactiveCrudRepository", "MongoRepository", "Repository":
+ return true
+ }
+ }
+ return false
+}
+
+// --- JPA / storage ---
+
+// detectJpaStorage emits a KindStorage fact for JPA entities, Spring @Repository
+// classes, and Spring Data repository interfaces.
+func detectJpaStorage(name string, annotations []javaAnnotation, relFile string, line int, dir string) *facts.Fact {
+ var storageKind, framework string
+ switch {
+ case hasAnnotation(annotations, "Entity"):
+ storageKind, framework = "entity", "jpa"
+ case hasAnnotation(annotations, "Repository"):
+ storageKind, framework = "repository", "spring-data"
+ default:
+ return nil
+ }
+
+ f := &facts.Fact{
+ Kind: facts.KindStorage,
+ Name: dir + "." + name,
+ File: relFile,
+ Line: line,
+ Props: map[string]any{
+ "storage_kind": storageKind,
+ "language": "java",
+ "framework": framework,
+ },
+ Relations: []facts.Relation{
+ {Kind: facts.RelDeclares, Target: dir},
+ },
+ }
+ // @Table(name="...") or @Entity(name="...") → table name.
+ if t := findAnnotation(annotations, "Table"); t != nil {
+ if tn := t.named["name"]; tn != "" {
+ f.Props["table"] = tn
+ } else if len(t.positional) > 0 {
+ f.Props["table"] = t.positional[0]
+ }
+ }
+ return f
+}
+
+// --- Spring MVC routes ---
+
+func isSpringController(annotations []javaAnnotation) bool {
+ return hasAnnotation(annotations, "RestController", "Controller")
+}
+
+// requestMappingPath returns the base path declared by a class-level @RequestMapping
+// (its value/path argument), or "" when absent.
+func requestMappingPath(annotations []javaAnnotation) string {
+ rm := findAnnotation(annotations, "RequestMapping")
+ if rm == nil {
+ return ""
+ }
+ return mappingPath(rm)
+}
+
+// mappingMethods maps a method-level mapping annotation to its HTTP verb(s).
+var mappingMethods = map[string]string{
+ "GetMapping": "GET",
+ "PostMapping": "POST",
+ "PutMapping": "PUT",
+ "DeleteMapping": "DELETE",
+ "PatchMapping": "PATCH",
+}
+
+// springRouteFacts emits a KindRoute fact for each HTTP method a controller method
+// handles, combining the class base path with the method-level mapping path.
+func springRouteFacts(basePath string, methodAnns []javaAnnotation, relFile string, line int, dir, handler string) []facts.Fact {
+ var out []facts.Fact
+ for _, a := range methodAnns {
+ var methods []string
+ var sub string
+ if verb, ok := mappingMethods[a.name]; ok {
+ methods = []string{verb}
+ sub = mappingPath(&a)
+ } else if a.name == "RequestMapping" {
+ methods = requestMappingVerbs(&a)
+ sub = mappingPath(&a)
+ } else {
+ continue
+ }
+ full := joinRoutePath(basePath, sub)
+ for _, m := range methods {
+ out = append(out, facts.Fact{
+ Kind: facts.KindRoute,
+ Name: full,
+ File: relFile,
+ Line: line,
+ Props: map[string]any{
+ "method": m,
+ "framework": "spring",
+ "language": "java",
+ "handler": handler,
+ },
+ Relations: []facts.Relation{
+ {Kind: facts.RelDeclares, Target: dir},
+ },
+ })
+ }
+ }
+ return out
+}
+
+// mappingPath extracts the path from a mapping annotation: a positional value,
+// value=, or path= argument.
+func mappingPath(a *javaAnnotation) string {
+ if len(a.positional) > 0 {
+ return firstPath(a.positional[0])
+ }
+ if v := a.named["value"]; v != "" {
+ return firstPath(v)
+ }
+ if v := a.named["path"]; v != "" {
+ return firstPath(v)
+ }
+ return ""
+}
+
+// firstPath returns the first entry of a possibly comma-joined path list.
+func firstPath(s string) string {
+ if i := strings.IndexByte(s, ','); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+// requestMappingVerbs returns the HTTP methods named by a @RequestMapping's
+// method= argument, defaulting to "ALL" when unspecified.
+func requestMappingVerbs(a *javaAnnotation) []string {
+ m := a.named["method"]
+ if m == "" {
+ return []string{"ALL"}
+ }
+ var out []string
+ for _, part := range strings.Split(m, ",") {
+ if v := strings.TrimSpace(part); v != "" {
+ out = append(out, v)
+ }
+ }
+ if len(out) == 0 {
+ return []string{"ALL"}
+ }
+ return out
+}
+
+func joinRoutePath(base, sub string) string {
+ base = strings.TrimSpace(base)
+ sub = strings.TrimSpace(sub)
+ switch {
+ case base == "":
+ if sub == "" {
+ return "/"
+ }
+ return ensureLeadingSlash(sub)
+ case sub == "":
+ return ensureLeadingSlash(base)
+ default:
+ return ensureLeadingSlash(strings.TrimSuffix(base, "/")) + ensureLeadingSlash(sub)
+ }
+}
+
+func ensureLeadingSlash(s string) string {
+ if s == "" {
+ return ""
+ }
+ if strings.HasPrefix(s, "/") {
+ return s
+ }
+ return "/" + s
+}
diff --git a/internal/extractors/javaextractor/spring_test.go b/internal/extractors/javaextractor/spring_test.go
new file mode 100644
index 0000000..fe03580
--- /dev/null
+++ b/internal/extractors/javaextractor/spring_test.go
@@ -0,0 +1,259 @@
+package javaextractor
+
+import (
+ "testing"
+
+ "github.com/enola-labs/enola/internal/facts"
+)
+
+func TestSpring_Routes(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "web/EdqsController.java": `package web;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/edqs")
+public class EdqsController {
+
+ @GetMapping("/ready")
+ public void isReady() {}
+
+ @PostMapping("/reset")
+ public void reset() {}
+}
+`,
+ })
+
+ routes := factsByKind(ff, facts.KindRoute)
+ if len(routes) != 2 {
+ t.Fatalf("want 2 routes, got %d: %v", len(routes), names(ff))
+ }
+
+ want := map[string]string{
+ "/api/edqs/ready": "GET",
+ "/api/edqs/reset": "POST",
+ }
+ for _, r := range routes {
+ method, ok := want[r.Name]
+ if !ok {
+ t.Errorf("unexpected route %q", r.Name)
+ continue
+ }
+ if r.Props["method"] != method {
+ t.Errorf("route %q method = %v, want %v", r.Name, r.Props["method"], method)
+ }
+ if r.Props["framework"] != "spring" {
+ t.Errorf("route %q framework = %v", r.Name, r.Props["framework"])
+ }
+ if r.Props["handler"] == "" {
+ t.Errorf("route %q missing handler", r.Name)
+ }
+ }
+}
+
+func TestSpring_RequestMappingWithMethod(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "web/UserController.java": `package web;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class UserController {
+
+ @RequestMapping(value = "/users", method = RequestMethod.GET)
+ public void list() {}
+}
+`,
+ })
+ r, ok := findFactKind(ff, facts.KindRoute, "/users")
+ if !ok {
+ t.Fatalf("missing /users route; got %v", names(ff))
+ }
+ if r.Props["method"] != "GET" {
+ t.Errorf("method = %v, want GET", r.Props["method"])
+ }
+}
+
+func TestSpring_Components(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "svc/UserService.java": `package svc;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserService {}
+`,
+ })
+ s, _ := findFact(ff, "svc.UserService")
+ if s.Props["framework"] != "spring" || s.Props["component"] != "service" {
+ t.Errorf("UserService component props = %+v", s.Props)
+ }
+}
+
+func TestSpring_JpaEntity(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "model/User.java": `package model;
+
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+@Entity
+@Table(name = "users")
+public class User {}
+`,
+ })
+ st, ok := findFactKind(ff, facts.KindStorage, "model.User")
+ if !ok {
+ t.Fatalf("missing storage fact for @Entity; got %v", names(ff))
+ }
+ if st.Props["storage_kind"] != "entity" {
+ t.Errorf("storage_kind = %v, want entity", st.Props["storage_kind"])
+ }
+ if st.Props["table"] != "users" {
+ t.Errorf("table = %v, want users", st.Props["table"])
+ }
+}
+
+func TestSpring_JpaTableConstantResolved(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "model/ModelConstants.java": `package model;
+
+public class ModelConstants {
+ public static final String ADMIN_SETTINGS_TABLE_NAME = "admin_settings";
+}
+`,
+ "model/AdminSettingsEntity.java": `package model;
+
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import static model.ModelConstants.ADMIN_SETTINGS_TABLE_NAME;
+
+@Entity
+@Table(name = ADMIN_SETTINGS_TABLE_NAME)
+public class AdminSettingsEntity {}
+`,
+ })
+ st, ok := findFactKind(ff, facts.KindStorage, "model.AdminSettingsEntity")
+ if !ok {
+ t.Fatalf("missing storage fact; got %v", names(ff))
+ }
+ if st.Props["table"] != "admin_settings" {
+ t.Errorf("table = %v, want admin_settings (resolved from constant)", st.Props["table"])
+ }
+ if st.Props["table_constant"] != "ADMIN_SETTINGS_TABLE_NAME" {
+ t.Errorf("table_constant = %v, want ADMIN_SETTINGS_TABLE_NAME", st.Props["table_constant"])
+ }
+}
+
+func TestSpring_DataRepository(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "repo/UserRepository.java": `package repo;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserRepository extends JpaRepository {}
+`,
+ })
+ s, _ := findFact(ff, "repo.UserRepository")
+ if s.Props["component"] != "repository" {
+ t.Errorf("UserRepository component = %v, want repository", s.Props["component"])
+ }
+}
+
+func TestSpring_ConstructorInjection(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "svc/Repo.java": "package svc;\npublic class Repo {}\n",
+ "svc/Handler.java": `package svc;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class Handler {
+ private final Repo repo;
+
+ public Handler(Repo repo) {
+ this.repo = repo;
+ }
+}
+`,
+ })
+ h, _ := findFact(ff, "svc.Handler")
+ if !hasRelation(h, facts.RelInjects, "svc.Repo") {
+ t.Errorf("Handler should inject svc.Repo; got %+v", h.Relations)
+ }
+}
+
+func TestSpring_FieldInjection(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "svc/Dep.java": "package svc;\npublic class Dep {}\n",
+ "svc/Svc.java": `package svc;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+@Service
+public class Svc {
+ @Autowired
+ private Dep dep;
+}
+`,
+ })
+ s, _ := findFact(ff, "svc.Svc")
+ if !hasRelation(s, facts.RelInjects, "svc.Dep") {
+ t.Errorf("Svc should inject svc.Dep via @Autowired field; got %+v", s.Relations)
+ }
+}
+
+func TestSpring_LombokRequiredArgsConstructor(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "svc/Store.java": "package svc;\npublic class Store {}\n",
+ "svc/Worker.java": `package svc;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class Worker {
+ private final Store store;
+}
+`,
+ })
+ w, _ := findFact(ff, "svc.Worker")
+ if !hasRelation(w, facts.RelInjects, "svc.Store") {
+ t.Errorf("Worker should inject svc.Store via @RequiredArgsConstructor; got %+v", w.Relations)
+ }
+}
+
+func TestDubbo_SPI(t *testing.T) {
+ ff := extractAll(t, map[string]string{
+ "ext/Protocol.java": `package ext;
+
+import org.apache.dubbo.common.extension.SPI;
+
+@SPI
+public interface Protocol {}
+`,
+ "ext/DubboProtocol.java": `package ext;
+
+import org.apache.dubbo.common.extension.Activate;
+
+@Activate
+public class DubboProtocol implements Protocol {}
+`,
+ })
+ spi, _ := findFact(ff, "ext.Protocol")
+ if spi.Props["framework"] != "dubbo" || spi.Props["dubbo_spi"] != true {
+ t.Errorf("Protocol dubbo props = %+v", spi.Props)
+ }
+ act, _ := findFact(ff, "ext.DubboProtocol")
+ if act.Props["dubbo_activate"] != true {
+ t.Errorf("DubboProtocol should be marked dubbo_activate; got %+v", act.Props)
+ }
+}
diff --git a/mcp-arch.yaml b/mcp-arch.yaml
index 1331822..c260d47 100644
--- a/mcp-arch.yaml
+++ b/mcp-arch.yaml
@@ -68,6 +68,7 @@ ignore:
extractors:
- cpp
- go
+ - java
- kotlin
- openapi
- python
diff --git a/pkg/bootstrap/bootstrap.go b/pkg/bootstrap/bootstrap.go
index 048f778..ff2f4dc 100644
--- a/pkg/bootstrap/bootstrap.go
+++ b/pkg/bootstrap/bootstrap.go
@@ -15,6 +15,7 @@ import (
"github.com/enola-labs/enola/internal/explainers/layers"
"github.com/enola-labs/enola/internal/extractors/cppextractor"
"github.com/enola-labs/enola/internal/extractors/goextractor"
+ "github.com/enola-labs/enola/internal/extractors/javaextractor"
"github.com/enola-labs/enola/internal/extractors/kotlinextractor"
"github.com/enola-labs/enola/internal/extractors/openapiextractor"
"github.com/enola-labs/enola/internal/extractors/pythonextractor"
@@ -155,6 +156,7 @@ func NewEngine(opts Options) (*Engine, *config.Config, error) {
// Register all OSS extractors
eng.RegisterExtractor(cppextractor.New())
eng.RegisterExtractor(goextractor.New())
+ eng.RegisterExtractor(javaextractor.New())
eng.RegisterExtractor(kotlinextractor.New())
eng.RegisterExtractor(openapiextractor.New())
eng.RegisterExtractor(pythonextractor.New())