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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions internal/engine/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,42 @@ import (
// v49: Swift models XcodeGen test-bundle targets (bundle.unit-test/bundle.ui-testing) as one module each, so a test bundle's files (e.g. Tests/Core/**) collapse into a single module instead of exploding into per-leaf-directory modules; and every module fact now carries a normalized `module_role` prop (production/test/tooling/unknown) — derived from the XcodeGen target type, the SPM target vs testTarget call, or a path heuristic for leaf-directory fallback — so package-metrics and other analyses can measure the production population without re-parsing manifests.
// v50: the `module_role` prop is now emitted by the Ruby extractor too (packwerk packages → production; leaf-directory modules → path heuristic), and the path heuristic was hoisted to facts.ModuleRoleForPath and broadened to common cross-language conventions (spec/test/tests + scripts/bin/fastlane/ci_scripts), so Ruby build-tooling modules (fastlane/, Scripts/) are classified as tooling rather than defaulting to the production population.
// v51: Swift no longer emits type-reference-derived module→module dependency edges for files that belong to a resolved SPM/XcodeGen target — those files' cross-module deps are captured completely by their `import X` statements plus the declared target graph, whereas the type-reference pass resolved bare short names through a collision-prone global index (Swift namespaces nested types, so names like Event/State/Coordinator recur across targets) and fabricated impossible back-edges (a Foundation-level target "importing" a feature target) that produced a false module cycle. For loose Swift projects (leaf-directory fallback, no target graph) the pass still runs but now skips any type name defined in more than one module. Fixes the false Swift dependency cycle.
const cacheVersion = "v51"
// v52: Kotlin emits SymbolMethod (not SymbolFunc) for functions declared inside a class/object (parity with Go/Java), keeping member functions out of the high-confidence orphan bucket; records a short-name RelCalls edge for every navigation expression (receiver method call `repo.getUser()` and property/field access `slot.uniqueId`), which the short-name-matching dead-code detector needs to see live members as used; and tags `override` methods and Dagger/Hilt `@Provides`/`@Binds` methods with `override`/`di_provider` props so framework/DI entry points are excluded from orphan reporting. Fixes the mass Kotlin/Android dead-code false positives (thousands of live Retrofit/lifecycle/interface members reported as high-confidence orphans).
// v53: Kotlin base-package detection now matches Groovy build scripts (`namespace 'x'`, single quotes) in addition to Kotlin-DSL (`namespace = "x"`). A double-quote-only regex left the base package empty for `.gradle` (Groovy) projects, so every in-repo import resolved as external and bare calls to imported top-level/extension functions (`formatPrettyDate(...)`, `setMargin(...)`) emitted no call edge — reporting live utilities as high-confidence orphans.
// v54: Kotlin now walks calls that live OUTSIDE a function body — function default-parameter values (`fun f(x = helper())`) and supertype constructor-delegation arguments (`class NpeId(id) : Enrichment(npeEntity(id))`, and the object equivalent). These were previously skipped, so a helper/factory referenced only from a default value or a base-class initializer was mis-reported as a high-confidence orphan (e.g. Snowplow entity builders, Compose default-arg providers).
// v55: Kotlin captures callable references (`::foo`, `Type::foo`, e.g. `onClick = ::doNothing`, `.map(::helper)`) as short-name RelCalls edges. A function referenced only as a method reference (never called directly) was previously mis-reported as a high-confidence orphan.
// v56: Kotlin/Java perf-fact precision — (1) recursion (`recursive_self`) is now argument-count aware: a call sharing the enclosing function's name is only flagged recursion when its arg count matches the parameter count, so a call to a same-named overload (`updateItem(x)` → `updateItem(i, x)`, `onChangeStarted(2)` override → `onChangeStarted(3)`) is no longer read as self-recursion — the dominant Kotlin/Android recursion false positive (Conductor `onChange*` lifecycle). (2) Kotlin RxJava/coroutine-Flow chains no longer inflate loop_depth: in a reactive function (reactive return type Single/Observable/Maybe/Flowable/Completable/Flow, or body reactive operators subscribeOn/observeOn/applySchedulers/andThen/.subscribe/flowOn/.collect/…), the ambiguous operators (map/flatMap/filter/fold/reduce/onEach) are stream transforms, not per-element collection loops, so a `Single.flatMap { … .map { } }` is no longer a false O(n²)/O(n³). Fixes the analyze_performance false positives on this RxJava-heavy codebase.
// v57: Kotlin/Java recursion also clears the arity-matched case where an `override` delegates to a same-name, same-arity overload declared in a parent (invisible here): a body that calls `super.<self>()` marks the sibling `<self>(…)` call as delegation, not recursion (fixes the residual Conductor `onChangeEnded` false positives). And Kotlin methods now carry `io_direct`/`performs_io` when annotated as a Retrofit endpoint (@GET/@POST/@PUT/@DELETE/@PATCH/@HEAD/@OPTIONS/@HTTP) or a Room DAO op (@Query/@Insert/@Update/@Upsert/@Delete/@RawQuery) — a precise per-method I/O identity that lets analyze_performance flag a per-iteration call to a real network/DB method as a genuine N+1 (ranked high) without relying on the cross-language keyword guess.
// v58: Kotlin/Java multi-module import resolution — imports are now resolved via a
// cross-language declared-package→directory index (built from every non-test .kt AND
// .java file) instead of assuming a single global source root. In a multi-module
// Gradle project where several modules root packages at the same prefix (app/, api/,
// business/ all under de.foo.*), the old Kotlin resolver mapped every internal import
// under the single most common source root (the app module), collapsing all
// cross-module afferent coupling onto the app package (bogus Ca god-package) and
// starving library modules of Ca (falsely "useless" in package-metrics). Now an
// import resolves to the module that actually declares the package. The Java extractor
// seeds its FQN resolver with the same cross-language index so Java→Kotlin imports no
// longer drop. Kotlin & Java module facts now carry `module_role` (test for
// src/test & src/androidTest, else production) so package-metrics excludes test source
// sets; and Kotlin `sealed` classes are marked `abstract` so abstractness (A) counts
// them (they are non-instantiable), fixing inflated Distance / false "rigid" findings.
// v59: the package index now prefers a main source set (…/src/main/…) over a Gradle
// build-variant source set (src/debug, src/release, src/staging) when both declare the
// same package. An Android app's src/main and src/debug both declare the root
// application package; the v58 lexicographic tie-break wrongly mapped it to src/debug
// ('d' < 'm'), misrouting the whole app's afferent coupling onto the debug variant
// (a bogus Ca god-package). Imports of the root package now resolve to the main module.
// v60: package-metrics precision — (1) module_role now sub-token-matches compound test
// module names (split each path segment on -/_ and match an exact `test`/`tests` token),
// so Gradle test-automation modules that compile as src/main (release-tests, ui-test-utils,
// test-lab) are classified test rather than leaking into the production population, without
// misfiring on latest/contest/abtest. (2) Dagger/Hilt DI infrastructure is now tagged:
// @Component/@Subcomponent interfaces get `di_component`, @Module classes get `di_module`
// (Java & Kotlin); a Dagger @Component interface is no longer mislabeled a Spring component
// (disambiguated by interface-vs-class). Lets package-metrics exclude DI wiring from
// abstractness/type counts (a Dagger component package was falsely "useless").
const cacheVersion = "v60"

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down Expand Up @@ -169,15 +204,27 @@ func computeExtractorKeys(all []extractors.Extractor, files []string, hashes map
}

// Partition files: per-owner owned lists + the shared (un-owned) remainder.
// keyFiles is owned ∪ AffectsKey — the full set whose contents feed the key,
// so a cross-language file that a KeyDependent extractor reads (but does not
// own) still invalidates its cache. Ownership (and thus the shared remainder)
// is decided purely by OwnsFile; AffectsKey only widens the key, never the
// ownership partition.
owned := map[string][]string{}
keyFiles := map[string][]string{}
var shared []string
for _, f := range files {
ownedByAny := false
for name, fo := range owners {
if fo.OwnsFile(f) {
owns := fo.OwnsFile(f)
if owns {
owned[name] = append(owned[name], f)
ownedByAny = true
}
if owns {
keyFiles[name] = append(keyFiles[name], f)
} else if kd, ok := fo.(plugin.KeyDependent); ok && kd.AffectsKey(f) {
keyFiles[name] = append(keyFiles[name], f)
}
}
if !ownedByAny {
shared = append(shared, f)
Expand All @@ -189,7 +236,7 @@ func computeExtractorKeys(all []extractors.Extractor, files []string, hashes map
for name := range owners {
h := sha256.New()
h.Write([]byte(cacheVersion + "\x00" + name + "\x00" + sharedHash + "\x00"))
h.Write([]byte(hashFileSet(owned[name], hashes)))
h.Write([]byte(hashFileSet(keyFiles[name], hashes)))
keys[name] = hex.EncodeToString(h.Sum(nil))
}
return keys
Expand Down
26 changes: 23 additions & 3 deletions internal/extractors/javaextractor/java.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"

"github.com/enola-labs/enola/internal/extractors/jvmsrc"
"github.com/enola-labs/enola/internal/facts"
"github.com/enola-labs/enola/internal/parallel"
)
Expand Down Expand Up @@ -72,7 +73,12 @@ func (e *JavaExtractor) Extract(ctx context.Context, repoPath string, files []st
modules[filepath.Dir(javaFiles[i])] = true
}

canonicalizeTargets(allFacts)
// Cross-language package index (.kt AND .java) so a Java import of a Kotlin
// type resolves to the module that declares it, instead of being dropped as
// external. Java→Java imports still resolve via the in-facts FQN index below;
// this only fills the cross-language gap.
packageIndex := jvmsrc.BuildPackageIndex(repoPath, files)
canonicalizeTargets(allFacts, packageIndex)
resolveTableConstants(allFacts)

for dir := range modules {
Expand All @@ -81,7 +87,8 @@ func (e *JavaExtractor) Extract(ctx context.Context, repoPath string, files []st
Name: dir,
File: dir,
Props: map[string]any{
"language": "java",
"language": "java",
facts.PropModuleRole: jvmsrc.ModuleRole(dir),
},
})
}
Expand All @@ -97,10 +104,16 @@ func (e *JavaExtractor) Extract(ctx context.Context, repoPath string, files []st
// - import dependency facts whose target FQN resolves to a declared type — or whose
// value names a known source package — are marked source="internal" and pointed at
// the owning module dir.
func canonicalizeTargets(allFacts []facts.Fact) {
func canonicalizeTargets(allFacts []facts.Fact, crossLangIndex map[string]string) {
typeIndex := make(map[string]string) // FQN -> "<dir>.<Type>" canonical name
typeDir := make(map[string]string) // FQN -> dir
packageDir := make(map[string]string)
// Seed with cross-language packages (e.g. Kotlin modules) so a Java import of
// a package we didn't index from .java files still resolves. Java-declared
// packages below take precedence (they overwrite these entries).
for pkg, dir := range crossLangIndex {
packageDir[pkg] = dir
}
for _, f := range allFacts {
if f.Kind != facts.KindSymbol {
continue
Expand Down Expand Up @@ -240,6 +253,13 @@ func isJavaFile(path string) bool {
// OwnsFile implements plugin.FileOwner for incremental caching.
func (e *JavaExtractor) OwnsFile(relFile string) bool { return isJavaFile(relFile) }

// AffectsKey implements plugin.KeyDependent: a .kt file's package declaration
// feeds the cross-language package index used to resolve Java imports of Kotlin
// types, so a change to any Kotlin source must invalidate the Java extractor's cache.
func (e *JavaExtractor) AffectsKey(relFile string) bool {
return strings.HasSuffix(strings.ToLower(relFile), ".kt")
}

// containsJavaSource reports whether any .java file exists under root within
// maxDepth directory levels. It returns on the first match and skips hidden and
// common build/dependency directories so it stays cheap on large repos.
Expand Down
66 changes: 59 additions & 7 deletions internal/extractors/javaextractor/java_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ type astWalker struct {
loopDepth int
selfName string
selfShort string
// selfParams is the enclosing method's declared parameter count. A resolved
// self-call is only genuine recursion when its argument count matches — otherwise
// it is a call to a same-named overload, not recursion.
selfParams int
}

// javaBodyMetrics accumulates per-method complexity signals during the single
Expand All @@ -88,9 +92,10 @@ type javaBodyMetrics struct {
loopDepth int // max loop nesting depth
loopCount int // number of loop constructs (syntactic + stream lambdas)
decisions int // decision points (cyclomatic = 1 + decisions)
callsInLoop []string // distinct call targets invoked at loop depth >= 1
inLoopSeen map[string]bool // dedup set for callsInLoop
recursive bool // body directly calls the enclosing method
callsInLoop []string // distinct call targets invoked at loop depth >= 1
inLoopSeen map[string]bool // dedup set for callsInLoop
recursive bool // body directly calls the enclosing method
sawSuperSelf bool // body calls super.<enclosingName>() (override delegation)
}

// javaIterators are Stream/Collection methods whose lambda argument runs once per
Expand Down Expand Up @@ -119,16 +124,53 @@ var javaCheapMethods = map[string]bool{

// recordCallMetrics notes a resolved call target against the current method's
// complexity metrics: flags direct recursion and records calls made inside loops.
func (w *astWalker) recordCallMetrics(target string) {
// argCount is the invocation's argument count; recursion is flagged only when it
// matches the enclosing method's parameter count, so a call to a same-named overload
// is not mistaken for self-recursion.
func (w *astWalker) recordCallMetrics(target string, argCount int) {
if w.metrics == nil || target == "" {
return
}
if target == w.selfName || target == w.selfShort {
if (target == w.selfName || target == w.selfShort) && argCount == w.selfParams {
w.metrics.recursive = true
}
w.recordInLoop(target)
}

// javaArgCount returns the number of arguments of a method_invocation node.
func javaArgCount(node *sitter.Node) int {
args := node.ChildByFieldName("arguments")
if args == nil {
return 0
}
n := 0
for i := uint(0); i < uint(args.ChildCount()); i++ {
if args.Child(i).IsNamed() {
n++
}
}
return n
}

// javaParamCount returns the declared parameter count of a method declaration node.
func javaParamCount(node *sitter.Node) int {
params := node.ChildByFieldName("parameters")
if params == nil {
params = findChildByKind(node, "formal_parameters")
}
if params == nil {
return 0
}
n := 0
for i := uint(0); i < uint(params.ChildCount()); i++ {
switch params.Child(i).Kind() {
case "formal_parameter", "spread_parameter":
n++
}
}
return n
}

// recordInLoop adds a target to calls_in_loop (deduped) when inside a loop, without
// the recursion check — used for raw instance-method names.
func (w *astWalker) recordInLoop(target string) {
Expand Down Expand Up @@ -525,10 +567,12 @@ func (w *astWalker) handleMethod(node *sitter.Node) {
// may be invalidated if the body walk grows w.out).
savedMetrics, savedDepth := w.metrics, w.loopDepth
savedName, savedShort := w.selfName, w.selfShort
savedParams := w.selfParams
w.metrics = &javaBodyMetrics{}
w.loopDepth = 0
w.selfName = f.Name
w.selfShort = name
w.selfParams = javaParamCount(node)
if body := node.ChildByFieldName("body"); body != nil {
w.walkForCalls(body)
}
Expand All @@ -544,11 +588,14 @@ func (w *astWalker) handleMethod(node *sitter.Node) {
if len(m.callsInLoop) > 0 {
props["calls_in_loop"] = m.callsInLoop
}
if m.recursive {
// A body that calls super.<self>() is an override delegating to a same-named
// overload, not genuine recursion — clear the arity-matched self-call flag.
if m.recursive && !m.sawSuperSelf {
props["recursive_self"] = true
}
w.metrics, w.loopDepth = savedMetrics, savedDepth
w.selfName, w.selfShort = savedName, savedShort
w.selfParams = savedParams
w.popOwner()
}

Expand Down Expand Up @@ -807,14 +854,19 @@ func (w *astWalker) handleInvocation(node *sitter.Node) {
// own methods. Calls on other receivers are left unresolved (the receiver's
// type is not tracked), matching the Kotlin extractor's conservative model.
isThis := obj != nil && nodeText(obj, w.src) == "this"
if obj != nil && w.metrics != nil && name == w.selfShort && nodeText(obj, w.src) == "super" {
// super.<self>() — an override delegating to its supertype. Note it so an
// arity-matched bare <self>(…) call is read as overload delegation, not recursion.
w.metrics.sawSuperSelf = true
}
if obj == nil || isThis {
if methods := w.currentMethods(); methods[name] {
target := w.dir + "." + w.enclosingType() + "." + name
owner.Relations = append(owner.Relations, facts.Relation{
Kind: facts.RelCalls,
Target: target,
})
w.recordCallMetrics(target)
w.recordCallMetrics(target, javaArgCount(node))
}
} else if w.metrics != nil && w.loopDepth > 0 && !javaCheapMethods[name] {
// Method call on a non-this receiver inside a loop (repo.findById(), …). No
Expand Down
15 changes: 15 additions & 0 deletions internal/extractors/javaextractor/spring.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ func findAnnotation(anns []javaAnnotation, name string) *javaAnnotation {
// classifyComponent tags a type symbol fact with framework/component props for
// Spring stereotypes and Dubbo SPI. It mutates f.Props in place.
func classifyComponent(f *facts.Fact, name string, annotations []javaAnnotation, supertypes []string) {
// Dagger/Hilt DI infrastructure. Dagger @Component/@Subcomponent are declared
// on INTERFACES, whereas Spring stereotypes are always concrete classes — so a
// @Component on an interface is Dagger, not Spring. This disambiguates the
// simple-name collision between dagger.Component and springframework…Component
// (annotations are matched by simple name) and keeps DI wiring out of the
// domain-architecture metrics. @Module classes are DI wiring regardless of kind.
if hasAnnotation(annotations, "Module") {
f.Props["di_module"] = true
}
if f.Props["symbol_kind"] == facts.SymbolInterface &&
(hasAnnotation(annotations, "Component") || hasAnnotation(annotations, "Subcomponent")) {
f.Props["di_component"] = true
return // do NOT fall through to the Spring stereotype switch (avoids mislabel)
}

switch {
case hasAnnotation(annotations, "RestController"):
f.Props["framework"] = "spring"
Expand Down
Loading
Loading