-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathexplain.go
More file actions
515 lines (466 loc) · 16.6 KB
/
Copy pathexplain.go
File metadata and controls
515 lines (466 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
// Package explain produces a human-readable statistical summary of an Enola
// architectural snapshot — the data behind `enola --explain <repository>`.
//
// 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 (
"fmt"
"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
}
// RankedItem is one offender in a code-health finding group: a symbol or module
// plus a pre-formatted metric (e.g. "147 dependents", "depth 11").
type RankedItem struct {
Name string `json:"name"`
Detail string `json:"detail"`
}
// FindingGroup is one code-health explainer's contribution: its total count and
// the top offenders for display.
type FindingGroup struct {
Label string `json:"label"`
Count int `json:"count"`
Top []RankedItem `json:"top,omitempty"`
}
// 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"`
// Languages are the actual source languages present, most-prevalent first
// (derived from the per-fact "language" prop, not the extractor names — the
// C/C++ extractor is named "cpp" but a repo may be entirely C).
Languages []string `json:"languages,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"`
// CodeHealth holds the per-explainer findings from the symbol/module-level
// explainers (god-class, hotspots, dependency-depth, exported-surface,
// complexity-outliers), each with a total count and its top offenders.
CodeHealth []FindingGroup `json:"code_health,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
}
r.Languages = languagesByPrevalence(store)
// 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, plus the code-health explainer groups. Titles are matched
// against the explainer formats (see each explainer's Title: site).
if snap != nil {
// Code-health groups, assembled in a fixed display order. Each accumulates
// its count and (up to topPerGroup) top offenders; insights arrive already
// severity-sorted within each explainer, so first-seen == top.
godClass := &FindingGroup{Label: "god classes (high fan-in)"}
hotspots := &FindingGroup{Label: "call-graph hotspots"}
deepChains := &FindingGroup{Label: "deep dependency chains"}
surfaces := &FindingGroup{Label: "large public surfaces"}
complexFns := &FindingGroup{Label: "complexity outliers"}
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)
case strings.HasPrefix(in.Title, "High fan-in symbol:"):
name := nameBetween(in.Title, "High fan-in symbol:", " (")
addFinding(godClass, name, fmt.Sprintf("%d dependents", firstParenInt(in.Title)))
case strings.HasPrefix(in.Title, "Call-graph hotspot:"):
name := nameBetween(in.Title, "Call-graph hotspot:", " (")
ints := allInts(in.Title)
addFinding(hotspots, name, fanDetail(ints))
case strings.HasPrefix(in.Title, "Deep dependency chain:"):
name := nameBetween(in.Title, "Deep dependency chain:", " (")
addFinding(deepChains, name, fmt.Sprintf("depth %d", firstParenInt(in.Title)))
case strings.HasPrefix(in.Title, "Large public surface:"):
name := nameBetween(in.Title, "Large public surface:", " exports")
addFinding(surfaces, name, surfaceDetail(allInts(in.Title)))
case strings.HasPrefix(in.Title, "High cyclomatic complexity:"):
name := nameBetween(in.Title, "High cyclomatic complexity:", " (")
addFinding(complexFns, name, fmt.Sprintf("complexity %d", firstParenInt(in.Title)))
}
}
for _, g := range []*FindingGroup{godClass, hotspots, deepChains, surfaces, complexFns} {
if g.Count > 0 {
r.CodeHealth = append(r.CodeHealth, *g)
}
}
}
computeHotspots(store, r)
return r
}
// languagesByPrevalence returns the distinct source languages present across
// module facts, most-common first (ties broken alphabetically for determinism).
// It reads the per-fact "language" prop rather than the extractor names, so a
// repo parsed by the "cpp" extractor but written entirely in C reports "c".
// Returns nil when no module carries a language (e.g. a pre-language snapshot),
// letting the renderer fall back to the extractor list.
func languagesByPrevalence(store *facts.Store) []string {
counts := map[string]int{}
for _, f := range store.ByKind(facts.KindModule) {
if l, _ := f.Props["language"].(string); l != "" {
counts[l]++
}
}
if len(counts) == 0 {
return nil
}
langs := make([]string, 0, len(counts))
for l := range counts {
langs = append(langs, l)
}
sort.Slice(langs, func(i, j int) bool {
if counts[langs[i]] != counts[langs[j]] {
return counts[langs[i]] > counts[langs[j]]
}
return langs[i] < langs[j]
})
return langs
}
// topPerGroup caps how many offenders each code-health group lists in the report.
const topPerGroup = 5
// addFinding increments a group's count and records the offender as a top item
// until the per-group display cap is reached.
func addFinding(g *FindingGroup, name, detail string) {
g.Count++
if len(g.Top) < topPerGroup {
g.Top = append(g.Top, RankedItem{Name: name, Detail: detail})
}
}
// fanDetail formats a hotspot's "fan-in N / out M" from the ints parsed out of
// its title (fan-in first, fan-out second).
func fanDetail(ints []int) string {
in, out := 0, 0
if len(ints) > 0 {
in = ints[0]
}
if len(ints) > 1 {
out = ints[1]
}
return fmt.Sprintf("fan-in %d / out %d", in, out)
}
// surfaceDetail formats "E/T (P%)" from the ints parsed out of a large-public-
// surface title (exported, total, percent — in that order).
func surfaceDetail(ints []int) string {
for len(ints) < 3 {
ints = append(ints, 0)
}
return fmt.Sprintf("%d/%d (%d%%)", ints[0], ints[1], ints[2])
}
// 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
}
// nameBetween returns the symbol/module name in an insight title: the text after
// prefix up to the first occurrence of stop (e.g. " (" or " exports"), trimmed.
// Symbol and module names contain no "(", so the cut is unambiguous.
func nameBetween(title, prefix, stop string) string {
s := strings.TrimPrefix(title, prefix)
if i := strings.Index(s, stop); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s)
}
// allInts returns every run of digits in s as integers, in order. Used to pull
// the metrics out of insight titles (e.g. "(fan-in 64, fan-out 20)" -> [64,20]).
func allInts(s string) []int {
var out []int
digits := strings.Builder{}
flush := func() {
if digits.Len() > 0 {
n, _ := strconv.Atoi(digits.String())
out = append(out, n)
digits.Reset()
}
}
for _, ch := range s {
if ch >= '0' && ch <= '9' {
digits.WriteRune(ch)
} else {
flush()
}
}
flush()
return out
}
// 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
}