@@ -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.
66198type 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+
164326func (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).
179377func (e * LayerExplainer ) detectViolations (store * facts.Store , pattern * archPattern ) []facts.Insight {
180378 var insights []facts.Insight
0 commit comments