Skip to content

Commit 0272d63

Browse files
committed
Tune Ruby/Rails architecture findings to cut false positives
Rails snapshots produced noisy, often-wrong findings: Ruby modules are directories and coupling is synthesized from autoloaded constant references, so the module graph is nearly complete and bidirectional and the generic OOP/hexagonal explainers over-fire. - layers: re-level the Rails pattern to two tiers (delivery=controllers/ views, domain=everything else) so idiomatic model->service/job/helper references are no longer "violations"; only domain->delivery smells fire. Also classify interactors/presenters/serializers/notifiers/types. - god-class, hotspots: dedupe candidates by symbol name (a constant reopened across N files no longer yields N identical findings, e.g. RailsAdmin::Config::Actions x50) and cap god-class output at 25. - cycles: report SCCs larger than 8 modules as one low-confidence "highly coupled module cluster" note instead of a confidence-1.0 cyclic-dependency alarm; in autoloaded Rails a giant SCC is expected. - ruby extractor: tag synthetic coupling edges with coupling_kind and exclude ActiveRecord associations from cycle detection (has_many/ belongs_to pairs are bidirectional by nature); add framework constants (I18n, Rails, Logger, GlobalID, Mime) to the fan-in ignore list. - bump cacheVersion v94->v95 (+ cachecov coverage entry).
1 parent be76612 commit 0272d63

16 files changed

Lines changed: 473 additions & 45 deletions

File tree

internal/cachecov/coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ var versionCoverage = map[int][]string{
126126
92: {"TestRoutes_SymbolPathArg", "TestRoutes_ScopeBareSymbolPrefix", "TestRoutes_ResourcePathOverride"}, // Ruby symbol path args + scope :symbol + resource path: override
127127
93: {"TestSwitchReturns_MultiLineCaseLabels", "TestExtractEndpointFacts_MultiLineMethodCase"}, // Swift multi-line case-label method parsing
128128
94: {"TestExtractEndpointFacts_ConstantMethod"}, // Swift single-value (constant) method property
129+
95: {"TestResolveImports_CouplingKindTagged", "TestResolveImports_ReferenceBeatsAssociation"}, // Ruby synthetic-edge coupling_kind prop + framework-const ignore list
129130
}
130131

