diff --git a/cmd/enola/main.go b/cmd/enola/main.go index d2b683f..9e5d29f 100644 --- a/cmd/enola/main.go +++ b/cmd/enola/main.go @@ -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() { @@ -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 + } } } @@ -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 { @@ -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()) +} diff --git a/internal/extractors/javaextractor/java.go b/internal/extractors/javaextractor/java.go index 7b901e6..7c14cb2 100644 --- a/internal/extractors/javaextractor/java.go +++ b/internal/extractors/javaextractor/java.go @@ -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 } diff --git a/internal/extractors/javaextractor/java_ast.go b/internal/extractors/javaextractor/java_ast.go index d7f7282..a450b6f 100644 --- a/internal/extractors/javaextractor/java_ast.go +++ b/internal/extractors/javaextractor/java_ast.go @@ -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}, }, @@ -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}, diff --git a/internal/extractors/javaextractor/java_ast_test.go b/internal/extractors/javaextractor/java_ast_test.go index 3f0a5a7..f369296 100644 --- a/internal/extractors/javaextractor/java_ast_test.go +++ b/internal/extractors/javaextractor/java_ast_test.go @@ -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") @@ -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", diff --git a/internal/extractors/kotlinextractor/kotlin.go b/internal/extractors/kotlinextractor/kotlin.go index db63380..cbf97a1 100644 --- a/internal/extractors/kotlinextractor/kotlin.go +++ b/internal/extractors/kotlinextractor/kotlin.go @@ -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 diff --git a/internal/extractors/kotlinextractor/kotlin_test.go b/internal/extractors/kotlinextractor/kotlin_test.go new file mode 100644 index 0000000..c6f8351 --- /dev/null +++ b/internal/extractors/kotlinextractor/kotlin_test.go @@ -0,0 +1,72 @@ +package kotlinextractor + +import ( + "os" + "path/filepath" + "testing" +) + +// writeKotlinRepo writes files (rel path -> content) into a temp repo and returns +// the repo dir and the relative file list. +func writeKotlinRepo(t *testing.T, files map[string]string) (string, []string) { + t.Helper() + dir := t.TempDir() + var rel []string + for name, content := range files { + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + rel = append(rel, name) + } + return dir, rel +} + +// TestDetectSourceRoot_IgnoresTestSourceSets is the regression guard for the +// coupling-collapse bug: when a test-source-set file is walked first, source-root +// detection must still pick the production (main) root, not the androidTest one. +func TestDetectSourceRoot_IgnoresTestSourceSets(t *testing.T) { + repo, files := writeKotlinRepo(t, map[string]string{ + // androidTest file deliberately first in the map; map order is random so the + // fix must not depend on order — it skips test sources entirely. + "app/src/androidTest/java/com/foo/AppTest.kt": "package com.foo\nclass AppTest\n", + "app/src/test/java/com/foo/UnitTest.kt": "package com.foo\nclass UnitTest\n", + "app/src/main/java/com/foo/A.kt": "package com.foo\nclass A\n", + "app/src/main/java/com/foo/B.kt": "package com.foo\nclass B\n", + }) + + got := detectKotlinSourceRoot(repo, files) + want := "app/src/main/java/" + if got != want { + t.Errorf("detectKotlinSourceRoot = %q, want %q (must prefer the production source set)", got, want) + } +} + +// TestDetectSourceRoot_MostCommonProductionRoot picks the dominant production +// root, not whichever file happens to be scanned first. +func TestDetectSourceRoot_MostCommonProductionRoot(t *testing.T) { + repo, files := writeKotlinRepo(t, map[string]string{ + "feature/src/main/kotlin/com/x/One.kt": "package com.x\nclass One\n", + "app/src/main/java/com/foo/A.kt": "package com.foo\nclass A\n", + "app/src/main/java/com/foo/B.kt": "package com.foo\nclass B\n", + "app/src/main/java/com/foo/bar/C.kt": "package com.foo.bar\nclass C\n", + }) + // app/src/main/java/ appears 3x; feature/src/main/kotlin/ once → app wins. + if got := detectKotlinSourceRoot(repo, files); got != "app/src/main/java/" { + t.Errorf("detectKotlinSourceRoot = %q, want app/src/main/java/ (most common)", got) + } +} + +// TestDetectSourceRoot_AllTestsFallback: if every file is a test source (no +// production code), fall back to a detected root rather than returning "". +func TestDetectSourceRoot_AllTestsFallback(t *testing.T) { + repo, files := writeKotlinRepo(t, map[string]string{ + "app/src/test/java/com/foo/UnitTest.kt": "package com.foo\nclass UnitTest\n", + }) + if got := detectKotlinSourceRoot(repo, files); got != "app/src/test/java/" { + t.Errorf("detectKotlinSourceRoot = %q, want app/src/test/java/ (fallback when only tests)", got) + } +} diff --git a/internal/extractors/pythonextractor/python.go b/internal/extractors/pythonextractor/python.go index 4f5583a..7398060 100644 --- a/internal/extractors/pythonextractor/python.go +++ b/internal/extractors/pythonextractor/python.go @@ -107,6 +107,11 @@ func (e *PythonExtractor) Extract(ctx context.Context, repoPath string, files [] modules[dir] = true } + // Resolve dotted import targets to internal module slash paths (and classify + // stdlib/external) now that the full module set is known. Without this, + // Python imports never match module Names downstream. + resolveImports(allFacts, modules) + for dir := range modules { allFacts = append(allFacts, facts.Fact{ Kind: facts.KindModule, diff --git a/internal/extractors/pythonextractor/resolve.go b/internal/extractors/pythonextractor/resolve.go new file mode 100644 index 0000000..243111b --- /dev/null +++ b/internal/extractors/pythonextractor/resolve.go @@ -0,0 +1,252 @@ +package pythonextractor + +import ( + "sort" + "strings" + + "github.com/enola-labs/enola/internal/facts" +) + +// resolveImports rewrites, in place, each dependency fact's `imports` relation +// Target so it matches the slash-directory Name of the internal module it refers +// to, and sets Props["source"] = "internal" | "external" | "stdlib". +// +// The Python extractor emits raw dotted import paths as relation Targets +// ("airflow.models.dag"), but module facts are named by slash directory +// ("airflow-core/src/airflow/models"). Downstream consumers (the graph index, +// package metrics, and the explain hotspots) all match the Target against module +// Names, so without this pass Python imports never resolve to internal modules +// and coupling collapses to zero. This mirrors the Go extractor, which resolves +// imports to slash paths at extraction time by stripping the go.mod module path. +func resolveImports(allFacts []facts.Fact, modules map[string]bool) { + idx := buildSuffixIndex(modules) + topPkgs := topLevelSegments(modules) + + for i := range allFacts { + f := &allFacts[i] + if f.Kind != facts.KindDependency { + continue + } + importerDir := fileDir(f.File) + for j := range f.Relations { + rel := &f.Relations[j] + if rel.Kind != facts.RelImports { + continue + } + + raw := rel.Target + source := "external" + switch { + case strings.HasPrefix(raw, "."): + // Relative imports are intra-project by definition. + if dir, ok := resolveRelative(raw, importerDir); ok && dir != "" && dir != importerDir { + rel.Target = dir + } + source = "internal" + default: + if dir := resolveAbsolute(raw, idx, topPkgs, importerDir); dir != "" { + rel.Target = dir + source = "internal" + } else if pyStdlib[firstSeg(raw)] { + source = "stdlib" + } + } + + if f.Props == nil { + f.Props = map[string]any{} + } + f.Props["source"] = source + } + } +} + +// suffixIndex maps a dotted-suffix key ("a.b.c", "b.c", "c") to the module dirs +// whose trailing path segments produce that key. Buckets are pre-sorted so the +// nearest source root (shortest physical path) is first. +type suffixIndex map[string][]string + +// buildSuffixIndex indexes every module dir by each of its trailing-segment +// suffixes. For dir "a/b/c" it registers "a.b.c"->dir, "b.c"->dir, "c"->dir. +func buildSuffixIndex(modules map[string]bool) suffixIndex { + idx := make(suffixIndex) + for dir := range modules { + if dir == "" || dir == "." { + continue + } + segs := strings.Split(dir, "/") + for i := range segs { + key := strings.Join(segs[i:], ".") + idx[key] = append(idx[key], dir) + } + } + // Pre-sort each bucket: shortest physical path first (nearest source root), + // then lexicographic — deterministic regardless of map iteration order. + for key, dirs := range idx { + sort.Slice(dirs, func(a, b int) bool { + sa := strings.Count(dirs[a], "/") + sb := strings.Count(dirs[b], "/") + if sa != sb { + return sa < sb + } + return dirs[a] < dirs[b] + }) + idx[key] = dirs + } + return idx +} + +// topLevelSegments returns the set of all path segments appearing in any module +// dir. It is used as a cheap, safe early-exit gate in resolveAbsolute: an import +// whose first dotted segment names no internal directory cannot be internal, so +// it is left for stdlib/external classification. It is permissive by design — +// it never wrongly rejects an internal import (the failure mode we are fixing). +func topLevelSegments(modules map[string]bool) map[string]bool { + segs := make(map[string]bool) + for dir := range modules { + for _, s := range strings.Split(dir, "/") { + if s != "" && s != "." { + segs[s] = true + } + } + } + return segs +} + +// resolveAbsolute maps a dotted absolute import ("airflow.models.dag") to the +// slash dir of the nearest matching internal module, or "" if none. importerDir +// is used only to skip a self-match. It tries the most specific dotted path +// first, then drops trailing segments (so "from a.b import c" — Target "a.b" — +// and "import a.b.c" both resolve to the package dir). +func resolveAbsolute(dotted string, idx suffixIndex, topPkgs map[string]bool, importerDir string) string { + if dotted == "" { + return "" + } + segs := strings.Split(dotted, ".") + if !topPkgs[segs[0]] { + return "" // first segment is not an internal directory → not internal + } + for end := len(segs); end >= 1; end-- { + cand := strings.Join(segs[:end], ".") + bucket := idx[cand] + if len(bucket) == 0 { + continue + } + for _, dir := range bucket { + if dir != importerDir { + return dir // pre-sorted: nearest source root wins + } + } + // Only a self-match at this candidate; treat as no internal target so we + // never emit a self-coupling edge. + return "" + } + return "" +} + +// resolveRelative maps a relative import (".", ".models", "..models.dag") to a +// slash dir, computed against importerDir. N leading dots means: start at the +// importer's dir, then go up (N-1) levels; the remaining dotted tail becomes +// slash segments. The returned dir need not be a known module — graph.go walks +// up to the nearest real ancestor. +func resolveRelative(raw, importerDir string) (string, bool) { + n := countLeadingDots(raw) + if n == 0 { + return "", false + } + tail := strings.TrimLeft(raw, ".") + base := importerDir + if base == "" { + base = "." + } + for i := 0; i < n-1; i++ { + base = parentDir(base) + } + if tail != "" { + tailSlash := strings.ReplaceAll(tail, ".", "/") + if base == "." { + base = tailSlash + } else { + base = base + "/" + tailSlash + } + } + if base == "" { + base = "." + } + return base, true +} + +// --- small helpers (kept local; do not import explain's equivalents) --- + +// fileDir returns the directory portion of a slash file path, or "." for a +// bare filename. +func fileDir(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[:i] + } + return "." +} + +// parentDir returns the parent of a slash dir path, clamped at ".". +func parentDir(p string) string { + if p == "" || p == "." { + return "." + } + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[:i] + } + return "." +} + +// firstSeg returns the first dotted segment ("json.decoder" -> "json"). +func firstSeg(dotted string) string { + if i := strings.IndexByte(dotted, '.'); i >= 0 { + return dotted[:i] + } + return dotted +} + +// countLeadingDots counts the leading '.' characters of a relative import. +func countLeadingDots(s string) int { + n := 0 + for n < len(s) && s[n] == '.' { + n++ + } + return n +} + +// pyStdlib is the set of Python standard-library top-level module names. Used to +// split non-internal imports into "stdlib" vs "external" in the dependency +// breakdown (mirrors the Go extractor's stdlib classification). +var pyStdlib = map[string]bool{ + "__future__": true, "abc": true, "argparse": true, "array": true, "ast": true, + "asyncio": true, "base64": true, "bisect": true, "builtins": true, "bz2": true, + "calendar": true, "cgi": true, "cmath": true, "cmd": true, "codecs": true, + "collections": true, "concurrent": true, "configparser": true, "contextlib": true, + "contextvars": true, "copy": true, "copyreg": true, "csv": true, "ctypes": true, + "dataclasses": true, "datetime": true, "decimal": true, "difflib": true, "dis": true, + "doctest": true, "email": true, "encodings": true, "enum": true, "errno": true, + "faulthandler": true, "fcntl": true, "filecmp": true, "fileinput": true, "fnmatch": true, + "fractions": true, "ftplib": true, "functools": true, "gc": true, "getopt": true, + "getpass": true, "gettext": true, "glob": true, "graphlib": true, "gzip": true, + "hashlib": true, "heapq": true, "hmac": true, "html": true, "http": true, + "imaplib": true, "importlib": true, "inspect": true, "io": true, "ipaddress": true, + "itertools": true, "json": true, "keyword": true, "linecache": true, "locale": true, + "logging": true, "lzma": true, "mailbox": true, "marshal": true, "math": true, + "mimetypes": true, "mmap": true, "multiprocessing": true, "numbers": true, "operator": true, + "os": true, "pathlib": true, "pdb": true, "pickle": true, "pickletools": true, + "pkgutil": true, "platform": true, "plistlib": true, "posixpath": true, "pprint": true, + "profile": true, "pstats": true, "pty": true, "pwd": true, "py_compile": true, + "queue": true, "quopri": true, "random": true, "re": true, "reprlib": true, + "resource": true, "runpy": true, "sched": true, "secrets": true, "select": true, + "selectors": true, "shelve": true, "shlex": true, "shutil": true, "signal": true, + "site": true, "smtplib": true, "socket": true, "socketserver": true, "sqlite3": true, + "ssl": true, "stat": true, "statistics": true, "string": true, "stringprep": true, + "struct": true, "subprocess": true, "symtable": true, "sys": true, "sysconfig": true, + "tarfile": true, "tempfile": true, "termios": true, "textwrap": true, "threading": true, + "time": true, "timeit": true, "tkinter": true, "token": true, "tokenize": true, + "tomllib": true, "trace": true, "traceback": true, "tracemalloc": true, "tty": true, + "types": true, "typing": true, "unicodedata": true, "unittest": true, "urllib": true, + "uuid": true, "venv": true, "warnings": true, "wave": true, "weakref": true, + "webbrowser": true, "xml": true, "xmlrpc": true, "zipapp": true, "zipfile": true, + "zipimport": true, "zlib": true, "zoneinfo": true, +} diff --git a/internal/extractors/pythonextractor/resolve_test.go b/internal/extractors/pythonextractor/resolve_test.go new file mode 100644 index 0000000..c87288e --- /dev/null +++ b/internal/extractors/pythonextractor/resolve_test.go @@ -0,0 +1,225 @@ +package pythonextractor + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/enola-labs/enola/internal/facts" +) + +// modSet builds a module-dir set from the given dirs. +func modSet(dirs ...string) map[string]bool { + m := make(map[string]bool, len(dirs)) + for _, d := range dirs { + m[d] = true + } + return m +} + +// depFact builds a dependency fact with a single imports relation, as the +// extractor emits them. +func depFact(file, target string) facts.Fact { + return facts.Fact{ + Kind: facts.KindDependency, + Name: "x -> " + target, + File: file, + Props: map[string]any{"language": "python"}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: target}}, + } +} + +// importTarget returns the (possibly rewritten) imports target of a dep fact. +func importTarget(f facts.Fact) string { + for _, r := range f.Relations { + if r.Kind == facts.RelImports { + return r.Target + } + } + return "" +} + +func source(f facts.Fact) string { + s, _ := f.Props["source"].(string) + return s +} + +func TestResolveImports_AbsoluteMultiSourceRoot(t *testing.T) { + modules := modSet( + "airflow-core/src/airflow", + "airflow-core/src/airflow/models", + "airflow-core/src/airflow/utils", + "providers/foo/src/airflow/providers/foo", + ) + ff := []facts.Fact{ + depFact("airflow-core/src/airflow/dag.py", "airflow.models.dag"), + depFact("airflow-core/src/airflow/dag.py", "airflow.providers.foo"), + depFact("airflow-core/src/airflow/dag.py", "airflow.utils"), + } + resolveImports(ff, modules) + + if got := importTarget(ff[0]); got != "airflow-core/src/airflow/models" { + t.Errorf("airflow.models.dag resolved to %q, want airflow-core/src/airflow/models", got) + } + if got := importTarget(ff[1]); got != "providers/foo/src/airflow/providers/foo" { + t.Errorf("airflow.providers.foo resolved to %q, want providers/foo/src/airflow/providers/foo", got) + } + if got := importTarget(ff[2]); got != "airflow-core/src/airflow/utils" { + t.Errorf("airflow.utils resolved to %q, want airflow-core/src/airflow/utils", got) + } + for i, f := range ff { + if source(f) != "internal" { + t.Errorf("fact %d source = %q, want internal", i, source(f)) + } + } +} + +func TestResolveImports_ShortestSourceRootWinsDeterministic(t *testing.T) { + // Two dirs whose trailing segments are both "pkg/models"; the shorter path + // (nearest source root) must win, consistently across runs. + modules := modSet( + "src/pkg/models", + "deeply/nested/src/pkg/models", + "src/pkg", + ) + for run := 0; run < 3; run++ { + ff := []facts.Fact{depFact("src/pkg/x.py", "pkg.models")} + resolveImports(ff, modules) + if got := importTarget(ff[0]); got != "src/pkg/models" { + t.Fatalf("run %d: pkg.models resolved to %q, want src/pkg/models", run, got) + } + } +} + +func TestResolveImports_Relative(t *testing.T) { + modules := modSet("pkg/a/b", "pkg/a", "pkg") + cases := []struct { + raw string + want string // expected rewritten target; "" means unchanged (self) + }{ + {".sibling", "pkg/a/b/sibling"}, + {"..uncle", "pkg/a/uncle"}, + {"...grand.x", "pkg/grand/x"}, + {".", ".|self"}, // bare dot is self → target unchanged + } + for _, c := range cases { + ff := []facts.Fact{depFact("pkg/a/b/mod.py", c.raw)} + resolveImports(ff, modules) + got := importTarget(ff[0]) + if c.want == ".|self" { + if got != c.raw { + t.Errorf("relative %q: self import should leave target unchanged, got %q", c.raw, got) + } + } else if got != c.want { + t.Errorf("relative %q resolved to %q, want %q", c.raw, got, c.want) + } + if source(ff[0]) != "internal" { + t.Errorf("relative %q source = %q, want internal", c.raw, source(ff[0])) + } + } +} + +func TestResolveImports_StdlibAndExternal(t *testing.T) { + modules := modSet("pkg/app") + ff := []facts.Fact{ + depFact("pkg/app/x.py", "os"), + depFact("pkg/app/x.py", "json.decoder"), + depFact("pkg/app/x.py", "requests"), + depFact("pkg/app/x.py", "sqlalchemy.orm"), + } + resolveImports(ff, modules) + + wantSource := []string{"stdlib", "stdlib", "external", "external"} + for i, f := range ff { + if source(f) != wantSource[i] { + t.Errorf("fact %d (%s) source = %q, want %q", i, importTarget(f), source(f), wantSource[i]) + } + // Targets must be left untouched for non-internal imports. + if importTarget(f) != ff[i].Relations[0].Target { + // (always true since we read the same slice, but assert intent) + } + } + if importTarget(ff[0]) != "os" { + t.Errorf("stdlib import target should be unchanged, got %q", importTarget(ff[0])) + } +} + +func TestResolveImports_SelfImportNoSelfEdge(t *testing.T) { + // An absolute import that resolves to the importer's own dir must NOT rewrite + // the target to that dir (which would create a self-coupling edge). + modules := modSet("pkg/app", "pkg") + ff := []facts.Fact{depFact("pkg/app/x.py", "pkg.app")} + resolveImports(ff, modules) + if got := importTarget(ff[0]); got == "pkg/app" { + t.Errorf("self import resolved to own dir %q (self-edge); should be left as dotted", got) + } +} + +// TestExtract_PythonResolvesImports is the end-to-end guard: a real Extract over +// a temp multi-dir repo must produce a dependency fact whose import target equals +// a module Name with source=internal. +func TestExtract_PythonResolvesImports(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "pyproject.toml": "[project]\nname='demo'\n", + "src/demo/__init__.py": "", + "src/demo/models/__init__.py": "class Model:\n pass\n", + "src/demo/service.py": "from demo.models import Model\n\ndef use():\n return Model()\n", + } + var rel []string + for name, content := range files { + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + rel = append(rel, name) + } + + ff, err := New().Extract(context.Background(), dir, rel) + if err != nil { + t.Fatalf("Extract: %v", err) + } + + // Collect module names. + moduleNames := map[string]bool{} + for _, f := range ff { + if f.Kind == facts.KindModule { + moduleNames[f.Name] = true + } + } + + // Find the dependency fact for the "demo.models" import from service.py and + // assert it resolved to the models module dir. + found := false + for _, f := range ff { + if f.Kind != facts.KindDependency { + continue + } + for _, r := range f.Relations { + if r.Kind == facts.RelImports && r.Target == "src/demo/models" { + if source(f) != "internal" { + t.Errorf("resolved import source = %q, want internal", source(f)) + } + if !moduleNames[r.Target] { + t.Errorf("resolved import target %q is not a module Name", r.Target) + } + found = true + } + } + } + if !found { + t.Errorf("expected the demo.models import to resolve to module dir 'src/demo/models'; module names: %v", keysOf(moduleNames)) + } +} + +func keysOf(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/extractors/rubyextractor/resolve.go b/internal/extractors/rubyextractor/resolve.go new file mode 100644 index 0000000..765b61c --- /dev/null +++ b/internal/extractors/rubyextractor/resolve.go @@ -0,0 +1,384 @@ +package rubyextractor + +import ( + "sort" + "strings" + + "github.com/enola-labs/enola/internal/facts" +) + +// resolveImports derives internal module-coupling edges for Ruby and returns them +// as synthetic dependency facts. Rails autoloads constants, so internal coupling +// is expressed through constant references — class inheritance, include/extend +// mixins, ActiveRecord associations, and method calls — whose relation targets are +// Ruby constant names, not directory paths. None of those relation kinds are +// counted by the coupling consumers (graph, package metrics, explain hotspots), +// which only count dependency facts whose `imports` target matches a module Name. +// +// This pass builds a constant -> declaring-module-dir index, resolves every +// cross-module constant reference to a srcDir -> destDir edge, and emits one +// synthetic dependency fact per unique edge (with an `imports` relation to the +// destination module dir). It also resolves require_relative paths and Packwerk +// package.yml dependencies, and classifies require/require_relative facts as +// internal/stdlib/external in place. This mirrors the Python extractor's resolve +// pass; it never guesses — every edge comes from a real parsed reference. +func resolveImports(allFacts []facts.Fact, isRails bool) []facts.Fact { + ix := buildConstIndex(allFacts) + moduleNames := collectModuleNames(allFacts) + + edges := map[[2]string]bool{} + add := func(src, dst string) { + if src == "" || dst == "" || src == dst { + return // skip empties and self-edges + } + edges[[2]string{src, dst}] = true + } + + for i := range allFacts { + f := &allFacts[i] + switch f.Kind { + case facts.KindSymbol: + src := declaresTarget(f) + for _, rel := range f.Relations { + switch rel.Kind { + case facts.RelImplements: // inheritance + add(src, ix.resolve(rel.Target)) + case facts.RelCalls: + if c := constFromCall(rel.Target); c != "" { + add(src, ix.resolve(c)) + } + } + } + case facts.KindDependency: + src := fileDir(f.File) + for j := range f.Relations { + rel := &f.Relations[j] + switch rel.Kind { + case facts.RelImplements: // include/extend/prepend mixins + if dst := ix.resolve(rel.Target); dst != "" { + add(src, dst) + setSource(f, "internal") + } else { + setSource(f, "external") + } + case facts.RelDependsOn: // ActiveRecord associations + if dst := ix.resolve(rel.Target); dst != "" { + add(src, dst) + setSource(f, "internal") + } else { + setSource(f, "external") + } + case facts.RelImports: // require / require_relative + classifyRequire(f, rel, src, moduleNames, add) + } + } + case facts.KindModule: + // Packwerk package.yml dependencies: explicit module -> module edges. + for _, rel := range f.Relations { + if rel.Kind == facts.RelDependsOn { + add(packwerkDir(f.Name), packwerkDir(rel.Target)) + } + } + } + } + + return emitEdges(edges, isRails) +} + +// constIndex resolves a Ruby constant reference to the slash dir of the module +// that declares it. +type constIndex struct { + qualified map[string]string // "Orders::Order" -> "app/models/orders" + bare map[string][]string // "Order" -> ["app/models/orders", ...] (sorted, deduped) +} + +// buildConstIndex indexes every class/module/constant symbol by its qualified and +// bare names. Source dir is the declares-relation target (fallback fileDir). +func buildConstIndex(allFacts []facts.Fact) *constIndex { + ix := &constIndex{qualified: map[string]string{}, bare: map[string][]string{}} + for i := range allFacts { + f := &allFacts[i] + if f.Kind != facts.KindSymbol { + continue + } + switch sk, _ := f.Props["symbol_kind"].(string); sk { + case facts.SymbolClass, facts.SymbolInterface, facts.SymbolConstant: + default: + continue + } + dir := declaresTarget(f) + if dir == "" { + continue + } + qn := stripLeadingColons(f.Name) + if qn == "" { + continue + } + // Qualified: prefer the shortest declaring dir on collision (nearest root). + if cur, ok := ix.qualified[qn]; !ok || shorter(dir, cur) { + ix.qualified[qn] = dir + } + bare := lastSegment(qn) + ix.bare[bare] = append(ix.bare[bare], dir) + } + for k, dirs := range ix.bare { + ix.bare[k] = sortDedupDirs(dirs) + } + return ix +} + +// resolve returns the declaring module dir of a constant reference, or "". +func (ix *constIndex) resolve(ref string) string { + ref = stripLeadingColons(ref) + if ref == "" { + return "" + } + if dir, ok := ix.qualified[ref]; ok { + return dir + } + if dirs := ix.bare[lastSegment(ref)]; len(dirs) > 0 { + return dirs[0] // pre-sorted: shortest dir, then lexicographic + } + return "" +} + +// classifyRequire resolves a require/require_relative dependency fact: relative +// requires are intra-project (resolved to a module dir when possible); absolute +// requires are stdlib or external. Sets Props["source"] in place. +func classifyRequire(f *facts.Fact, rel *facts.Relation, src string, moduleNames map[string]bool, add func(s, d string)) { + raw := rel.Target + isRel, _ := f.Props["require_relative"].(bool) + switch { + case isRel || strings.HasPrefix(raw, "."): + if dst := resolveRequireRelative(raw, src, moduleNames); dst != "" { + add(src, dst) + } + setSource(f, "internal") + case rubyStdlib[raw] || rubyStdlib[firstPathSeg(raw)]: + setSource(f, "stdlib") + default: + setSource(f, "external") + } +} + +// resolveRequireRelative resolves a relative require path (e.g. "../helper") +// against the importing file's dir, then walks up to the nearest known module. +// Returns "" if it cannot be placed inside the project. +func resolveRequireRelative(raw, importerDir string, moduleNames map[string]bool) string { + p := strings.TrimSuffix(raw, ".rb") + base := importerDir + if base == "" { + base = "." + } + for _, seg := range strings.Split(p, "/") { + switch seg { + case "", ".": + // stay + case "..": + base = parentDir(base) + default: + if base == "." { + base = seg + } else { + base = base + "/" + seg + } + } + } + // The resolved path points at a file; its module is the containing dir, walked + // up to the nearest known module. + return nearestModule(fileDir(base), moduleNames) +} + +// nearestModule walks up dir's ancestors until it finds a known module, or "". +func nearestModule(dir string, moduleNames map[string]bool) string { + cur := dir + for cur != "" && cur != "." { + if moduleNames[cur] { + return cur + } + cur = parentDir(cur) + } + if moduleNames[cur] { + return cur + } + return "" +} + +// emitEdges builds one synthetic dependency fact per unique edge. File is set to +// "/_coupling.rb" so that consumers deriving the source module via +// fileDir(File) recover exactly srcDir (a bare srcDir would lose its last segment). +func emitEdges(edges map[[2]string]bool, isRails bool) []facts.Fact { + out := make([]facts.Fact, 0, len(edges)) + for e := range edges { + src, dst := e[0], e[1] + props := map[string]any{ + "language": "ruby", + "source": "internal", + "synthetic_coupling": true, + } + if isRails { + props["framework"] = "rails" + } + out = append(out, facts.Fact{ + Kind: facts.KindDependency, + Name: src + " -> " + dst, + File: src + "/_coupling.rb", + Props: props, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: dst}}, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// --- helpers --- + +// constFromCall turns a calls-target into its receiver constant, or "" when the +// receiver is not a constant. Splits on the LAST '.' so the inner '::' of a +// namespaced receiver is preserved: "Foo::Bar.method" -> "Foo::Bar", +// "Account.active" -> "Account", "var.method" -> "" (lowercase receiver). +func constFromCall(target string) string { + target = stripLeadingColons(target) + dot := strings.LastIndex(target, ".") + if dot < 0 { + return "" + } + recv := target[:dot] + if recv == "" || !startsUpper(recv) { + return "" + } + return recv +} + +// declaresTarget returns a fact's declares-relation target (its module dir), +// falling back to the directory of its file. +func declaresTarget(f *facts.Fact) string { + for _, rel := range f.Relations { + if rel.Kind == facts.RelDeclares { + return rel.Target + } + } + return fileDir(f.File) +} + +// collectModuleNames returns the set of module-fact Names. +func collectModuleNames(allFacts []facts.Fact) map[string]bool { + m := make(map[string]bool) + for i := range allFacts { + if allFacts[i].Kind == facts.KindModule { + m[allFacts[i].Name] = true + } + } + return m +} + +// setSource sets Props["source"] if not already set. +func setSource(f *facts.Fact, source string) { + if f.Props == nil { + f.Props = map[string]any{} + } + if _, ok := f.Props["source"]; !ok { + f.Props["source"] = source + } +} + +// packwerkDir normalizes a Packwerk package name/target: "root" -> ".". +func packwerkDir(name string) string { + if name == "root" { + return "." + } + return name +} + +// fileDir returns the directory portion of a slash file path, or "." for a bare +// filename. +func fileDir(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[:i] + } + return "." +} + +// parentDir returns the parent of a slash dir path, clamped at ".". +func parentDir(p string) string { + if p == "" || p == "." { + return "." + } + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[:i] + } + return "." +} + +// stripLeadingColons removes a leading "::" from a Ruby constant reference. +func stripLeadingColons(s string) string { + return strings.TrimPrefix(s, "::") +} + +// lastSegment returns the final "::"-separated segment ("Orders::Order" -> "Order"). +func lastSegment(s string) string { + if i := strings.LastIndex(s, "::"); i >= 0 { + return s[i+2:] + } + return s +} + +// firstPathSeg returns the segment before the first '/' ("net/http" -> "net"). +func firstPathSeg(s string) string { + if i := strings.IndexByte(s, '/'); i >= 0 { + return s[:i] + } + return s +} + +// startsUpper reports whether the first character is an ASCII uppercase letter +// (a Ruby constant always starts uppercase; a variable receiver does not). +func startsUpper(s string) bool { + return len(s) > 0 && s[0] >= 'A' && s[0] <= 'Z' +} + +// shorter reports whether dir a is "nearer a source root" than b: fewer path +// segments, then lexicographically smaller. +func shorter(a, b string) bool { + sa, sb := strings.Count(a, "/"), strings.Count(b, "/") + if sa != sb { + return sa < sb + } + return a < b +} + +// sortDedupDirs sorts dirs by the shorter() order and removes duplicates. +func sortDedupDirs(dirs []string) []string { + sort.Slice(dirs, func(i, j int) bool { return shorter(dirs[i], dirs[j]) }) + out := dirs[:0:0] + var prev string + for i, d := range dirs { + if i == 0 || d != prev { + out = append(out, d) + } + prev = d + } + return out +} + +// rubyStdlib is the set of Ruby standard-library require names, used to split +// non-internal requires into "stdlib" vs "external" in the dependency breakdown. +var rubyStdlib = map[string]bool{ + "set": true, "json": true, "yaml": true, "psych": true, "date": true, + "time": true, "securerandom": true, "digest": true, "openssl": true, + "net/http": true, "net/https": true, "net/smtp": true, "net/imap": true, + "net/pop": true, "net/ftp": true, "net": true, "fileutils": true, + "logger": true, "forwardable": true, "singleton": true, "ostruct": true, + "pathname": true, "uri": true, "base64": true, "csv": true, "erb": true, + "tempfile": true, "stringio": true, "benchmark": true, "monitor": true, + "timeout": true, "thread": true, "fiber": true, "socket": true, + "resolv": true, "ipaddr": true, "zlib": true, "stringscanner": true, + "strscan": true, "io/console": true, "io/wait": true, "pp": true, + "pstore": true, "delegate": true, "observer": true, "comparable": true, + "enumerator": true, "rational": true, "complex": true, "bigdecimal": true, + "prime": true, "matrix": true, "abbrev": true, "shellwords": true, + "optparse": true, "getoptlong": true, "tsort": true, "weakref": true, + "objspace": true, "coverage": true, "ripper": true, "readline": true, + "etc": true, "fcntl": true, "syslog": true, "open3": true, "open-uri": true, + "tmpdir": true, "find": true, "rbconfig": true, "mkmf": true, "rubygems": true, +} diff --git a/internal/extractors/rubyextractor/resolve_test.go b/internal/extractors/rubyextractor/resolve_test.go new file mode 100644 index 0000000..e5022d4 --- /dev/null +++ b/internal/extractors/rubyextractor/resolve_test.go @@ -0,0 +1,364 @@ +package rubyextractor + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/enola-labs/enola/internal/facts" +) + +// --- fixture builders --- + +func symFact(name, dir, symbolKind string, rels ...facts.Relation) facts.Fact { + all := []facts.Relation{{Kind: facts.RelDeclares, Target: dir}} + all = append(all, rels...) + return facts.Fact{ + Kind: facts.KindSymbol, + Name: name, + File: dir + "/" + strings.ToLower(lastSegment(name)) + ".rb", + Props: map[string]any{"symbol_kind": symbolKind, "language": "ruby"}, + Relations: all, + } +} + +func depFactRuby(file string, props map[string]any, rel facts.Relation) facts.Fact { + if props == nil { + props = map[string]any{"language": "ruby"} + } + return facts.Fact{ + Kind: facts.KindDependency, + Name: file + " -> " + rel.Target, + File: file, + Props: props, + Relations: []facts.Relation{rel}, + } +} + +func modFact(name string, rels ...facts.Relation) facts.Fact { + return facts.Fact{Kind: facts.KindModule, Name: name, File: name, Relations: rels} +} + +// hasEdge reports whether the synthetic facts contain a srcDir->dstDir coupling. +func hasEdge(out []facts.Fact, src, dst string) bool { + for _, f := range out { + if f.Name != src+" -> "+dst { + continue + } + for _, r := range f.Relations { + if r.Kind == facts.RelImports && r.Target == dst { + return true + } + } + } + return false +} + +// --- const index --- + +func TestBuildConstIndex_QualifiedAndBare(t *testing.T) { + ff := []facts.Fact{ + symFact("Orders::Order", "app/models/orders", facts.SymbolClass), + symFact("Email::FromBuilder", "app/builders/email", facts.SymbolClass), + } + ix := buildConstIndex(ff) + + if got := ix.resolve("Orders::Order"); got != "app/models/orders" { + t.Errorf("qualified resolve = %q, want app/models/orders", got) + } + if got := ix.resolve("Order"); got != "app/models/orders" { + t.Errorf("bare resolve = %q, want app/models/orders", got) + } + if got := ix.resolve("::Order"); got != "app/models/orders" { + t.Errorf("leading-colon resolve = %q, want app/models/orders", got) + } + if got := ix.resolve("Nonexistent"); got != "" { + t.Errorf("unknown resolve = %q, want empty", got) + } +} + +func TestConstIndex_BareAmbiguityDeterministic(t *testing.T) { + ff := []facts.Fact{ + symFact("Item", "engines/foo/app/models", facts.SymbolClass), + symFact("Item", "app/models", facts.SymbolClass), + } + for run := 0; run < 3; run++ { + ix := buildConstIndex(ff) + if got := ix.resolve("Item"); got != "app/models" { + t.Fatalf("run %d: ambiguous bare resolve = %q, want shortest dir app/models", run, got) + } + } +} + +func TestConstFromCall(t *testing.T) { + cases := map[string]string{ + "Account.active": "Account", + "Agents::DestroyJob.perform_later": "Agents::DestroyJob", + "ActiveRecord::Base.transaction": "ActiveRecord::Base", + "::Account.active": "Account", + "config.fetch": "", // lowercase receiver + "Foo::Bar": "", // no method suffix + } + for in, want := range cases { + if got := constFromCall(in); got != want { + t.Errorf("constFromCall(%q) = %q, want %q", in, got, want) + } + } +} + +// --- reference kinds --- + +func TestResolveImports_Inheritance(t *testing.T) { + ff := []facts.Fact{ + symFact("Email::FromBuilder", "app/builders/email", facts.SymbolClass, + facts.Relation{Kind: facts.RelImplements, Target: "Mail::BaseBuilder"}), + symFact("Mail::BaseBuilder", "app/mailers/mail", facts.SymbolClass), + } + out := resolveImports(ff, false) + if !hasEdge(out, "app/builders/email", "app/mailers/mail") { + t.Errorf("missing inheritance edge; got %+v", out) + } +} + +func TestResolveImports_Mixin(t *testing.T) { + ff := []facts.Fact{ + symFact("Helpers::UrlHelper", "app/helpers", facts.SymbolInterface), + depFactRuby("app/actions/contact.rb", + map[string]any{"language": "ruby", "mixin_kind": "include"}, + facts.Relation{Kind: facts.RelImplements, Target: "UrlHelper"}), + symFact("UrlHelper", "app/helpers", facts.SymbolInterface), + } + out := resolveImports(ff, false) + if !hasEdge(out, "app/actions", "app/helpers") { + t.Errorf("missing mixin edge; got %+v", out) + } +} + +func TestResolveImports_Association(t *testing.T) { + ff := []facts.Fact{ + depFactRuby("app/models/order.rb", + map[string]any{"language": "ruby", "association_kind": "has_many"}, + facts.Relation{Kind: facts.RelDependsOn, Target: "Item"}), + symFact("Item", "app/models/items", facts.SymbolClass), + } + out := resolveImports(ff, false) + if !hasEdge(out, "app/models", "app/models/items") { + t.Errorf("missing association edge; got %+v", out) + } +} + +func TestResolveImports_MethodCall(t *testing.T) { + ff := []facts.Fact{ + symFact("CleanupJob#perform", "app/jobs", facts.SymbolMethod, + facts.Relation{Kind: facts.RelCalls, Target: "Account.active"}), + symFact("Account", "app/models", facts.SymbolClass), + } + out := resolveImports(ff, false) + if !hasEdge(out, "app/jobs", "app/models") { + t.Errorf("missing method-call edge; got %+v", out) + } +} + +func TestResolveImports_SelfEdgeSkipped(t *testing.T) { + ff := []facts.Fact{ + symFact("Account", "app/models", facts.SymbolClass, + facts.Relation{Kind: facts.RelCalls, Target: "User.find"}), + symFact("User", "app/models", facts.SymbolClass), + } + out := resolveImports(ff, false) + if len(out) != 0 { + t.Errorf("expected no edges for same-dir reference, got %+v", out) + } +} + +func TestResolveImports_DedupAndSorted(t *testing.T) { + // Two distinct references producing the same edge → one fact. + ff := []facts.Fact{ + symFact("A", "app/a", facts.SymbolClass, + facts.Relation{Kind: facts.RelCalls, Target: "Z.foo"}), + symFact("B", "app/a", facts.SymbolClass, + facts.Relation{Kind: facts.RelImplements, Target: "Z"}), + symFact("Z", "app/z", facts.SymbolClass), + } + out := resolveImports(ff, false) + count := 0 + for _, f := range out { + if f.Name == "app/a -> app/z" { + count++ + } + } + if count != 1 { + t.Errorf("expected the duplicate edge deduped to 1 fact, got %d", count) + } + // Output is sorted by Name. + for i := 1; i < len(out); i++ { + if out[i-1].Name > out[i].Name { + t.Errorf("output not sorted: %q before %q", out[i-1].Name, out[i].Name) + } + } +} + +// --- require classification --- + +func TestClassifyRequire_StdlibExternalRelative(t *testing.T) { + ff := []facts.Fact{ + modFact("app/helpers"), + depFactRuby("app/x/a.rb", map[string]any{"language": "ruby"}, + facts.Relation{Kind: facts.RelImports, Target: "set"}), + depFactRuby("app/x/b.rb", map[string]any{"language": "ruby"}, + facts.Relation{Kind: facts.RelImports, Target: "net/http"}), + depFactRuby("app/x/c.rb", map[string]any{"language": "ruby"}, + facts.Relation{Kind: facts.RelImports, Target: "sidekiq"}), + depFactRuby("app/x/d.rb", map[string]any{"language": "ruby", "require_relative": true}, + facts.Relation{Kind: facts.RelImports, Target: "../helpers/url"}), + } + out := resolveImports(ff, false) + + wantSource := map[string]string{ + "set": "stdlib", + "net/http": "stdlib", + "sidekiq": "external", + "../helpers/url": "internal", + } + for _, f := range ff { + if f.Kind != facts.KindDependency { + continue + } + tgt := f.Relations[0].Target + if want, ok := wantSource[tgt]; ok { + if got, _ := f.Props["source"].(string); got != want { + t.Errorf("require %q source = %q, want %q", tgt, got, want) + } + } + } + // require_relative "../helpers/url" from app/x → app/helpers module. + if !hasEdge(out, "app/x", "app/helpers") { + t.Errorf("missing require_relative edge to app/helpers; got %+v", out) + } +} + +// --- packwerk --- + +func TestResolveImports_Packwerk(t *testing.T) { + ff := []facts.Fact{ + modFact("packages/orders", + facts.Relation{Kind: facts.RelDependsOn, Target: "packages/payments"}, + facts.Relation{Kind: facts.RelDependsOn, Target: "root"}), + modFact("packages/payments"), + modFact("root"), + } + out := resolveImports(ff, false) + if !hasEdge(out, "packages/orders", "packages/payments") { + t.Errorf("missing packwerk dependency edge; got %+v", out) + } + if !hasEdge(out, "packages/orders", ".") { + t.Errorf("missing packwerk root edge (root→.); got %+v", out) + } +} + +// --- fileDir source-side contract (load-bearing) --- + +// explainFileDir / graphFileDirectory mirror the consumer logic exactly so this +// test fails if either upstream helper or our sentinel File format drifts. +func explainFileDir(file string) string { + parts := strings.Split(file, "/") + if len(parts) <= 1 { + return "." + } + return strings.Join(parts[:len(parts)-1], "/") +} + +func graphFileDirectory(file string) string { + if i := strings.LastIndex(file, "/"); i >= 0 { + return file[:i] + } + return "." +} + +func TestEmitEdges_FileDirRoundTrip(t *testing.T) { + ff := []facts.Fact{ + symFact("CleanupJob#perform", "app/jobs", facts.SymbolMethod, + facts.Relation{Kind: facts.RelCalls, Target: "Account.active"}), + symFact("Account", "app/models", facts.SymbolClass), + } + out := resolveImports(ff, false) + if len(out) == 0 { + t.Fatal("expected an edge") + } + f := out[0] + if explainFileDir(f.File) != "app/jobs" { + t.Errorf("explainFileDir(%q) = %q, want app/jobs (source-side trap)", f.File, explainFileDir(f.File)) + } + if graphFileDirectory(f.File) != "app/jobs" { + t.Errorf("graphFileDirectory(%q) = %q, want app/jobs", f.File, graphFileDirectory(f.File)) + } +} + +// --- end-to-end Extract --- + +func TestExtract_EndToEnd_CouplingResolves(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "Gemfile": "source 'https://rubygems.org'\ngem 'rails'\n", + "config/application.rb": "module Demo\n class Application\n end\nend\n", + "app/models/account.rb": "class Account\n def self.active\n end\nend\n", + "app/jobs/cleanup_job.rb": "class CleanupJob\n def perform\n Account.active\n end\nend\n", + } + var rel []string + for name, content := range files { + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + rel = append(rel, name) + } + + ff, err := New().Extract(context.Background(), dir, rel) + if err != nil { + t.Fatalf("Extract: %v", err) + } + + moduleNames := map[string]bool{} + for _, f := range ff { + if f.Kind == facts.KindModule { + moduleNames[f.Name] = true + } + } + + // Replicate computeHotspots' fan-in/out resolution. + resolvedEdges := 0 + sawSynthetic := false + for _, f := range ff { + if f.Kind != facts.KindDependency { + continue + } + if sc, _ := f.Props["synthetic_coupling"].(bool); sc { + sawSynthetic = true + } + for _, r := range f.Relations { + if r.Kind == facts.RelImports && moduleNames[r.Target] { + resolvedEdges++ + } + } + } + + if !sawSynthetic { + t.Error("expected at least one synthetic_coupling dependency fact") + } + if resolvedEdges == 0 { + t.Errorf("expected resolvedEdges > 0; module names: %v", moduleKeys(moduleNames)) + } +} + +func moduleKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/extractors/rubyextractor/ruby.go b/internal/extractors/rubyextractor/ruby.go index 5614946..7b65318 100644 --- a/internal/extractors/rubyextractor/ruby.go +++ b/internal/extractors/rubyextractor/ruby.go @@ -102,6 +102,12 @@ func (e *RubyExtractor) Extract(ctx context.Context, repoPath string, files []st allFacts = append(allFacts, routeFacts...) } + // Resolve constant references (inheritance, mixins, associations, calls), + // require_relative paths, and Packwerk dependencies into internal module + // coupling edges. Without this, Ruby imports never match module Names + // downstream and coupling collapses to zero. + allFacts = append(allFacts, resolveImports(allFacts, isRails)...) + return allFacts, nil } diff --git a/internal/extractors/swiftextractor/resolve.go b/internal/extractors/swiftextractor/resolve.go new file mode 100644 index 0000000..f1b7ddc --- /dev/null +++ b/internal/extractors/swiftextractor/resolve.go @@ -0,0 +1,123 @@ +package swiftextractor + +import ( + "strings" + + "github.com/enola-labs/enola/internal/facts" +) + +// resolveImports rewrites, in place, Swift `import X` dependency facts whose +// relation target is a bare module name, and sets Props["source"] on every +// dependency fact. +// +// Swift imports name a module (an SPM target or a system framework), not a path, +// so handleImport emits the bare name ("import AppComposition" -> "AppComposition") +// with no source. That never matches a module fact Name (a slash dir), so coupling +// collapses. SPM target module facts carry Props["spm_target"] and are named by +// their Sources directory, so this pass maps a bare import name to that dir and +// classifies the rest as stdlib (Apple system frameworks) or external. +func resolveImports(allFacts []facts.Fact) { + // SPM target name -> module dir, from the manifest-derived module facts. + spmDir := make(map[string]string) + for i := range allFacts { + f := &allFacts[i] + if f.Kind != facts.KindModule { + continue + } + if name, ok := f.Props["spm_target"].(string); ok && name != "" { + spmDir[name] = f.Name + } + } + + for i := range allFacts { + f := &allFacts[i] + if f.Kind != facts.KindDependency { + continue + } + for j := range f.Relations { + rel := &f.Relations[j] + if rel.Kind != facts.RelImports { + continue + } + t := rel.Target + + // Targets that are already a path come from manifest parsing or the + // type-reference pass; leave them, just normalise source. + if strings.Contains(t, "/") || t == "." { + setSource(f, sourceForResolvedDep(f)) + continue + } + + // Bare module name: resolve to an SPM target dir, or classify. + switch { + case spmDir[t] != "": + rel.Target = spmDir[t] + setSource(f, "internal") + case swiftSystemFramework[t]: + setSource(f, "stdlib") + default: + setSource(f, "external") + } + } + } +} + +// sourceForResolvedDep returns the source label for a dependency fact whose +// target is already a path: "internal" when it was flagged so by the +// type-reference pass or carries an internal source, else the existing/external. +func sourceForResolvedDep(f *facts.Fact) string { + if s, ok := f.Props["source"].(string); ok && s != "" { + return s + } + if b, _ := f.Props["internal"].(bool); b { + return "internal" + } + return "internal" // a path target inside the repo is internal by construction +} + +// setSource sets Props["source"] (overwriting only when empty/unset). +func setSource(f *facts.Fact, source string) { + if f.Props == nil { + f.Props = map[string]any{} + } + if s, ok := f.Props["source"].(string); ok && s != "" { + return + } + f.Props["source"] = source +} + +// swiftSystemFramework is the set of Apple/system module names that an `import` +// can name. Used to split non-internal imports into "stdlib" vs "external" +// (third-party SPM/CocoaPods deps). +var swiftSystemFramework = map[string]bool{ + "Swift": true, "Foundation": true, "Combine": true, "Dispatch": true, + "os": true, "OSLog": true, "Darwin": true, "ObjectiveC": true, "simd": true, + "Observation": true, "SwiftData": true, + // UI + "UIKit": true, "SwiftUI": true, "SwiftUICore": true, "AppKit": true, + "WatchKit": true, "WidgetKit": true, "Charts": true, "WebKit": true, + "SafariServices": true, "MessageUI": true, "PDFKit": true, "QuickLook": true, + "QuickLookThumbnailing": true, "UserNotifications": true, "UserNotificationsUI": true, + "PhotosUI": true, "QuartzCore": true, "CoreAnimation": true, + // Core + "CoreData": true, "CoreGraphics": true, "CoreLocation": true, "CoreFoundation": true, + "CoreImage": true, "CoreMedia": true, "CoreText": true, "CoreBluetooth": true, + "CoreMotion": true, "CoreML": true, "CoreAudio": true, "CoreTelephony": true, + "CoreSpotlight": true, "CoreHaptics": true, "CoreVideo": true, "CoreServices": true, + // Media / graphics + "AVFoundation": true, "AVKit": true, "MediaPlayer": true, "ImageIO": true, + "Metal": true, "MetalKit": true, "ModelIO": true, "SpriteKit": true, + "SceneKit": true, "ARKit": true, "RealityKit": true, "Vision": true, + "VideoToolbox": true, "Photos": true, + // Services / data + "MapKit": true, "StoreKit": true, "CloudKit": true, "Network": true, + "Security": true, "LocalAuthentication": true, "AuthenticationServices": true, + "Contacts": true, "ContactsUI": true, "EventKit": true, "EventKitUI": true, + "HealthKit": true, "HomeKit": true, "GameKit": true, "Intents": true, + "IntentsUI": true, "CallKit": true, "PushKit": true, "BackgroundTasks": true, + "GroupActivities": true, "NaturalLanguage": true, "Speech": true, + "Accelerate": true, "MetricKit": true, "DeviceCheck": true, "AdSupport": true, + "AppTrackingTransparency": true, "LinkPresentation": true, "UniformTypeIdentifiers": true, + // Testing + "XCTest": true, "Testing": true, +} diff --git a/internal/extractors/swiftextractor/resolve_test.go b/internal/extractors/swiftextractor/resolve_test.go new file mode 100644 index 0000000..aa60caf --- /dev/null +++ b/internal/extractors/swiftextractor/resolve_test.go @@ -0,0 +1,138 @@ +package swiftextractor + +import ( + "context" + "testing" + + "github.com/enola-labs/enola/internal/facts" +) + +func importDep(file, target string) facts.Fact { + return facts.Fact{ + Kind: facts.KindDependency, + Name: file + " -> " + target, + File: file, + Props: map[string]any{"language": "swift"}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: target}}, + } +} + +func importTargetOf(f facts.Fact) string { + for _, r := range f.Relations { + if r.Kind == facts.RelImports { + return r.Target + } + } + return "" +} + +func sourceOf(f facts.Fact) string { + s, _ := f.Props["source"].(string) + return s +} + +func TestResolveImports_SwiftBareNames(t *testing.T) { + ff := []facts.Fact{ + {Kind: facts.KindModule, Name: "Packages/Mods/Sources/AppComposition", + Props: map[string]any{"language": "swift", "spm_target": "AppComposition"}}, + {Kind: facts.KindModule, Name: "App/Screens", + Props: map[string]any{"language": "swift"}}, + importDep("App/Screens/Home.swift", "AppComposition"), // internal SPM target + importDep("App/Screens/Home.swift", "Foundation"), // system framework + importDep("App/Screens/Home.swift", "SwiftUI"), // system framework + importDep("App/Screens/Home.swift", "Alamofire"), // unknown third-party + } + resolveImports(ff) + + cases := map[string]struct{ target, source string }{ + "AppComposition": {"Packages/Mods/Sources/AppComposition", "internal"}, + "Foundation": {"Foundation", "stdlib"}, + "SwiftUI": {"SwiftUI", "stdlib"}, + "Alamofire": {"Alamofire", "external"}, + } + for _, f := range ff { + if f.Kind != facts.KindDependency { + continue + } + raw := f.Name[len("App/Screens/Home.swift -> "):] + want, ok := cases[raw] + if !ok { + continue + } + if got := importTargetOf(f); got != want.target { + t.Errorf("import %q target = %q, want %q", raw, got, want.target) + } + if got := sourceOf(f); got != want.source { + t.Errorf("import %q source = %q, want %q", raw, got, want.source) + } + } +} + +func TestResolveImports_SwiftPathTargetsKept(t *testing.T) { + // A target that is already a path (from the manifest or type-reference pass) + // must be left intact and marked internal. + ff := []facts.Fact{ + {Kind: facts.KindModule, Name: "Pkg/Sources/A", Props: map[string]any{"language": "swift", "spm_target": "A"}}, + {Kind: facts.KindDependency, Name: "Pkg/Sources/B -> Pkg/Sources/A", File: "Pkg/Sources/B/x.swift", + Props: map[string]any{"language": "swift", "internal": true}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: "Pkg/Sources/A"}}}, + } + resolveImports(ff) + dep := ff[1] + if importTargetOf(dep) != "Pkg/Sources/A" { + t.Errorf("path target should be unchanged, got %q", importTargetOf(dep)) + } + if sourceOf(dep) != "internal" { + t.Errorf("pass-2 path dep should be source=internal, got %q", sourceOf(dep)) + } +} + +// TestExtract_SwiftImportResolvesInternal is the end-to-end guard over a real SPM +// manifest repo: a bare `import ` must resolve to that target's module dir. +func TestExtract_SwiftImportResolvesInternal(t *testing.T) { + manifest := `// swift-tools-version:5.9 +import PackageDescription +let package = Package( + name: "Mods", + targets: [ + .target(name: "Core"), + .target(name: "Feature", dependencies: ["Core"]), + ] +) +` + repo, files := writeManifestRepo(t, manifest, []string{"Core", "Feature"}) + + // Add a Feature source file that imports Core and Foundation. + featureFile := "Packages/Mods/Sources/Feature/View.swift" + mustWrite(t, repo, featureFile, "import Foundation\nimport Core\n\npublic struct View {}\n") + files = append(files, featureFile) + + ff, err := New().Extract(context.Background(), repo, files) + if err != nil { + t.Fatalf("Extract: %v", err) + } + + moduleNames := map[string]bool{} + for _, f := range ff { + if f.Kind == facts.KindModule { + moduleNames[f.Name] = true + } + } + + var importCoreResolved bool + for _, f := range ff { + if f.Kind != facts.KindDependency { + continue + } + for _, r := range f.Relations { + if r.Kind == facts.RelImports && r.Target == "Packages/Mods/Sources/Core" && moduleNames[r.Target] { + if sourceOf(f) == "internal" { + importCoreResolved = true + } + } + } + } + if !importCoreResolved { + t.Errorf("`import Core` should resolve to module Packages/Mods/Sources/Core (source=internal); modules: %v", moduleNames) + } +} diff --git a/internal/extractors/swiftextractor/swift.go b/internal/extractors/swiftextractor/swift.go index 4fd0348..3c7924e 100644 --- a/internal/extractors/swiftextractor/swift.go +++ b/internal/extractors/swiftextractor/swift.go @@ -207,6 +207,10 @@ func (e *SwiftExtractor) Extract(ctx context.Context, repoPath string, files []s } } + // Resolve bare `import X` targets to SPM module dirs and classify + // stdlib/external, now that all module facts (incl. SPM targets) exist. + resolveImports(allFacts) + return allFacts, nil } diff --git a/pkg/explain/explain.go b/pkg/explain/explain.go new file mode 100644 index 0000000..f93a0b7 --- /dev/null +++ b/pkg/explain/explain.go @@ -0,0 +1,359 @@ +// Package explain produces a human-readable statistical summary of an Enola +// architectural snapshot — the data behind `enola --explain `. +// +// It is intentionally a public package (not internal/) so that enola-enterprise +// can reuse the base Report and append its own license-gated sections (dead code, +// package metrics) before rendering. Compute works purely off the exported +// bootstrap.Engine API, so it sees whatever the engine currently holds: run +// GenerateSnapshot (or auto-load a snapshot) first. +package explain + +import ( + "sort" + "strconv" + "strings" + + "github.com/enola-labs/enola/internal/facts" + "github.com/enola-labs/enola/pkg/bootstrap" +) + +// Criticality thresholds for a module hotspot, scored by fan-in + fan-out. +// Mirrors the llm_context renderer so "critical module" means the same thing +// everywhere. +const ( + criticalHigh = 10 + criticalMedium = 5 +) + +// blastDepth / blastNodes bound the reverse reachability used to estimate a +// hotspot's blast radius (the impact_analysis number). Kept modest so --explain +// stays fast even on large repos; the total is still accurate within the depth. +const ( + blastDepth = 3 + blastNodes = 500 + topHotspots = 8 +) + +// LabelCount is a named tally (a kind, a symbol kind, an HTTP method, …). +type LabelCount struct { + Label string `json:"label"` + Count int `json:"count"` +} + +// Hotspot is a module ranked by coupling, with its estimated change blast radius. +type Hotspot struct { + Module string `json:"module"` + FanIn int `json:"fan_in"` + FanOut int `json:"fan_out"` + Criticality string `json:"criticality"` // high | medium | low + BlastRadius int `json:"blast_radius"` // transitive reverse-dependents within blastDepth +} + +// Section is an extra block appended to the report by enterprise code. Body is +// pre-rendered text (the lines under the Title heading). +type Section struct { + Title string + Body string +} + +// Report is the full statistical picture of a snapshot. Fields are plain types +// only, so consumers in other modules (enola-enterprise) can read them without +// importing enola's internal packages. +type Report struct { + RepoPath string `json:"repo_path"` + GeneratedAt string `json:"generated_at,omitempty"` + Duration string `json:"duration,omitempty"` + Extractors []string `json:"extractors,omitempty"` + TotalFacts int `json:"total_facts"` + + KindCounts []LabelCount `json:"kind_counts"` // module/symbol/route/storage/dependency/service + SymbolKinds []LabelCount `json:"symbol_kinds"` // function/method/struct/… + DepSources []LabelCount `json:"dep_sources"` // external/internal/stdlib/… + + Routes int `json:"routes"` + RoutesByMethod []LabelCount `json:"routes_by_method,omitempty"` + Storage int `json:"storage"` + + Architecture string `json:"architecture,omitempty"` + ArchConfidence float64 `json:"architecture_confidence,omitempty"` + Cycles int `json:"cyclic_dependencies"` + LayerViolations int `json:"layer_violations"` + CrossRepoEdges int `json:"cross_repo_edges"` + + Modules int `json:"modules"` + HighCriticality int `json:"high_criticality"` + MediumCriticality int `json:"medium_criticality"` + Hotspots []Hotspot `json:"hotspots,omitempty"` + + // CouplingUnresolved is true when dependency facts exist but none of their + // import edges resolved to a module — coupling analysis is unavailable, not + // genuinely zero. The renderer surfaces this as a note. + CouplingUnresolved bool `json:"coupling_unresolved,omitempty"` + + // ExtraSections are appended (e.g. by enterprise) and rendered after the + // base report. + ExtraSections []Section `json:"-"` +} + +// Compute reads the engine's current fact store and snapshot and builds a Report. +// It does not generate a snapshot — callers do that first. +func Compute(eng *bootstrap.Engine) *Report { + store := eng.Store() + snap := eng.Snapshot() + + r := &Report{TotalFacts: store.Count()} + if snap != nil { + r.RepoPath = snap.Meta.RepoPath + r.GeneratedAt = snap.Meta.GeneratedAt + r.Duration = snap.Meta.Duration + r.Extractors = snap.Meta.Extractors + } + + // Architectural-kind tallies, in the canonical order from ARCHITECTURE.md. + for _, k := range []string{ + facts.KindModule, facts.KindSymbol, facts.KindRoute, + facts.KindStorage, facts.KindDependency, facts.KindService, + } { + if n := len(store.ByKind(k)); n > 0 { + r.KindCounts = append(r.KindCounts, LabelCount{Label: k, Count: n}) + } + } + + // Symbol-kind breakdown (function/method/struct/…). + skCount := map[string]int{} + for _, f := range store.ByKind(facts.KindSymbol) { + sk, _ := f.Props["symbol_kind"].(string) + if sk == "" { + sk = "unknown" + } + skCount[sk]++ + } + r.SymbolKinds = sortedCounts(skCount) + + // Routes, broken down by HTTP method. + routes := store.ByKind(facts.KindRoute) + r.Routes = len(routes) + methodCount := map[string]int{} + for _, f := range routes { + m, _ := f.Props["method"].(string) + if m == "" { + m = "(unspecified)" + } else { + m = strings.ToUpper(m) + } + methodCount[m]++ + } + if r.Routes > 0 { + r.RoutesByMethod = sortedCounts(methodCount) + } + + r.Storage = len(store.ByKind(facts.KindStorage)) + + // Dependency facts grouped by their declared source (external/internal/stdlib). + srcCount := map[string]int{} + for _, f := range store.ByKind(facts.KindDependency) { + s, _ := f.Props["source"].(string) + if s == "" { + s = "unclassified" + } + srcCount[s]++ + } + if len(srcCount) > 0 { + r.DepSources = sortedCounts(srcCount) + } + + r.Modules = len(store.ByKind(facts.KindModule)) + + // Insight-derived numbers: architecture pattern, cycles, layer violations, + // cross-repo edges. Titles are matched against the explainer formats. + if snap != nil { + for _, in := range snap.Insights { + switch { + case strings.HasPrefix(in.Title, "Cyclic dependency"): + r.Cycles++ + case strings.HasPrefix(in.Title, "Layer violation"): + r.LayerViolations++ + case strings.HasPrefix(in.Title, "Architecture pattern:"): + r.Architecture = strings.TrimSpace(strings.TrimPrefix(in.Title, "Architecture pattern:")) + r.ArchConfidence = in.Confidence + case strings.HasPrefix(in.Title, "Cross-repo dependencies"): + r.CrossRepoEdges = firstParenInt(in.Title) + } + } + } + + computeHotspots(store, r) + return r +} + +// computeHotspots ranks modules by fan-in + fan-out (the same coupling signal as +// the llm_context "Critical Modules" table) and estimates each top module's +// change blast radius via reverse graph reachability. +func computeHotspots(store *facts.Store, r *Report) { + modules := map[string]bool{} + for _, f := range store.ByKind(facts.KindModule) { + modules[f.Name] = true + } + + fanIn := map[string]int{} + fanOut := map[string]int{} + resolvedEdges := 0 + deps := store.ByKind(facts.KindDependency) + for _, dep := range deps { + src := fileDir(dep.File) + for _, rel := range dep.Relations { + if rel.Kind != facts.RelImports { + continue + } + // Resolve the import target to its nearest enclosing module. Some + // extractors (e.g. Kotlin) emit type-level targets one segment below the + // module dir; graph.go and the package-metrics tool already walk up, so + // resolve here too rather than requiring an exact module match. External + // targets are dotted (no '/'), so the walk-up finds nothing and they are + // correctly ignored. + if dst := resolveToModule(rel.Target, modules); dst != "" { + fanOut[src]++ + fanIn[dst]++ + resolvedEdges++ + } + } + } + + // Dependency facts exist but nothing resolved to a module: coupling could not + // be computed (e.g. an extractor whose import targets don't match module + // names). Flag it so the renderer says so rather than implying zero coupling. + if len(deps) > 0 && resolvedEdges == 0 { + r.CouplingUnresolved = true + } + + type scored struct { + name string + fanIn, fanOut int + score int + } + var ranked []scored + for mod := range modules { + s := scored{name: mod, fanIn: fanIn[mod], fanOut: fanOut[mod], score: fanIn[mod] + fanOut[mod]} + if s.score == 0 { + continue + } + switch { + case s.score >= criticalHigh: + r.HighCriticality++ + case s.score >= criticalMedium: + r.MediumCriticality++ + } + ranked = append(ranked, s) + } + + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].score != ranked[j].score { + return ranked[i].score > ranked[j].score + } + return ranked[i].name < ranked[j].name // stable, deterministic + }) + + graph := store.Graph() + limit := topHotspots + if len(ranked) < limit { + limit = len(ranked) + } + for _, s := range ranked[:limit] { + h := Hotspot{ + Module: s.name, + FanIn: s.fanIn, + FanOut: s.fanOut, + Criticality: criticalityLabel(s.score), + } + if graph != nil { + h.BlastRadius = graph.ImpactSet(s.name, blastDepth, blastNodes, false).TotalDependents + } + r.Hotspots = append(r.Hotspots, h) + } +} + +func criticalityLabel(score int) string { + switch { + case score >= criticalHigh: + return "high" + case score >= criticalMedium: + return "medium" + default: + return "low" + } +} + +// sortedCounts converts a tally map into a slice ordered by count desc, then +// label asc, for deterministic output. +func sortedCounts(m map[string]int) []LabelCount { + out := make([]LabelCount, 0, len(m)) + for k, v := range m { + out = append(out, LabelCount{Label: k, Count: v}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Label < out[j].Label + }) + return out +} + +// firstParenInt extracts the first integer appearing inside parentheses, e.g. +// "Cross-repo dependencies (7 edges)" -> 7. Returns 0 if none is found. +func firstParenInt(s string) int { + open := strings.IndexByte(s, '(') + if open < 0 { + return 0 + } + rest := s[open+1:] + digits := strings.Builder{} + for _, ch := range rest { + if ch >= '0' && ch <= '9' { + digits.WriteRune(ch) + } else if digits.Len() > 0 { + break + } + } + if digits.Len() == 0 { + return 0 + } + n, _ := strconv.Atoi(digits.String()) + return n +} + +// resolveToModule returns the nearest enclosing module of target: target itself if +// it is a module, else its closest ancestor directory that is. Returns "" if none. +// Mirrors graph.go's resolveToModule (unexported there), so hotspot coupling sees +// the same edges as traversal and package metrics. +func resolveToModule(target string, modules map[string]bool) string { + cur := target + for cur != "" { + if modules[cur] { + return cur + } + i := strings.LastIndex(cur, "/") + if i < 0 { + return "" + } + cur = cur[:i] + } + return "" +} + +// fileDir returns the directory portion of a repo-relative file path (the module +// a fact belongs to). Mirrors the llm_context renderer. +func fileDir(file string) string { + parts := strings.Split(file, "/") + if len(parts) <= 1 { + return "." + } + return strings.Join(parts[:len(parts)-1], "/") +} + +// AddSection appends an extra section (used by enterprise code) and returns the +// report for chaining. +func (r *Report) AddSection(title, body string) *Report { + r.ExtraSections = append(r.ExtraSections, Section{Title: title, Body: body}) + return r +} diff --git a/pkg/explain/explain_test.go b/pkg/explain/explain_test.go new file mode 100644 index 0000000..7014e87 --- /dev/null +++ b/pkg/explain/explain_test.go @@ -0,0 +1,346 @@ +package explain + +import ( + "strings" + "testing" + + "github.com/enola-labs/enola/internal/facts" + "github.com/enola-labs/enola/pkg/bootstrap" +) + +// newTestEngine builds a bootstrap.Engine with no config file (falls back to +// defaults) so tests can populate its store directly. +func newTestEngine(t *testing.T) *bootstrap.Engine { + t.Helper() + eng, _, err := bootstrap.NewEngine(bootstrap.Options{ConfigPath: "/nonexistent/mcp-arch.yaml"}) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return eng +} + +// fixtureFacts builds a small but representative fact set: two modules, a few +// symbols of distinct kinds, a route, a storage table, and dependency edges that +// make module "internal/b" a coupling hotspot (fan-in 6). +func fixtureFacts() []facts.Fact { + ff := []facts.Fact{ + {Kind: facts.KindModule, Name: "internal/a", File: "internal/a"}, + {Kind: facts.KindModule, Name: "internal/b", File: "internal/b"}, + {Kind: facts.KindSymbol, Name: "internal/a.DoThing", File: "internal/a/x.go", Line: 10, + Props: map[string]any{"symbol_kind": facts.SymbolFunc}, + Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: "internal/a"}, + {Kind: facts.RelCalls, Target: "internal/b.Helper"}}}, + {Kind: facts.KindSymbol, Name: "internal/b.Helper", File: "internal/b/y.go", Line: 5, + Props: map[string]any{"symbol_kind": facts.SymbolFunc}, + Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: "internal/b"}}}, + {Kind: facts.KindSymbol, Name: "internal/b.Store", File: "internal/b/y.go", Line: 20, + Props: map[string]any{"symbol_kind": facts.SymbolStruct}, + Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: "internal/b"}}}, + {Kind: facts.KindSymbol, Name: "internal/b.Reader", File: "internal/b/y.go", Line: 30, + Props: map[string]any{"symbol_kind": facts.SymbolInterface}, + Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: "internal/b"}}}, + {Kind: facts.KindRoute, Name: "GET /things", Props: map[string]any{"method": "get"}}, + {Kind: facts.KindStorage, Name: "things", File: "internal/b/y.go"}, + } + + // Six modules each importing internal/b → fan-in 6 → medium criticality. + for _, src := range []string{"internal/a", "internal/c", "internal/d", "internal/e", "internal/f", "internal/g"} { + ff = append(ff, facts.Fact{ + Kind: facts.KindDependency, + Name: src + " -> internal/b", + File: src + "/dep.go", + Props: map[string]any{"source": "internal"}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: "internal/b"}}, + }) + } + // One external dependency. + ff = append(ff, facts.Fact{ + Kind: facts.KindDependency, Name: "internal/a -> github.com/x/y", File: "internal/a/x.go", + Props: map[string]any{"source": "external"}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: "github.com/x/y"}}, + }) + return ff +} + +func computeFixture(t *testing.T) *Report { + t.Helper() + eng := newTestEngine(t) + eng.Store().Add(fixtureFacts()...) + eng.Store().BuildGraph() + eng.SetSnapshot(&facts.Snapshot{ + Meta: facts.SnapshotMeta{ + RepoPath: "/repo/demo", + GeneratedAt: "2026-06-17T00:00:00Z", + Duration: "42ms", + Extractors: []string{"go"}, + }, + Insights: []facts.Insight{ + {Title: "Architecture pattern: Go-standard", Confidence: 0.85}, + {Title: "Cyclic dependency detected (3 modules)", Confidence: 1.0}, + {Title: "Layer violation: domain -> adapter", Confidence: 0.5}, + {Title: "Cross-repo dependencies (4 edges)", Confidence: 1.0}, + }, + }) + return Compute(eng) +} + +func TestCompute_KindCounts(t *testing.T) { + r := computeFixture(t) + + want := map[string]int{ + facts.KindModule: 2, + facts.KindSymbol: 4, + facts.KindRoute: 1, + facts.KindStorage: 1, + facts.KindDependency: 7, + } + got := map[string]int{} + for _, kc := range r.KindCounts { + got[kc.Label] = kc.Count + } + for k, n := range want { + if got[k] != n { + t.Errorf("kind %q: got %d, want %d", k, got[k], n) + } + } + if _, ok := got[facts.KindService]; ok { + t.Errorf("service kind should be omitted when zero") + } +} + +func TestCompute_SymbolKinds(t *testing.T) { + r := computeFixture(t) + got := map[string]int{} + for _, sk := range r.SymbolKinds { + got[sk.Label] = sk.Count + } + if got[facts.SymbolFunc] != 2 { + t.Errorf("function count: got %d, want 2", got[facts.SymbolFunc]) + } + if got[facts.SymbolStruct] != 1 || got[facts.SymbolInterface] != 1 { + t.Errorf("struct/interface counts wrong: %+v", got) + } + // Descending order: function (2) should come before struct/interface (1). + if r.SymbolKinds[0].Label != facts.SymbolFunc { + t.Errorf("symbol kinds not sorted by count desc: %+v", r.SymbolKinds) + } +} + +func TestCompute_RoutesStorageDeps(t *testing.T) { + r := computeFixture(t) + if r.Routes != 1 { + t.Errorf("routes: got %d, want 1", r.Routes) + } + if len(r.RoutesByMethod) != 1 || r.RoutesByMethod[0].Label != "GET" { + t.Errorf("routes by method wrong: %+v", r.RoutesByMethod) + } + if r.Storage != 1 { + t.Errorf("storage: got %d, want 1", r.Storage) + } + src := map[string]int{} + for _, d := range r.DepSources { + src[d.Label] = d.Count + } + if src["internal"] != 6 || src["external"] != 1 { + t.Errorf("dep sources wrong: %+v", src) + } +} + +func TestCompute_Insights(t *testing.T) { + r := computeFixture(t) + if r.Architecture != "Go-standard" { + t.Errorf("architecture: got %q, want Go-standard", r.Architecture) + } + if r.ArchConfidence != 0.85 { + t.Errorf("arch confidence: got %v, want 0.85", r.ArchConfidence) + } + if r.Cycles != 1 { + t.Errorf("cycles: got %d, want 1", r.Cycles) + } + if r.LayerViolations != 1 { + t.Errorf("layer violations: got %d, want 1", r.LayerViolations) + } + if r.CrossRepoEdges != 4 { + t.Errorf("cross-repo edges: got %d, want 4", r.CrossRepoEdges) + } +} + +func TestCompute_Hotspots(t *testing.T) { + r := computeFixture(t) + if len(r.Hotspots) == 0 { + t.Fatal("expected at least one hotspot") + } + // internal/b has fan-in 6 → medium criticality, top of the list. + top := r.Hotspots[0] + if top.Module != "internal/b" { + t.Errorf("top hotspot: got %q, want internal/b", top.Module) + } + if top.FanIn != 6 { + t.Errorf("internal/b fan-in: got %d, want 6", top.FanIn) + } + if top.Criticality != "medium" { + t.Errorf("internal/b criticality: got %q, want medium", top.Criticality) + } + if r.MediumCriticality < 1 { + t.Errorf("expected MediumCriticality >= 1, got %d", r.MediumCriticality) + } + // internal/b is reached (reverse) by the importing modules → blast radius > 0. + if top.BlastRadius <= 0 { + t.Errorf("expected positive blast radius for internal/b, got %d", top.BlastRadius) + } +} + +func TestRender_ContainsHeadlineNumbers(t *testing.T) { + r := computeFixture(t) + out := r.Render() + for _, want := range []string{ + "Repository explanation: /repo/demo", + "Architectural kinds", + "Symbol breakdown", + "Impact analysis (hotspots)", + "Go-standard", + "internal/b", + } { + if !strings.Contains(out, want) { + t.Errorf("render output missing %q\n---\n%s", want, out) + } + } +} + +func TestRender_ExtraSections(t *testing.T) { + r := computeFixture(t) + r.AddSection("Dead code (enterprise)", " potential dead code 3\n") + out := r.Render() + if !strings.Contains(out, "Dead code (enterprise)") { + t.Errorf("extra section title missing\n%s", out) + } + if !strings.Contains(out, "potential dead code") { + t.Errorf("extra section body missing\n%s", out) + } +} + +// unresolvedFixtureFacts mimics a Python snapshot before import resolution: +// module names are slash paths but dependency import targets are raw dotted +// paths that match no module. +func unresolvedFixtureFacts() []facts.Fact { + return []facts.Fact{ + {Kind: facts.KindModule, Name: "src/airflow/models", File: "src/airflow/models"}, + {Kind: facts.KindModule, Name: "src/airflow/utils", File: "src/airflow/utils"}, + {Kind: facts.KindDependency, Name: "src/airflow/utils -> airflow.models", + File: "src/airflow/utils/dates.py", + Relations: []facts.Relation{{Kind: facts.RelImports, Target: "airflow.models"}}}, + } +} + +func TestCompute_CouplingUnresolved(t *testing.T) { + eng := newTestEngine(t) + eng.Store().Add(unresolvedFixtureFacts()...) + eng.Store().BuildGraph() + eng.SetSnapshot(&facts.Snapshot{Meta: facts.SnapshotMeta{RepoPath: "/repo/py"}}) + r := Compute(eng) + + if !r.CouplingUnresolved { + t.Error("expected CouplingUnresolved=true when no import edge resolves") + } + if len(r.Hotspots) != 0 { + t.Errorf("expected no hotspots, got %d", len(r.Hotspots)) + } + if r.HighCriticality+r.MediumCriticality != 0 { + t.Errorf("expected zero criticality counts, got high=%d medium=%d", r.HighCriticality, r.MediumCriticality) + } +} + +func TestCompute_CouplingResolved_NoFlag(t *testing.T) { + // The standard fixture's dependency targets are slash module names → resolved. + r := computeFixture(t) + if r.CouplingUnresolved { + t.Error("CouplingUnresolved should be false when import edges resolve") + } +} + +func TestRender_CouplingUnresolvedNote(t *testing.T) { + eng := newTestEngine(t) + eng.Store().Add(unresolvedFixtureFacts()...) + eng.Store().BuildGraph() + eng.SetSnapshot(&facts.Snapshot{Meta: facts.SnapshotMeta{RepoPath: "/repo/py"}}) + out := Compute(eng).Render() + if !strings.Contains(out, "coupling could not be resolved") { + t.Errorf("expected unresolved-coupling note in output\n%s", out) + } + + // The standard (resolved) fixture must NOT carry the note. + if std := computeFixture(t).Render(); strings.Contains(std, "coupling could not be resolved") { + t.Errorf("resolved fixture should not show the note\n%s", std) + } +} + +// subModuleFixtureFacts mimics a Kotlin snapshot: the internal import Target is a +// type-level path one segment below the module dir (e.g. "a/b/SomeType"), and an +// external import is dotted. computeHotspots must walk up to module "a/b". +func subModuleFixtureFacts() []facts.Fact { + return []facts.Fact{ + {Kind: facts.KindModule, Name: "a/b", File: "a/b"}, + {Kind: facts.KindModule, Name: "a/c", File: "a/c"}, + {Kind: facts.KindDependency, Name: "a/c -> a/b/SomeType", File: "a/c/User.kt", + Props: map[string]any{"source": "internal"}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: "a/b/SomeType"}}}, + {Kind: facts.KindDependency, Name: "a/c -> org.ext.Foo", File: "a/c/User.kt", + Props: map[string]any{"source": "external"}, + Relations: []facts.Relation{{Kind: facts.RelImports, Target: "org.ext.Foo"}}}, + } +} + +func TestCompute_SubModuleTargetWalkUp(t *testing.T) { + eng := newTestEngine(t) + eng.Store().Add(subModuleFixtureFacts()...) + eng.Store().BuildGraph() + eng.SetSnapshot(&facts.Snapshot{Meta: facts.SnapshotMeta{RepoPath: "/repo/kt"}}) + r := Compute(eng) + + if r.CouplingUnresolved { + t.Error("sub-module import target should resolve via walk-up, not flag unresolved") + } + if len(r.Hotspots) == 0 { + t.Fatal("expected a hotspot for module a/b") + } + if r.Hotspots[0].Module != "a/b" || r.Hotspots[0].FanIn != 1 { + t.Errorf("expected a/b fan-in 1, got %+v", r.Hotspots[0]) + } +} + +func TestResolveToModule(t *testing.T) { + mods := map[string]bool{"a/b": true, "a": true} + cases := map[string]string{ + "a/b/SomeType": "a/b", // walk up one segment + "a/b": "a/b", // exact module + "a/x/y": "a", // walk up to ancestor module + "org.ext.Foo": "", // dotted external, no '/' module + "zzz": "", // unknown + } + for in, want := range cases { + if got := resolveToModule(in, mods); got != want { + t.Errorf("resolveToModule(%q) = %q, want %q", in, got, want) + } + } +} + +func TestHelpers(t *testing.T) { + if firstParenInt("Cross-repo dependencies (7 edges)") != 7 { + t.Error("firstParenInt failed for 7") + } + if firstParenInt("no parens here") != 0 { + t.Error("firstParenInt should be 0 with no parens") + } + if firstParenInt("Cyclic dependency detected (12 modules)") != 12 { + t.Error("firstParenInt failed for 12") + } + if criticalityLabel(10) != "high" || criticalityLabel(5) != "medium" || criticalityLabel(1) != "low" { + t.Error("criticalityLabel thresholds wrong") + } + if fileDir("internal/a/x.go") != "internal/a" { + t.Errorf("fileDir wrong: %q", fileDir("internal/a/x.go")) + } + if fileDir("main.go") != "." { + t.Errorf("fileDir of bare file should be '.', got %q", fileDir("main.go")) + } +} diff --git a/pkg/explain/render.go b/pkg/explain/render.go new file mode 100644 index 0000000..1d90849 --- /dev/null +++ b/pkg/explain/render.go @@ -0,0 +1,137 @@ +package explain + +import ( + "fmt" + "strings" +) + +// Render returns the human-readable report as a single string, ready to print to +// a terminal. Sections are plain aligned text (not markdown) so they read well +// directly in a shell. +func (r *Report) Render() string { + var b strings.Builder + + repo := r.RepoPath + if repo == "" { + repo = "(unknown)" + } + rule := strings.Repeat("═", 60) + fmt.Fprintf(&b, "%s\n", rule) + fmt.Fprintf(&b, " Repository explanation: %s\n", repo) + fmt.Fprintf(&b, "%s\n\n", rule) + + // Overview + b.WriteString("Overview\n") + if r.GeneratedAt != "" { + kv(&b, "Generated", r.GeneratedAt) + } + if r.Duration != "" { + kv(&b, "Analysis time", r.Duration) + } + if len(r.Extractors) > 0 { + kv(&b, "Languages", strings.Join(r.Extractors, ", ")) + } + kv(&b, "Total facts", fmt.Sprintf("%d", r.TotalFacts)) + b.WriteString("\n") + + // Architectural kinds + b.WriteString("Architectural kinds\n") + if len(r.KindCounts) == 0 { + b.WriteString(" (none)\n") + } + for _, kc := range r.KindCounts { + countRow(&b, kc.Label, kc.Count) + } + b.WriteString("\n") + + // Symbol breakdown + if len(r.SymbolKinds) > 0 { + b.WriteString("Symbol breakdown\n") + for _, sk := range r.SymbolKinds { + countRow(&b, sk.Label, sk.Count) + } + b.WriteString("\n") + } + + // API surface + b.WriteString("API & data surface\n") + countRow(&b, "routes", r.Routes) + for _, m := range r.RoutesByMethod { + countRow(&b, " "+m.Label, m.Count) + } + countRow(&b, "storage", r.Storage) + b.WriteString("\n") + + // Dependencies + if len(r.DepSources) > 0 { + b.WriteString("Dependencies\n") + for _, d := range r.DepSources { + countRow(&b, d.Label, d.Count) + } + b.WriteString("\n") + } + + // Architecture insights + b.WriteString("Architecture\n") + if r.Architecture != "" { + kv(&b, "Pattern", fmt.Sprintf("%s (%.0f%% confidence)", r.Architecture, r.ArchConfidence*100)) + } else { + kv(&b, "Pattern", "(none detected)") + } + countRow(&b, "cyclic dependencies", r.Cycles) + countRow(&b, "layer violations", r.LayerViolations) + if r.CrossRepoEdges > 0 { + countRow(&b, "cross-repo edges", r.CrossRepoEdges) + } + b.WriteString("\n") + + // Impact / hotspots + b.WriteString("Impact analysis (hotspots)\n") + countRow(&b, "coupled modules", r.HighCriticality+r.MediumCriticality) + countRow(&b, " high criticality", r.HighCriticality) + countRow(&b, " medium criticality", r.MediumCriticality) + if r.CouplingUnresolved { + b.WriteString(" Note: coupling could not be resolved from the import graph\n") + b.WriteString(" (imports did not match any module).\n") + } + if len(r.Hotspots) > 0 { + b.WriteString(" Top hotspots (by coupling):\n") + fmt.Fprintf(&b, " %-32s %7s %8s %-8s %s\n", "module", "fan-in", "fan-out", "crit", "blast radius") + for _, h := range r.Hotspots { + fmt.Fprintf(&b, " %-32s %7d %8d %-8s %d\n", + truncate(h.Module, 32), h.FanIn, h.FanOut, h.Criticality, h.BlastRadius) + } + } + b.WriteString("\n") + + // Enterprise / extra sections + for _, s := range r.ExtraSections { + fmt.Fprintf(&b, "%s\n", s.Title) + body := s.Body + if !strings.HasSuffix(body, "\n") { + body += "\n" + } + b.WriteString(body) + b.WriteString("\n") + } + + return b.String() +} + +func kv(b *strings.Builder, key, val string) { + fmt.Fprintf(b, " %-20s %s\n", key+":", val) +} + +func countRow(b *strings.Builder, label string, n int) { + fmt.Fprintf(b, " %-22s %6d\n", label, n) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 1 { + return s[:max] + } + return s[:max-1] + "…" +}