Skip to content

Commit 7a010e2

Browse files
authored
Adding and documenting Java support (#32)
1 parent 5a73928 commit 7a010e2

13 files changed

Lines changed: 2056 additions & 6 deletions

File tree

ARCHITECTURE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ Repository
108108
109109
110110
File Walker ──▶ Extractors ──▶ Fact Store ──▶ Cross-Repo Linker ──▶ Graph Index
111-
(apply (Go, Kotlin, (indexed by (only with 2+ (bidirectional)
112-
ignore Python, TS, kind / file / repos loaded) │
113-
globs) Swift, Ruby, name / repo) ▼
114-
C++, OpenAPI) Explainers
111+
(apply (Go, Java, (indexed by (only with 2+ (bidirectional)
112+
ignore Kotlin, Python, kind / file / repos loaded) │
113+
globs) TS, Swift, name / repo) ▼
114+
Ruby, C++, OpenAPI) Explainers
115115
(cycles, layers,
116116
crossrepo)
117117
@@ -299,6 +299,7 @@ ignore:
299299
- "**/*.yaml"
300300
extractors:
301301
- go
302+
- java
302303
- kotlin
303304
- openapi
304305
- python
@@ -322,7 +323,7 @@ The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list (
322323
|-------|-------------|---------|
323324
| `repo` | Repository root path | `"."` |
324325
| `ignore` | Glob patterns for files/dirs to skip | vendor, node_modules, .git, tests, build dirs, docs, config data, … |
325-
| `extractors` | Enabled extractors | `["cpp", "go", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` |
326+
| `extractors` | Enabled extractors | `["cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` |
326327
| `explainers` | Enabled explainers | `["cycles", "layers", "crossrepo"]` |
327328
| `renderers` | Enabled renderers | `["llm_context"]` |
328329
| `output.dir` | Output directory for artifacts | `".enola"` |
@@ -337,6 +338,7 @@ Each extractor is detected by characteristic project files and then parses what
337338
| Language | Parser | Detected by |
338339
|------------|------------------|-------------|
339340
| Go | `go/ast` | `go.mod` present |
341+
| 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) |
340342
| Kotlin | tree-sitter | `build.gradle.kts` / `build.gradle` with Kotlin/Android |
341343
| 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) |
342344
| 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
351353

352354
**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.
353355

356+
**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 `<dir>.<Type>` / `<dir>.<Type>.<method>` 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.
357+
354358
**Kotlin** is Android-aware: it detects Jetpack Compose (`@Composable`), Hilt DI (`@HiltViewModel`, `@Module`, `@AndroidEntryPoint`), Room (`@Entity`, `@Dao`, `@Database`), ViewModels, Repositories, Use Cases, and Workers.
355359

