Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -299,6 +299,7 @@ ignore:
- "**/*.yaml"
extractors:
- go
- java
- kotlin
- openapi
- python
Expand All @@ -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"` |
Expand All @@ -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) |
Expand All @@ -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 `<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.

**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`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions examples/full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -86,6 +87,7 @@ ignore:
extractors:
- cpp
- go
- java
- kotlin
- typescript
- swift
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
253 changes: 253 additions & 0 deletions internal/extractors/javaextractor/java.go
Original file line number Diff line number Diff line change
@@ -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 "<dir>.<Type>" / 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 "<dir>.<Type>" 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 -> "<dir>.<Type>" 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)
}
Loading
Loading