-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathsurface.go
More file actions
181 lines (165 loc) · 6.55 KB
/
Copy pathsurface.go
File metadata and controls
181 lines (165 loc) · 6.55 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
// Package surface flags modules with an oversized public surface: large modules
// that export almost all of their symbols, encapsulating little.
//
// Two things keep this from flooding — which a naive ratio test does, because in
// Go ("Capitalized == public") and Ruby ("public is the default") most packages
// legitimately export the bulk of their symbols:
//
// - mock/test/generated packages (which export everything) are skipped, and a
// meaningful size + near-total export ratio is required; and
// - only the worst offenders are reported (top N by public-surface size), so a
// large repo surfaces a digestible shortlist rather than hundreds of hits.
//
// It is a heuristic shortlist of modules worth a visibility review, not a list of
// definite defects.
package surface
import (
"context"
"fmt"
"sort"
"github.com/enola-labs/enola/internal/explainers/common"
"github.com/enola-labs/enola/internal/facts"
"github.com/enola-labs/enola/pkg/mcputil"
)
const (
// minSymbols is the smallest module (by symbol count) considered; below this,
// exporting everything is unremarkable.
minSymbols = 15
// minExportedRatio is the exported/total ratio at or above which a module
// counts as near-fully-exported.
minExportedRatio = 0.85
// maxInsights caps how many modules are reported, largest public surface
// first, so the output stays a shortlist on big repos.
maxInsights = 20
// maxEvidence caps how many exported symbols are listed per insight.
maxEvidence = 8
)
// (The old excludedSegments map lived here. It has been replaced by the shared
// facts.IsTestPath — see excludedModule.)
// SurfaceExplainer detects modules with an oversized exported surface.
type SurfaceExplainer struct{}
// New creates a new SurfaceExplainer.
func New() *SurfaceExplainer {
return &SurfaceExplainer{}
}
func (e *SurfaceExplainer) Name() string {
return "exported-surface"
}
type moduleSurface struct {
total int
exported []string
}
// Explain tallies exported vs total symbols per module and reports the modules
// with the largest near-fully-exported public surface.
func (e *SurfaceExplainer) Explain(ctx context.Context, store *facts.Store) ([]facts.Insight, error) {
// Collapse #if/#else duplicates so a type declared once per branch is not counted
// twice toward a module's exported/total surface. Genuine overloads (same base
// name, not tagged conditional) are preserved.
symbols := facts.CanonicalSymbols(store.ByKind(facts.KindSymbol))
if len(symbols) == 0 {
return nil, nil
}
mods := make(map[string]*moduleSurface)
for _, s := range symbols {
// Ruby is public-by-default: every class/method is "exported", so the
// exported/total ratio is ~100% for every module and carries no signal (it
// floods with namespace modules like Core::V3, RailsAdmin, V2). Skip Ruby
// symbols so Ruby modules never become candidates; other languages in a
// multi-repo snapshot are unaffected. "Too big/central" is already covered by
// the god-class and complexity explainers.
if lang, _ := s.Props["language"].(string); lang == "ruby" {
continue
}
exported, ok := s.Props["exported"].(bool)
if !ok {
// Extractor didn't record visibility; ignore so it doesn't distort the ratio.
continue
}
mod := common.SymbolModule(s.Name)
ms := mods[mod]
if ms == nil {
ms = &moduleSurface{}
mods[mod] = ms
}
ms.total++
if exported {
ms.exported = append(ms.exported, s.Name)
}
}
type candidate struct {
module string
ms *moduleSurface
ratio float64
}
var candidates []candidate
for mod, ms := range mods {
if ms.total < minSymbols || excludedModule(mod) {
continue
}
ratio := float64(len(ms.exported)) / float64(ms.total)
if ratio < minExportedRatio {
continue
}
candidates = append(candidates, candidate{module: mod, ms: ms, ratio: ratio})
}
// Largest public surface first (the most worth reviewing), then by ratio,
// then name — so the top-N cap keeps the most significant modules.
sort.Slice(candidates, func(i, j int) bool {
if len(candidates[i].ms.exported) != len(candidates[j].ms.exported) {
return len(candidates[i].ms.exported) > len(candidates[j].ms.exported)
}
if candidates[i].ratio != candidates[j].ratio {
return candidates[i].ratio > candidates[j].ratio
}
return candidates[i].module < candidates[j].module
})
var insights []facts.Insight
for i, c := range candidates {
if i >= maxInsights {
break
}
exportedCount := len(c.ms.exported)
sorted := append([]string(nil), c.ms.exported...)
sort.Strings(sorted)
evidence := make([]facts.Evidence, 0, maxEvidence)
for j, sym := range sorted {
if j >= maxEvidence {
break
}
evidence = append(evidence, facts.Evidence{Symbol: sym, Detail: "exported"})
}
insights = append(insights, facts.Insight{
// Title format is parsed by pkg/explain (Code health section); keep stable.
Title: fmt.Sprintf("Large public surface: %s exports %d of %d symbols (%.0f%%)", c.module, exportedCount, c.ms.total, c.ratio*100),
Description: fmt.Sprintf(
"Module %q exports %d of its %d symbols (%.0f%%). A large, near-fully-exported module hides "+
"little of its implementation, so consumers can couple to internals and it is hard to "+
"change without breaking them. Worth a visibility review — though a deliberate library "+
"API may legitimately export most of its surface.",
c.module, exportedCount, c.ms.total, c.ratio*100,
),
Confidence: 0.6,
Evidence: evidence,
Actions: []string{
"Unexport symbols that are only used within the module",
"Define a small, intentional public API and hide the rest",
"Split the module if it has several independent public responsibilities",
},
})
}
return insights, nil
}
// excludedModule reports whether a module is test scaffolding or generated output:
// both naturally export everything and are not hand-maintained public APIs.
//
// It used to carry its own segment list, lowercasing the module path to match it.
// That covered `Tests/` (via `tests`) but not `androidTest` — which lowercases to
// `androidtest` — nor `__tests__` nor the Kotlin-Multiplatform trees, so
// nan/nebenan-android-app reported "Large public surface:
// app/src/androidTest/java/…/ui/signup/compose" as a finding. Both questions now have
// exactly one owner each: facts.IsTestPath and mcputil.IsGeneratedPath. Note
// IsTestPath is case-SENSITIVE and carries the real-world spellings (`Tests`,
// `Mocks`, `androidTest`, `__tests__`), so the lowercasing is gone with the list.
func excludedModule(mod string) bool {
return facts.IsTestPath(mod) || mcputil.IsGeneratedPath(mod)
}