356360
**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`.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ Working across several repos? Generate the first, then add the rest with append
153153
| Language | Detected by |
154154
|------------|-------------|
155155
| Go | `go.mod` |
156+
| Java | `pom.xml` (Maven) or `.java` sources (Spring routes / JPA / Lombok DI / Dubbo SPI aware) |
156157
| TypeScript | `tsconfig.json` / `package.json` with TypeScript (Next.js & monorepo aware) |
157158
| Python | `pyproject.toml`, `requirements.txt`, `setup.py`, … (FastAPI / Django / SQLAlchemy aware) |
158159
| Kotlin | `build.gradle(.kts)` with Kotlin/Android (Compose / Hilt / Room aware) |

examples/full.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#
77
# Supported extractors:
88
# - go (detection: go.mod)
9+
# - java (detection: pom.xml, build.gradle, or .java sources)
910
# - kotlin (detection: build.gradle.kts or build.gradle with Kotlin/Android)
1011
# - typescript (detection: tsconfig.json or package.json with TypeScript)
1112
# - swift (detection: Package.swift, .xcodeproj, or .xcworkspace)
@@ -86,6 +87,7 @@ ignore:
8687
extractors:
8788
- cpp
8889
- go
90+
- java
8991
- kotlin
9092
- typescript
9193
- swift

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0
88
github.com/tree-sitter/go-tree-sitter v0.24.0
99
github.com/tree-sitter/tree-sitter-cpp v0.22.4-0.20240818224355-b1a4e2b25148
10+
github.com/tree-sitter/tree-sitter-java v0.21.1-0.20240824015150-576d8097e495
1011
github.com/tree-sitter/tree-sitter-python v0.23.6
1112
github.com/tree-sitter/tree-sitter-typescript v0.23.2
1213
gopkg.in/yaml.v3 v3.0.1

internal/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func Default() *Config {
5757
"**/Pods/**",
5858
"**/.gradle/**",
5959
},
60-
Extractors: []string{"cpp", "go", "kotlin", "openapi", "python", "typescript", "swift", "ruby"},
60+
Extractors: []string{"cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"},
6161
Explainers: []string{"cycles", "layers", "crossrepo"},
6262
Renderers: []string{"llm_context"},
6363
Output: OutputConfig{
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package javaextractor
2+
3+
import (
4+
"context"
5+
"log"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/enola-labs/enola/internal/facts"
11+
)
12+
13+
// JavaExtractor extracts architectural facts from Java source code using
14+
// tree-sitter AST parsing (see java_ast.go for the walker and spring.go for
15+
// Spring/JPA/Dubbo framework specialization).
16+
type JavaExtractor struct{}
17+
18+
// New creates a new JavaExtractor.
19+
func New() *JavaExtractor {
20+
return &JavaExtractor{}
21+
}
22+
23+
func (e *JavaExtractor) Name() string {
24+
return "java"
25+
}
26+
27+
// Detect returns true if the repository looks like a Java project: a Maven project
28+
// (pom.xml), or any actual .java source file. A Gradle build file alone is not
29+
// sufficient — Gradle is equally used by Kotlin, Android, and Groovy projects, so
30+
// detecting on it would wrongly claim pure-Kotlin repos. Requiring real .java
31+
// sources keeps the Java extractor off non-Java JVM projects.
32+
func (e *JavaExtractor) Detect(repoPath string) (bool, error) {
33+
if _, err := os.Stat(filepath.Join(repoPath, "pom.xml")); err == nil {
34+
return true, nil
35+
}
36+
return containsJavaSource(repoPath, 8), nil
37+
}
38+
39+
// Extract parses Java files and emits architectural facts.
40+
//
41+
// Two passes: pass 1 walks each file's AST (extractFileAST) to emit declaration,
42+
// import, route, storage and call-graph facts while indexing every declared type by
43+
// its fully-qualified name. Pass 2 (canonicalizeTargets) rewrites type-reference
44+
// edge targets (implements/instantiates/injects) and import targets from FQNs to
45+
// canonical "<dir>.<Type>" / module-dir names so reverse traversal connects
46+
// dependents. Module facts are emitted per directory.
47+
func (e *JavaExtractor) Extract(ctx context.Context, repoPath string, files []string) ([]facts.Fact, error) {
48+
var allFacts []facts.Fact
49+
modules := make(map[string]bool)
50+
51+
for _, relFile := range files {
52+
select {
53+
case <-ctx.Done():
54+
return allFacts, ctx.Err()
55+
default:
56+
}
57+
58+
if !isJavaFile(relFile) {
59+
continue
60+
}
61+
62+
absFile := filepath.Join(repoPath, relFile)
63+
src, err := os.ReadFile(absFile)
64+
if err != nil {
65+
log.Printf("[java-extractor] error reading %s: %v", relFile, err)
66+
continue
67+
}
68+
69+
allFacts = append(allFacts, extractFileAST(src, relFile)...)
70+
modules[filepath.Dir(relFile)] = true
71+
}
72+
73+
canonicalizeTargets(allFacts)
74+
resolveTableConstants(allFacts)
75+
76+
for dir := range modules {
77+
allFacts = append(allFacts, facts.Fact{
78+
Kind: facts.KindModule,
79+
Name: dir,
80+
File: dir,
81+
Props: map[string]any{
82+
"language": "java",
83+
},
84+
})
85+
}
86+
87+
return allFacts, nil
88+
}
89+
90+
// canonicalizeTargets resolves FQN-based edge targets to canonical fact names.
91+
//
92+
// - implements/instantiates/injects targets that match a declared type's FQN are
93+
// rewritten to that type's "<dir>.<Type>" fact name; unresolved targets (external
94+
// libraries) are left as written.
95+
// - import dependency facts whose target FQN resolves to a declared type — or whose
96+
// value names a known source package — are marked source="internal" and pointed at
97+
// the owning module dir.
98+
func canonicalizeTargets(allFacts []facts.Fact) {
99+
typeIndex := make(map[string]string) // FQN -> "<dir>.<Type>" canonical name
100+
typeDir := make(map[string]string) // FQN -> dir
101+
packageDir := make(map[string]string)
102+
for _, f := range allFacts {
103+
if f.Kind != facts.KindSymbol {
104+
continue
105+
}
106+
switch f.Props["symbol_kind"] {
107+
case facts.SymbolClass, facts.SymbolInterface, facts.SymbolEnum:
108+
fqn, _ := f.Props["fqn"].(string)
109+
if fqn == "" {
110+
continue
111+
}
112+
dir := f.File
113+
if i := strings.LastIndex(dir, "/"); i >= 0 {
114+
dir = dir[:i]
115+
} else {
116+
dir = "."
117+
}
118+
typeIndex[fqn] = f.Name
119+
typeDir[fqn] = dir
120+
if pkg := parentName(fqn); pkg != "" {
121+
packageDir[pkg] = dir
122+
}
123+
}
124+
}
125+
126+
for i := range allFacts {
127+
f := &allFacts[i]
128+
if f.Kind == facts.KindDependency {
129+
resolveImport(f, typeDir, packageDir)
130+
continue
131+
}
132+
for j := range f.Relations {
133+
r := &f.Relations[j]
134+
switch r.Kind {
135+
case facts.RelImplements, facts.RelInstantiates, facts.RelInjects:
136+
if canon, ok := typeIndex[r.Target]; ok {
137+
r.Target = canon
138+
}
139+
}
140+
}
141+
}
142+
}
143+
144+
func resolveImport(f *facts.Fact, typeDir, packageDir map[string]string) {
145+
imp, _ := f.Props["import"].(string)
146+
if imp == "" {
147+
return
148+
}
149+
var dir string
150+
var ok bool
151+
if dir, ok = typeDir[imp]; !ok {
152+
// Wildcard / package import (e.g. "com.example.foo").
153+
dir, ok = packageDir[imp]
154+
}
155+
if !ok {
156+
return // external dependency
157+
}
158+
f.Props["source"] = "internal"
159+
for j := range f.Relations {
160+
if f.Relations[j].Kind == facts.RelImports {
161+
f.Relations[j].Target = dir
162+
}
163+
}
164+
}
165+
166+
// resolveTableConstants rewrites storage facts whose "table" prop names a string
167+
// constant (e.g. @Table(name = ADMIN_SETTINGS_TABLE_NAME)) to that constant's
168+
// literal value. Constants are indexed by simple name across all files, since the
169+
// table-name constants typically live in a shared ModelConstants class. When the
170+
// same simple name maps to conflicting values it is left unresolved (ambiguous).
171+
func resolveTableConstants(allFacts []facts.Fact) {
172+
values := make(map[string]string)
173+
ambiguous := make(map[string]bool)
174+
for _, f := range allFacts {
175+
if f.Kind != facts.KindSymbol {
176+
continue
177+
}
178+
v, ok := f.Props["value"].(string)
179+
if !ok {
180+
continue
181+
}
182+
simple := f.Name
183+
if i := strings.LastIndex(simple, "."); i >= 0 {
184+
simple = simple[i+1:]
185+
}
186+
if existing, seen := values[simple]; seen && existing != v {
187+
ambiguous[simple] = true
188+
continue
189+
}
190+
values[simple] = v
191+
}
192+
193+
for i := range allFacts {
194+
f := &allFacts[i]
195+
if f.Kind != facts.KindStorage {
196+
continue
197+
}
198+
tbl, ok := f.Props["table"].(string)
199+
if !ok {
200+
continue
201+
}
202+
if ambiguous[tbl] {
203+
continue
204+
}
205+
if v, ok := values[tbl]; ok {
206+
f.Props["table"] = v
207+
f.Props["table_constant"] = tbl
208+
}
209+
}
210+
}
211+
212+
func parentName(fqn string) string {
213+
if i := strings.LastIndex(fqn, "."); i >= 0 {
214+
return fqn[:i]
215+
}
216+
return ""
217+
}
218+
219+
func isJavaFile(path string) bool {
220+
return strings.HasSuffix(strings.ToLower(path), ".java")
221+
}
222+
223+
// containsJavaSource reports whether any .java file exists under root within
224+
// maxDepth directory levels. It returns on the first match and skips hidden and
225+
// common build/dependency directories so it stays cheap on large repos.
226+
func containsJavaSource(root string, maxDepth int) bool {
227+
var search func(dir string, depth int) bool
228+
search = func(dir string, depth int) bool {
229+
if depth > maxDepth {
230+
return false
231+
}
232+
entries, err := os.ReadDir(dir)
233+
if err != nil {
234+
return false
235+
}
236+
for _, entry := range entries {
237+
name := entry.Name()
238+
if entry.IsDir() {
239+
if strings.HasPrefix(name, ".") || name == "build" ||
240+
name == "target" || name == "node_modules" {
241+
continue
242+
}
243+
if search(filepath.Join(dir, name), depth+1) {
244+
return true
245+
}
246+
} else if isJavaFile(name) {
247+
return true
248+
}
249+
}
250+
return false
251+
}
252+
return search(root, 0)
253+
}

0 commit comments

Comments
 (0)