Skip to content

Commit 3aa9bb3

Browse files
authored
Adding benchmarks v1 (#36)
* Adding --explain and fixing Python exporters * Improving Ruby dependency resolving (requires and includes) * Closing static import gap in Java * Fixing Swift & Kotlin edge dependencies * Fixing resolving internal module coupling in Kotlin * Improving architecture detection
1 parent d6701fa commit 3aa9bb3

2 files changed

Lines changed: 533 additions & 90 deletions

File tree

internal/explainers/layers/layers.go

Lines changed: 229 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,147 @@ var (
6060
{Name: "pkg", Patterns: []string{"pkg"}, Level: 0},
6161
{Name: "api", Patterns: []string{"api"}, Level: 2},
6262
}
63+
64+
// Ruby on Rails MVC layout
65+
railsLayers = []layerDef{
66+
{Name: "model", Patterns: []string{"model", "models"}, Level: 0},
67+
{Name: "controller", Patterns: []string{"controller", "controllers"}, Level: 3},
68+
{Name: "view", Patterns: []string{"view", "views"}, Level: 3},
69+
{Name: "helper", Patterns: []string{"helper", "helpers"}, Level: 2},
70+
{Name: "mailer", Patterns: []string{"mailer", "mailers"}, Level: 2},
71+
{Name: "job", Patterns: []string{"job", "jobs", "worker", "workers"}, Level: 1},
72+
{Name: "service", Patterns: []string{"service", "services"}, Level: 1},
73+
}
74+
75+
// Android clean architecture / MVVM layout
76+
androidLayers = []layerDef{
77+
{Name: "domain", Patterns: []string{"domain"}, Level: 0},
78+
{Name: "data", Patterns: []string{"data", "repository", "repositories"}, Level: 1},
79+
{Name: "ui", Patterns: []string{"ui", "presentation", "view", "views", "screen", "screens"}, Level: 3},
80+
{Name: "di", Patterns: []string{"di", "injection"}, Level: 2},
81+
{Name: "designsystem", Patterns: []string{"designsystem"}, Level: 3},
82+
}
83+
84+
// iOS clean architecture / MVVM layout
85+
iosLayers = []layerDef{
86+
{Name: "domain", Patterns: []string{"domain"}, Level: 0},
87+
{Name: "data", Patterns: []string{"data", "repository", "repositories"}, Level: 1},
88+
{Name: "ui", Patterns: []string{"ui", "presentation", "view", "views", "screen", "screens", "components"}, Level: 3},
89+
{Name: "designsystem", Patterns: []string{"designsystem"}, Level: 3},
90+
}
91+
92+
// Spring layered architecture layout
93+
springLayers = []layerDef{
94+
{Name: "controller", Patterns: []string{"controller", "controllers", "rest", "web"}, Level: 3},
95+
{Name: "service", Patterns: []string{"service", "services"}, Level: 1},
96+
{Name: "repository", Patterns: []string{"repository", "repositories", "dao", "daos"}, Level: 1},
97+
{Name: "entity", Patterns: []string{"entity", "entities", "model", "models", "domain"}, Level: 0},
98+
{Name: "dto", Patterns: []string{"dto", "dtos"}, Level: 2},
99+
{Name: "config", Patterns: []string{"config", "configuration"}, Level: 2},
100+
}
101+
102+
// Django layout
103+
djangoLayers = []layerDef{
104+
{Name: "models", Patterns: []string{"model", "models"}, Level: 0},
105+
{Name: "views", Patterns: []string{"view", "views"}, Level: 3},
106+
{Name: "serializers", Patterns: []string{"serializer", "serializers"}, Level: 2},
107+
{Name: "urls", Patterns: []string{"url", "urls"}, Level: 3},
108+
{Name: "admin", Patterns: []string{"admin"}, Level: 3},
109+
{Name: "forms", Patterns: []string{"form", "forms"}, Level: 2},
110+
}
63111
)
64112

