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
46 changes: 43 additions & 3 deletions cmd/enola/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"os"
"path/filepath"

"github.com/enola-labs/enola/internal/config"
"github.com/enola-labs/enola/pkg/bootstrap"
"github.com/enola-labs/enola/pkg/explain"
)

func main() {
Expand All @@ -16,12 +18,24 @@ func main() {
ctx := context.Background()

generateMode := false
explainMode := false
cfgPath := "mcp-arch.yaml"
explainRepo := "" // optional positional repo path for --explain

for _, arg := range os.Args[1:] {
if arg == "--generate" {
switch arg {
case "--generate":
generateMode = true
} else {
cfgPath = arg
case "--explain":
explainMode = true
default:
// In --explain mode the positional argument is the repository path;
// otherwise it is the config file path.
if explainMode {
explainRepo = arg
} else {
cfgPath = arg
}
}
}

Expand All @@ -32,6 +46,11 @@ func main() {
log.Fatalf("failed to create engine: %v", err)
}

if explainMode {
runExplain(ctx, eng, cfg, explainRepo)
os.Exit(0)
}

if generateMode {
repoPath, err := filepath.Abs(cfg.Repo)
if err != nil {
Expand Down Expand Up @@ -68,3 +87,24 @@ func main() {
log.Fatalf("server error: %v", err)
}
}

// runExplain indexes the given repository (defaulting to the configured repo)
// and prints a human-readable statistical summary to stdout.
func runExplain(ctx context.Context, eng *bootstrap.Engine, cfg *config.Config, repoArg string) {
repo := repoArg
if repo == "" {
repo = cfg.Repo
}
repoPath, err := filepath.Abs(repo)
if err != nil {
log.Fatalf("failed to resolve repo path: %v", err)
}

fmt.Fprintf(os.Stderr, "Analyzing %s …\n", repoPath)
if _, err := eng.GenerateSnapshot(ctx, repoPath, false); err != nil {
log.Fatalf("snapshot generation failed: %v", err)
}

report := explain.Compute(eng)
fmt.Print(report.Render())
}
15 changes: 15 additions & 0 deletions internal/extractors/javaextractor/java.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,21 @@ func resolveImport(f *facts.Fact, typeDir, packageDir map[string]string) {
// Wildcard / package import (e.g. "com.example.foo").
dir, ok = packageDir[imp]
}
if !ok {
// Parent-FQN fallback for static-member imports
// ("com.foo.Constants.MAX" -> declaring type "com.foo.Constants") and
// imports of internal types we didn't index ("com.foo.Bar" -> package
// "com.foo"). Skipped for wildcards, whose import string is already the
// package — walking to the grandparent would mis-resolve. Only our own
// types/packages are in the indices, so this never flags an external import.
if wc, _ := f.Props["wildcard"].(bool); !wc {
if parent := parentName(imp); parent != "" {
if dir, ok = typeDir[parent]; !ok {
dir, ok = packageDir[parent]
}
}
}
}
if !ok {
return // external dependency
}
Expand Down
37 changes: 24 additions & 13 deletions internal/extractors/javaextractor/java_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,27 @@ func (w *astWalker) handleImport(node *sitter.Node) {
}
importPath := nodeText(pathNode, w.src)

props := map[string]any{
"language": "java",
"import": importPath,
"source": "external", // refined to "internal" in canonicalizeTargets
}
// Mark the import shape so resolveImport can apply the parent-FQN fallback to
// static-member / un-indexed-type imports but NOT to wildcards (whose import
// string is already the package — walking to the grandparent would mis-resolve).
if isStatic {
props["static"] = true
}
if isWildcard {
props["wildcard"] = true
}

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
},
Kind: facts.KindDependency,
Name: w.dir + " -> " + importPath,
File: w.relFile,
Line: int(node.StartPosition().Row) + 1,
Props: props,
Relations: []facts.Relation{
{Kind: facts.RelImports, Target: importPath},
},
Expand Down Expand Up @@ -421,10 +432,10 @@ func (w *astWalker) handleField(node *sitter.Node, owner *facts.Fact) {
}
}
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,
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},
Expand Down
99 changes: 96 additions & 3 deletions internal/extractors/javaextractor/java_ast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,9 @@ public class Order {

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",
"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")
Expand Down Expand Up @@ -201,6 +201,99 @@ public class Service {
}
}

// TestExtract_StaticImportResolvesInternal covers the parent-FQN fallback:
// a static member import names the member, not the type, so the declaring type's
// FQN is the parent of the import string.
func TestExtract_StaticImportResolvesInternal(t *testing.T) {
ff := extractAll(t, map[string]string{
"app/svc/Service.java": `package app.svc;

import static app.data.Constants.MAX;

public class Service {
int v = MAX;
}
`,
"app/data/Constants.java": "package app.data;\npublic class Constants { public static final int MAX = 1; }\n",
})

var ok bool
for _, f := range factsByKind(ff, facts.KindDependency) {
if f.Props["import"] == "app.data.Constants.MAX" {
if f.Props["source"] == "internal" && hasRelation(f, facts.RelImports, "app/data") {
ok = true
}
}
}
if !ok {
t.Error("static import app.data.Constants.MAX should resolve internal to module app/data")
}
}