131132
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,11 @@ import (
249249
// v94: Swift endpoint extractor reads a single-value method property (`var method:
250250
// HTTPMethod { return .post }`, no switch) and applies its lone verb to every case, instead
251251
// of defaulting to GET. Cached Swift snapshots must re-extract.
252-
const cacheVersion = "v94"
252+
// v95: Ruby extractor tags synthetic coupling edges with a coupling_kind prop (so the cycles
253+
// explainer can exclude ActiveRecord associations) and adds common Rails framework constants
254+
// (I18n, Rails, Logger, ...) to the builtin-const ignore list. Cached Ruby snapshots must
255+
// re-extract to pick up the new edge props and suppressed references.
256+
const cacheVersion = "v95"
253257

254258
// extractorCache holds per-extractor facts keyed by a content hash of the files
255259
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/engine/testdata/golden/ruby_sample.facts.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{"kind":"dependency","name":"Reporting::ReportWorker -\u003e Trackable","file":"app/services/report_worker.rb","line":8,"repo":"ruby_sample","props":{"language":"ruby","mixin_kind":"include","source":"internal"},"relations":[{"kind":"implements","target":"Trackable"}]}
2-
{"kind":"dependency","name":"app/services -\u003e app/models/concerns","file":"app/services/_coupling.rb","repo":"ruby_sample","props":{"framework":"rails","language":"ruby","source":"internal","synthetic_coupling":true},"relations":[{"kind":"imports","target":"app/models/concerns"}]}
2+
{"kind":"dependency","name":"app/services -\u003e app/models/concerns","file":"app/services/_coupling.rb","repo":"ruby_sample","props":{"coupling_kind":"mixin","framework":"rails","language":"ruby","source":"internal","synthetic_coupling":true},"relations":[{"kind":"imports","target":"app/models/concerns"}]}
33
{"kind":"dependency","name":"config -\u003e rails/all","file":"config/application.rb","line":5,"repo":"ruby_sample","props":{"language":"ruby","source":"external"},"relations":[{"kind":"imports","target":"rails/all"}]}
44
{"kind":"file_ref","name":"app/services/report_worker.rb","file":"app/services/report_worker.rb","repo":"ruby_sample","props":{"dynamic_send_prefixes":["report_"],"language":"ruby"}}
55
{"kind":"file_ref","name":"app/views/reports/show.html.erb","file":"app/views/reports/show.html.erb","repo":"ruby_sample","props":{"language":"ruby"},"relations":[{"kind":"calls","target":"ReportPresenter"},{"kind":"calls","target":"ReportPresenter.render_summary"},{"kind":"calls","target":"can_view_reports?"},{"kind":"calls","target":"current_user"},{"kind":"calls","target":"render_summary"}]}

internal/explainers/common/common.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,26 @@ func ResolveRelativeImport(sourceModule, target string) string {
7878
// roles. Modules with an absent or non-test role are kept (consumers treat an
7979
// absent role as included).
8080
func BuildModuleGraph(store *facts.Store) map[string][]string {
81+
return BuildModuleGraphExcluding(store)
82+
}
83+
84+
// BuildModuleGraphExcluding is BuildModuleGraph with the ability to drop synthetic
85+
// coupling edges by their Props["coupling_kind"] (see facts.Coupling* constants).
86+
// A dependency fact whose coupling_kind is in excludeKinds contributes no edge.
87+
// The cycles explainer uses this to exclude ActiveRecord associations, whose
88+
// inherent bidirectionality would otherwise manufacture false cycles. With no
89+
// excludeKinds it is identical to BuildModuleGraph.
90+
func BuildModuleGraphExcluding(store *facts.Store, excludeKinds ...string) map[string][]string {
8191
graph := make(map[string][]string)
8292

93+
var excluded map[string]bool
94+
if len(excludeKinds) > 0 {
95+
excluded = make(map[string]bool, len(excludeKinds))
96+
for _, k := range excludeKinds {
97+
excluded[k] = true
98+
}
99+
}
100+
83101
modules := store.ByKind(facts.KindModule)
84102
moduleNames := make(map[string]bool)
85103
testModules := make(map[string]bool)
@@ -100,6 +118,11 @@ func BuildModuleGraph(store *facts.Store) map[string][]string {
100118
if testModules[sourceModule] {
101119
continue // edge out of a test bundle — not production architecture
102120
}
121+
if excluded != nil {
122+
if ck, _ := dep.Props[facts.PropCouplingKind].(string); excluded[ck] {
123+
continue
124+
}
125+
}
103126

104127
for _, rel := range dep.Relations {
105128
if rel.Kind != facts.RelImports {

internal/explainers/cycles/cycles.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import (
99
"github.com/enola-labs/enola/internal/facts"
1010
)
1111

12+
// maxCycleModules is the largest SCC still reported as a discrete, actionable
13+
// "Cyclic dependency". Larger components are not a fixable cycle — in an
14+
// autoloaded language (Ruby/Rails) mutual constant references across many
15+
// directories are the expected topology, so a 99-module SCC is a coupling-density
16+
// signal, not a defect. Such components are reported once as a softer,
17+
// lower-confidence "Highly coupled module cluster" note instead of an alarming
18+
// confidence-1.0 cycle with advice ("introduce an interface") that cannot
19+
// meaningfully be applied to a 99-node cluster.
20+
const maxCycleModules = 8
21+
22+
// maxClusterMembers caps how many representative members are listed as evidence
23+
// for an oversized coupling cluster.
24+
const maxClusterMembers = 12
25+
1226
// CycleExplainer detects cyclic dependencies between modules using Tarjan's SCC algorithm.
1327
type CycleExplainer struct{}
1428

@@ -23,8 +37,11 @@ func (e *CycleExplainer) Name() string {
2337

2438
// Explain builds a dependency graph from import relations and detects cycles.
2539
func (e *CycleExplainer) Explain(ctx context.Context, store *facts.Store) ([]facts.Insight, error) {
26-
// Build adjacency list from dependency facts
27-
graph := common.BuildModuleGraph(store)
40+
// Build adjacency list from dependency facts. ActiveRecord associations are
41+
// excluded: has_many/belongs_to pairs are inherently bidirectional domain
42+
// relationships, not load-order dependencies, and would otherwise manufacture
43+
// spurious two-module cycles between associated models.
44+
graph := common.BuildModuleGraphExcluding(store, facts.CouplingAssociation)
2845

2946
// Run Tarjan's SCC
3047
sccs := tarjanSCC(graph)
@@ -36,6 +53,11 @@ func (e *CycleExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
3653
continue
3754
}
3855

56+
if len(scc) > maxCycleModules {
57+
insights = append(insights, coupledClusterInsight(scc))
58+
continue
59+
}
60+
3961
cyclePath := strings.Join(scc, " -> ") + " -> " + scc[0]
4062
evidence := make([]facts.Evidence, 0, len(scc))
4163
for _, mod := range scc {
@@ -46,6 +68,7 @@ func (e *CycleExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
4668
}
4769

4870
insights = append(insights, facts.Insight{
71+
// Title prefix is parsed by pkg/explain (Code health section); keep stable.
4972
Title: fmt.Sprintf("Cyclic dependency detected (%d modules)", len(scc)),
5073
Description: fmt.Sprintf("The following modules form a dependency cycle: %s. This can cause initialization issues, make refactoring harder, and indicates tight coupling.", cyclePath),
5174
Confidence: 1.0, // Deterministic
@@ -61,6 +84,39 @@ func (e *CycleExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
6184
return insights, nil
6285
}
6386

87+
// coupledClusterInsight reports an oversized SCC as a soft coupling signal rather
88+
// than a discrete cycle. The title deliberately does NOT start with "Cyclic
89+
// dependency" so pkg/explain does not fold it into the cycle count.
90+
func coupledClusterInsight(scc []string) facts.Insight {
91+
members := scc
92+
if len(members) > maxClusterMembers {
93+
members = members[:maxClusterMembers]
94+
}
95+
evidence := make([]facts.Evidence, 0, len(members))
96+
for _, mod := range members {
97+
evidence = append(evidence, facts.Evidence{
98+
Fact: mod,
99+
Detail: fmt.Sprintf("module %q is part of the cluster", mod),
100+
})
101+
}
102+
return facts.Insight{
103+
Title: fmt.Sprintf("Highly coupled module cluster (%d modules)", len(scc)),
104+
Description: fmt.Sprintf(
105+
"%d modules reference each other mutually. In an autoloaded codebase "+
106+
"(e.g. Rails) this is expected — constant references between directories "+
107+
"resolve lazily, so this is not a load-order cycle. Treat it as an overall "+
108+
"coupling-density signal, not a defect to break.",
109+
len(scc),
110+
),
111+
Confidence: 0.4,
112+
Evidence: evidence,
113+
Actions: []string{
114+
"Look for a few high-traffic modules whose extraction would thin the cluster",
115+
"Prefer narrowing individual module responsibilities over a single big refactor",
116+
},
117+
}
118+
}
119+
64120
// tarjanSCC computes strongly connected components of the module graph. It
65121
// delegates to common.StronglyConnectedComponents, whose output is deterministic
66122
// (sorted components with sorted members) — so the emitted cycle path, evidence

internal/explainers/cycles/cycles_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cycles
22

33
import (
44
"context"
5+
"fmt"
56
"reflect"
67
"sort"
78
"strings"
@@ -202,6 +203,74 @@ func TestExplain_WithCycle(t *testing.T) {
202203
}
203204
}
204205

206+
// TestExplain_OversizedClusterSoftened: an SCC larger than maxCycleModules is not
207+
// a fixable cycle (in autoloaded Ruby/Rails it is the expected topology). It must
208+
// be reported once as a soft, low-confidence "Highly coupled module cluster" note
209+
// whose title does NOT start with "Cyclic dependency" (so pkg/explain won't count
210+
// it as a cycle), not as a confidence-1.0 alarm.
211+
func TestExplain_OversizedClusterSoftened(t *testing.T) {
212+
n := maxCycleModules + 3
213+
modules := make([]string, n)
214+
deps := map[string][]string{}
215+
for i := 0; i < n; i++ {
216+
modules[i] = fmt.Sprintf("app/m%02d", i)
217+
}
218+
// One big ring: m0 -> m1 -> ... -> m(n-1) -> m0, so all n form a single SCC.
219+
for i := 0; i < n; i++ {
220+
deps[modules[i]] = []string{modules[(i+1)%n]}
221+
}
222+
store := makeStore(modules, deps)
223+
224+
insights, err := New().Explain(context.Background(), store)
225+
if err != nil {
226+
t.Fatalf("Explain: %v", err)
227+
}
228+
if len(insights) != 1 {
229+
t.Fatalf("expected 1 cluster insight, got %d: %+v", len(insights), insights)
230+
}
231+
in := insights[0]
232+
if strings.HasPrefix(in.Title, "Cyclic dependency") {
233+
t.Errorf("oversized SCC should not be titled as a cyclic dependency: %q", in.Title)
234+
}
235+
if !strings.HasPrefix(in.Title, "Highly coupled module cluster") {
236+
t.Errorf("expected a coupling-cluster title, got %q", in.Title)
237+
}
238+
if in.Confidence >= 1.0 {
239+
t.Errorf("cluster confidence should be soft (<1.0), got %v", in.Confidence)
240+
}
241+
if len(in.Evidence) > maxClusterMembers {
242+
t.Errorf("cluster evidence not capped: got %d, want <= %d", len(in.Evidence), maxClusterMembers)
243+
}
244+
}
245+
246+
// TestExplain_AssociationEdgesExcluded: a two-model "cycle" formed solely by
247+
// ActiveRecord associations (Order has_many LineItems, LineItem belongs_to Order)
248+
// is bidirectional by nature, not a load-order cycle, and must not be reported.
249+
func TestExplain_AssociationEdgesExcluded(t *testing.T) {
250+
s := facts.NewStore()
251+
s.Add(facts.Fact{Kind: facts.KindModule, Name: "app/models/order"})
252+
s.Add(facts.Fact{Kind: facts.KindModule, Name: "app/models/line_item"})
253+
// Synthetic association edges both ways (as emitEdges would produce).
254+
s.Add(facts.Fact{
255+
Kind: facts.KindDependency, File: "app/models/order/_coupling.rb",
256+
Props: map[string]any{facts.PropCouplingKind: facts.CouplingAssociation},
257+
Relations: []facts.Relation{{Kind: facts.RelImports, Target: "app/models/line_item"}},
258+
})
259+
s.Add(facts.Fact{
260+
Kind: facts.KindDependency, File: "app/models/line_item/_coupling.rb",
261+
Props: map[string]any{facts.PropCouplingKind: facts.CouplingAssociation},
262+
Relations: []facts.Relation{{Kind: facts.RelImports, Target: "app/models/order"}},
263+
})
264+
265+
insights, err := New().Explain(context.Background(), s)
266+
if err != nil {
267+
t.Fatalf("Explain: %v", err)
268+
}
269+
if len(insights) != 0 {
270+
t.Errorf("association-only 2-cycle should not be reported, got %d: %+v", len(insights), insights)
271+
}
272+
}
273+
205274
// TestExplain_Deterministic guards BUG-2: the cycle path, evidence order, and
206275
// multi-cycle insight order used to depend on Go's randomized map iteration
207276
// (tarjanSCC ranged the graph map directly and never sorted). Each Explain call

internal/explainers/godclass/godclass.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ const (
2222
stdDevK = 2.0
2323
// maxEvidence caps how many dependents are listed as evidence per insight.
2424
maxEvidence = 8
25+
// maxInsights caps how many high-fan-in symbols are reported, matching the
26+
// sibling explainers (surface=20, complexity=15, depth=10). Without a cap a
27+
// large repo can emit hundreds of findings dominated by central-by-design
28+
// framework types.
29+
maxInsights = 25
2530
)
2631

2732
// GodClassExplainer detects high-fan-in symbols.
@@ -50,10 +55,27 @@ func (e *GodClassExplainer) Explain(ctx context.Context, store *facts.Store) ([]
5055
return nil, nil
5156
}
5257

53-
// Fan-in per symbol and the distribution used for outlier detection.
54-
fanIn := make(map[string]int, len(symbols))
55-
values := make([]float64, 0, len(symbols))
58+
// Report each distinct symbol name once. A constant reopened across many files
59+
// (Ruby STI/concerns, monkey-patched framework namespaces like
60+
// RailsAdmin::Config::Actions) yields one symbol fact per file, all sharing a
61+
// Name. Fan-in is keyed by name, so without de-duping they produced identical
62+
// findings AND skewed the outlier distribution below (the repeated high value
63+
// dragged the threshold up). De-dupe here so both the distribution and the
64+
// candidate set are over distinct symbols.
65+
distinct := make([]facts.Fact, 0, len(symbols))
66+
seen := make(map[string]bool, len(symbols))
5667
for _, s := range symbols {
68+
if seen[s.Name] {
69+
continue
70+
}
71+
seen[s.Name] = true
72+
distinct = append(distinct, s)
73+
}
74+
75+
// Fan-in per symbol and the distribution used for outlier detection.
76+
fanIn := make(map[string]int, len(distinct))
77+
values := make([]float64, 0, len(distinct))
78+
for _, s := range distinct {
5779
n := len(reverse[s.Name])
5880
fanIn[s.Name] = n
5981
values = append(values, float64(n))
@@ -67,7 +89,7 @@ func (e *GodClassExplainer) Explain(ctx context.Context, store *facts.Store) ([]
6789
labels []string
6890
}
6991
var candidates []candidate
70-
for _, s := range symbols {
92+
for _, s := range distinct {
7193
n := fanIn[s.Name]
7294
if n < minFanIn || float64(n) <= threshold {
7395
continue
@@ -88,6 +110,10 @@ func (e *GodClassExplainer) Explain(ctx context.Context, store *facts.Store) ([]
88110
return candidates[i].fact.Name < candidates[j].fact.Name
89111
})
90112

113+
if len(candidates) > maxInsights {
114+
candidates = candidates[:maxInsights]
115+
}
116+
91117
var insights []facts.Insight
92118
for _, c := range candidates {
93119
evidence := make([]facts.Evidence, 0, maxEvidence+1)

internal/explainers/godclass/godclass_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,54 @@ func TestExplain_DetectsGodClass(t *testing.T) {
7777
}
7878
}
7979

80+
// TestExplain_DedupReopenedSymbol: a constant reopened across many files (Ruby
81+
// STI/concerns, monkey-patched framework namespaces) yields one symbol fact per
82+
// file, all sharing a Name. Fan-in is keyed by name, so each produced an identical
83+
// insight — the RailsAdmin::Config::Actions ×50 flood. Report the name once.
84+
func TestExplain_DedupReopenedSymbol(t *testing.T) {
85+
store := makeStore("core.Hub", manyCallers(12), nil)
86+
// Simulate the same constant reopened in 4 more files.
87+
for i := 0; i < 4; i++ {
88+
store.Add(facts.Fact{Kind: facts.KindSymbol, Name: "core.Hub", File: fmt.Sprintf("reopen/%d.go", i)})
89+
}
90+
store.BuildGraph()
91+
92+
insights, err := New().Explain(context.Background(), store)
93+
if err != nil {
94+
t.Fatalf("Explain: %v", err)
95+
}
96+
if len(insights) != 1 {
97+
t.Fatalf("reopened symbol should yield 1 insight, got %d: %+v", len(insights), insights)
98+
}
99+
}
100+
101+
// TestExplain_CapsInsightCount: no more than maxInsights findings are emitted even
102+
// when many distinct symbols exceed the outlier threshold.
103+
func TestExplain_CapsInsightCount(t *testing.T) {
104+
s := facts.NewStore()
105+
// 40 distinct hubs, each with 12 dependents -> all well above the floor.
106+
for h := 0; h < 40; h++ {
107+
hub := fmt.Sprintf("core.Hub%d", h)
108+
s.Add(facts.Fact{Kind: facts.KindSymbol, Name: hub, File: fmt.Sprintf("core/hub%d.go", h)})
109+
for i := 0; i < 12; i++ {
110+
s.Add(facts.Fact{
111+
Kind: facts.KindSymbol, Name: fmt.Sprintf("caller%d_%d.Fn", h, i),
112+
File: fmt.Sprintf("callers/%d_%d.go", h, i),
113+
Relations: []facts.Relation{{Kind: facts.RelCalls, Target: hub}},
114+
})
115+
}
116+
}
117+
s.BuildGraph()
118+
119+
insights, err := New().Explain(context.Background(), s)
120+
if err != nil {
121+
t.Fatalf("Explain: %v", err)
122+
}
123+
if len(insights) > maxInsights {
124+
t.Errorf("insight count not capped: got %d, want <= %d", len(insights), maxInsights)
125+
}
126+
}
127+
80128
func TestExplain_BelowFloor(t *testing.T) {
81129
// Hub has only 5 dependents — below minFanIn even if it's the max.
82130
store := makeStore("core.Hub", manyCallers(5), nil)

internal/explainers/hotspots/hotspots.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,24 @@ func (e *HotspotExplainer) Explain(ctx context.Context, store *facts.Store) ([]f
5454
return nil, nil
5555
}
5656

57-
scores := make(map[string]int, len(symbols))
58-
values := make([]float64, 0, len(symbols))
57+
// Report each distinct symbol name once. A constant reopened across many files
58+
// (Ruby STI/concerns, monkey-patched framework namespaces) yields one symbol
59+
// fact per file, all sharing a Name and therefore identical in/out degree.
60+
// De-dupe here so both the outlier distribution and the candidate set are over
61+
// distinct symbols (repeated values would otherwise skew the threshold).
62+
distinct := make([]facts.Fact, 0, len(symbols))
63+
seen := make(map[string]bool, len(symbols))
5964
for _, s := range symbols {
65+
if seen[s.Name] {
66+
continue
67+
}
68+
seen[s.Name] = true
69+
distinct = append(distinct, s)
70+
}
71+
72+
scores := make(map[string]int, len(distinct))
73+
values := make([]float64, 0, len(distinct))
74+
for _, s := range distinct {
6075
in := len(reverse[s.Name])
6176
out := len(forward[s.Name])
6277
score := in * out
@@ -73,7 +88,7 @@ func (e *HotspotExplainer) Explain(ctx context.Context, store *facts.Store) ([]f
7388
score int
7489
}
7590
var candidates []candidate
76-
for _, s := range symbols {
91+
for _, s := range distinct {
7792
in := len(reverse[s.Name])
7893
out := len(forward[s.Name])
7994
if in < minDegree || out < minDegree {

0 commit comments

Comments
 (0)