113+
// patternDef defines an architecture pattern together with the signals that gate
114+
// its detection. Without gating, generic directory names (api, app, ui, lib,
115+
// model, ...) cause patterns to match repos of the wrong language/framework.
116+
type patternDef struct {
117+
name string
118+
layers []layerDef
119+
120+
// languages, if non-empty, requires the repo's dominant language to be one
121+
// of these for the pattern to be considered.
122+
languages []string
123+
// frameworks, if non-empty, requires at least one of these frameworks to be
124+
// present in the facts for the pattern to be considered.
125+
frameworks []string
126+
// signatureLayers, if non-empty, requires at least minSignatureLayers of the
127+
// matched layers to be distinctive ones from this set (so a pattern built
128+
// only from generic names — or from a single stray directory — does not
129+
// qualify).
130+
signatureLayers []string
131+
minSignatureLayers int
132+
}
133+
134+
// patternDefs lists all known architecture patterns. Order does not affect the
135+
// outcome; bestPattern selects by specificity then confidence.
136+
var patternDefs = []patternDef{
137+
// Framework-gated patterns (most specific).
138+
{name: "nextjs", layers: nextjsLayers, frameworks: []string{"nextjs"}},
139+
{name: "rails-mvc", layers: railsLayers, frameworks: []string{"rails"}},
140+
{name: "android-clean", layers: androidLayers, frameworks: []string{"android"}},
141+
{name: "ios-clean", layers: iosLayers, frameworks: []string{"swiftui", "uikit"}},
142+
{name: "spring-layered", layers: springLayers, frameworks: []string{"spring"}},
143+
{name: "django", layers: djangoLayers, frameworks: []string{"django"}},
144+
145+
// Language-gated patterns.
146+
{name: "go-standard", layers: goStdLayers, languages: []string{"go"}},
147+
148+
// Language-agnostic patterns, gated on distinctive signature layers. Require
149+
// at least two distinct ports-and-adapters layers so a single stray
150+
// directory (e.g. one "infrastructure" test folder) does not trigger it.
151+
{name: "hexagonal", layers: hexagonalLayers, signatureLayers: []string{"application", "port", "adapter"}, minSignatureLayers: 2},
152+
}
153+
154+
// specificity ranks how targeted a pattern's gating is. Higher wins ties: a
155+
// framework-specific pattern is preferred over a language-gated one, which is
156+
// preferred over a generic (signature-only) pattern.
157+
func (d patternDef) specificity() int {
158+
switch {
159+
case len(d.frameworks) > 0:
160+
return 2
161+
case len(d.languages) > 0:
162+
return 1
163+
default:
164+
return 0
165+
}
166+
}
167+
168+
// gateOK reports whether the pattern's language/framework requirements are met.
169+
func (d patternDef) gateOK(lang string, frameworks map[string]bool) bool {
170+
if len(d.languages) > 0 {
171+
matched := false
172+
for _, l := range d.languages {
173+
if l == lang {
174+
matched = true
175+
break
176+
}
177+
}
178+
if !matched {
179+
return false
180+
}
181+
}
182+
if len(d.frameworks) > 0 {
183+
matched := false
184+
for _, f := range d.frameworks {
185+
if frameworks[f] {
186+
matched = true
187+
break
188+
}
189+
}
190+
if !matched {
191+
return false
192+
}
193+
}
194+
return true
195+
}
196+
65197
// archPattern represents a detected architecture pattern with its confidence.
66198
type archPattern struct {
67-
Name string
68-
Confidence float64
69-
Layers map[string]*layerDef
70-
Modules map[string]string // module -> layer name
199+
Name string
200+
Confidence float64
201+
Specificity int // from patternDef.specificity(); used to break ties in bestPattern
202+
Layers map[string]*layerDef
203+
Modules map[string]string // module -> layer name
71204
}
72205

73206
// Explain analyzes the fact store and detects architectural patterns.
@@ -77,8 +210,12 @@ func (e *LayerExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
77210
return nil, nil
78211
}
79212

213+
// Derive language/framework signals used to gate pattern detection.
214+
lang := dominantLanguage(modules)
215+
frameworks := presentFrameworks(store)
216+
80217
// Detect which architecture patterns match
81-
patterns := e.detectPatterns(modules)
218+
patterns := e.detectPatterns(modules, lang, frameworks)
82219

83220
var insights []facts.Insight
84221

@@ -111,21 +248,24 @@ func (e *LayerExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
111248
return insights, nil
112249
}
113250