// TestExtract_UnindexedTypeResolvesViaPackage covers the second fallback branch:
// an imported type we didn't index as a top-level class still resolves to its
// package's module dir, because the package is internal.
func TestExtract_UnindexedTypeResolvesViaPackage(t *testing.T) {
ff := extractAll(t, map[string]string{
"app/svc/Service.java": `package app.svc;

import app.data.Repo.Inner;

public class Service {}
`,
"app/data/Repo.java": "package app.data;\npublic class Repo { public static class Inner {} }\n",
})

var ok bool
for _, f := range factsByKind(ff, facts.KindDependency) {
if f.Props["import"] == "app.data.Repo.Inner" {
// Resolves via parent type app.data.Repo (or package app.data) → app/data.
if f.Props["source"] == "internal" && hasRelation(f, facts.RelImports, "app/data") {
ok = true
}
}
}
if !ok {
t.Error("import of un-indexed type app.data.Repo.Inner should resolve internal to app/data")
}
}

// TestExtract_WildcardNotOverResolved guards that the parent-FQN fallback is NOT
// applied to wildcard imports: an external wildcard stays external (it must not
// walk to a grandparent), while an internal wildcard still resolves normally.
func TestExtract_WildcardNotOverResolved(t *testing.T) {
ff := extractAll(t, map[string]string{
"app/svc/Service.java": `package app.svc;

import app.data.*;
import com.external.lib.*;

public class Service {}
`,
"app/data/Repo.java": "package app.data;\npublic class Repo {}\n",
})

var internalWildcardOK, externalWildcardExternal = false, true
for _, f := range factsByKind(ff, facts.KindDependency) {
switch f.Props["import"] {
case "app.data":
if f.Props["source"] == "internal" && hasRelation(f, facts.RelImports, "app/data") {
internalWildcardOK = true
}
case "com.external.lib":
if f.Props["source"] != "external" {
externalWildcardExternal = false
}
}
}
if !internalWildcardOK {
t.Error("internal wildcard import app.data.* should resolve to app/data")
}
if !externalWildcardExternal {
t.Error("external wildcard import com.external.lib.* must stay external (no grandparent fallback)")
}
}

func TestExtract_InstantiatesAndCalls(t *testing.T) {
ff := extractAll(t, map[string]string{
"m/Widget.java": "package m;\npublic class Widget {}\n",
Expand Down
90 changes: 67 additions & 23 deletions internal/extractors/kotlinextractor/kotlin.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,38 +311,82 @@ func extractTypeName(s string) string {

// --- Source-root and import resolution (project-level) ---

// detectKotlinSourceRoot derives the source root directory from the first
// Kotlin file's package declaration. For "app/src/main/java/com/foo/Bar.kt"
// declaring `package com.foo`, it returns "app/src/main/java/".
// detectKotlinSourceRoot derives the source-root directory shared by the
// project's production Kotlin files, by stripping each file's package path from
// its directory. For "app/src/main/java/com/foo/Bar.kt" declaring `package
// com.foo`, the per-file root is "app/src/main/java/".
//
// It deliberately ignores test source sets (src/test, src/androidTest) and picks
// the MOST COMMON production root rather than the first file seen. File order is
// not guaranteed: with the old "first file wins" logic, a project whose first
// walked file was a test ("app/src/androidTest/java/…") resolved every internal
// import under that test root, so the targets never matched the real (main)
// module dirs and coupling collapsed to zero.
func detectKotlinSourceRoot(repoPath string, files []string) string {
counts := make(map[string]int) // production source root -> file count
fallback := "" // any root seen, used only if all files are tests
haveFallback := false

for _, relFile := range files {
if !isKotlinFile(relFile) {
continue
}
absFile := filepath.Join(repoPath, relFile)
f, err := os.Open(absFile)
if err != nil {
root, ok := kotlinFileSourceRoot(repoPath, relFile)
if !ok {
continue
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if m := packageRe.FindStringSubmatch(line); m != nil {
pkg := m[1]
pkgPath := strings.ReplaceAll(pkg, ".", "/")
dir := filepath.ToSlash(filepath.Dir(relFile))
if strings.HasSuffix(dir, pkgPath) {
root := strings.TrimSuffix(dir, pkgPath)
f.Close()
return root
}
f.Close()
return ""
}
if !haveFallback {
fallback, haveFallback = root, true
}
f.Close()
if isKotlinTestSource(relFile) {
continue
}
counts[root]++
}
return ""

best, bestN, found := "", 0, false
for root, n := range counts {
if !found || n > bestN || (n == bestN && root < best) {
best, bestN, found = root, n, true
}
}
if found {
return best
}
return fallback
}

// kotlinFileSourceRoot returns a single file's source root: its directory with
// its package path stripped. ok is false when the file has no package decl.
func kotlinFileSourceRoot(repoPath, relFile string) (string, bool) {
absFile := filepath.Join(repoPath, relFile)
f, err := os.Open(absFile)
if err != nil {
return "", false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
m := packageRe.FindStringSubmatch(scanner.Text())
if m == nil {
continue
}
pkgPath := strings.ReplaceAll(m[1], ".", "/")
dir := filepath.ToSlash(filepath.Dir(relFile))
if strings.HasSuffix(dir, pkgPath) {
return strings.TrimSuffix(dir, pkgPath), true
}
return "", true // package found but dir doesn't mirror it — root is ""
}
return "", false
}

// isKotlinTestSource reports whether a file lives in a Gradle test source set
// (src/test or src/androidTest), which must not drive source-root detection.
func isKotlinTestSource(relFile string) bool {
p := filepath.ToSlash(relFile)
return strings.Contains(p, "/src/test/") || strings.HasPrefix(p, "src/test/") ||
strings.Contains(p, "/src/androidTest/") || strings.HasPrefix(p, "src/androidTest/")
}

// detectKotlinBasePackage reads the Android namespace from build.gradle.kts so
Expand Down
Loading
Loading