114-
func (e *LayerExplainer) detectPatterns(modules []facts.Fact) []*archPattern {
251+
func (e *LayerExplainer) detectPatterns(modules []facts.Fact, lang string, frameworks map[string]bool) []*archPattern {
115252
var patterns []*archPattern
116253

117-
for _, def := range []struct {
118-
name string
119-
layers []layerDef
120-
}{
121-
{"hexagonal", hexagonalLayers},
122-
{"nextjs", nextjsLayers},
123-
{"go-standard", goStdLayers},
124-
} {
254+
for di := range patternDefs {
255+
def := patternDefs[di]
256+
257+
// Skip patterns whose language/framework gate is not satisfied. This is
258+
// what stops e.g. a Python or Ruby repo from matching the generic
259+
// "nextjs" directory names, or a plain OOP repo from matching nextjs.
260+
if !def.gateOK(lang, frameworks) {
261+
continue
262+
}
263+
125264
pattern := &archPattern{
126-
Name: def.name,
127-
Layers: make(map[string]*layerDef),
128-
Modules: make(map[string]string),
265+
Name: def.name,
266+
Specificity: def.specificity(),
267+
Layers: make(map[string]*layerDef),
268+
Modules: make(map[string]string),
129269
}
130270

131271
matchCount := 0
@@ -140,41 +280,99 @@ func (e *LayerExplainer) detectPatterns(modules []facts.Fact) []*archPattern {
140280
}
141281
}
142282

143-
if matchCount > 0 && len(modules) > 0 {
144-
// Confidence based on how many modules are classified
145-
coverage := float64(matchCount) / float64(len(modules))
146-
// Also factor in how many distinct layers are matched
147-
layerCoverage := float64(len(pattern.Layers)) / float64(len(def.layers))
283+
if matchCount == 0 || len(modules) == 0 {
284+
continue
285+
}
148286

149-
pattern.Confidence = (coverage*0.6 + layerCoverage*0.4)
150-
if pattern.Confidence > 1.0 {
151-
pattern.Confidence = 1.0
152-
}
287+
// Require enough distinctive signature layers when the pattern declares
288+
// them, so a match built only from generic names (e.g. just model + ui)
289+
// or from a single stray directory does not qualify.
290+
if len(def.signatureLayers) > 0 &&
291+
countSignatureLayers(pattern, def.signatureLayers) < def.minSignatureLayers {
292+
continue
293+
}
153294

154-
// Minimum threshold
155-
if pattern.Confidence >= 0.2 && len(pattern.Layers) >= 2 {
156-
patterns = append(patterns, pattern)
157-
}
295+
// Confidence based on how many modules are classified
296+
coverage := float64(matchCount) / float64(len(modules))
297+
// Also factor in how many distinct layers are matched
298+
layerCoverage := float64(len(pattern.Layers)) / float64(len(def.layers))
299+
300+
pattern.Confidence = (coverage*0.6 + layerCoverage*0.4)
301+
if pattern.Confidence > 1.0 {
302+
pattern.Confidence = 1.0
303+
}
304+
305+
// Minimum threshold
306+
if pattern.Confidence >= 0.2 && len(pattern.Layers) >= 2 {
307+
patterns = append(patterns, pattern)
158308
}
159309
}
160310

161311
return patterns
162312
}
163313

314+
// countSignatureLayers returns how many of the given distinctive layer names the
315+
// detected pattern matched.
316+
func countSignatureLayers(pattern *archPattern, signature []string) int {
317+
n := 0
318+
for _, name := range signature {
319+
if _, ok := pattern.Layers[name]; ok {
320+
n++
321+
}
322+
}
323+
return n
324+
}
325+
164326
func (e *LayerExplainer) bestPattern(patterns []*archPattern) *archPattern {
165327
if len(patterns) == 0 {
166328
return nil
167329
}
168330

169331
best := patterns[0]
170332
for _, p := range patterns[1:] {
171-
if p.Confidence > best.Confidence {
333+
// Prefer the more specific pattern (framework > language > generic);
334+
// break ties by confidence.
335+
if p.Specificity > best.Specificity ||
336+
(p.Specificity == best.Specificity && p.Confidence > best.Confidence) {
172337
best = p
173338
}
174339
}
175340
return best
176341
}
177342

343+
// dominantLanguage returns the most common language across module facts, using
344+
// the Props["language"] attribute every extractor sets. Ties break
345+
// alphabetically for deterministic output.
346+
func dominantLanguage(modules []facts.Fact) string {
347+
counts := make(map[string]int)
348+
for _, m := range modules {
349+
if lang, ok := m.Props["language"].(string); ok && lang != "" {
350+
counts[lang]++
351+
}
352+
}
353+
best := ""
354+
bestN := 0
355+
for lang, n := range counts {
356+
if n > bestN || (n == bestN && lang < best) {
357+
best, bestN = lang, n
358+
}
359+
}
360+
return best
361+
}
362+
363+
// presentFrameworks collects the set of frameworks present anywhere in the fact
364+
// store, using the Props["framework"] attribute extractors set on routes,
365+
// symbols and modules (e.g. nextjs, rails, django, android, spring).
366+
func presentFrameworks(store *facts.Store) map[string]bool {
367+
out := make(map[string]bool)
368+
for _, f := range store.All() {
369+
if fw, ok := f.Props["framework"].(string); ok && fw != "" {
370+
out[fw] = true
371+
}
372+
}
373+
return out
374+
}
375+
178376
// detectViolations checks for layer boundary violations (inner layer importing outer layer).
179377
func (e *LayerExplainer) detectViolations(store *facts.Store, pattern *archPattern) []facts.Insight {
180378
var insights []facts.Insight

0 commit comments

Comments
 (0)