@archora/core 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
import { canSignalFailCi, reconcileSignalLifecycle } from '../analyzer/signals';
|
|
2
|
+
import type {
|
|
3
|
+
ArchitectureSignal,
|
|
4
|
+
Cycle,
|
|
5
|
+
LayerViolation,
|
|
6
|
+
ModuleId,
|
|
7
|
+
ScanResult,
|
|
8
|
+
SignalSeverity,
|
|
9
|
+
} from '../analyzer/types';
|
|
10
|
+
import { detectLayer } from '../analyzer/layers';
|
|
11
|
+
import { diffScans } from '../diff';
|
|
12
|
+
import type { ScanDiff } from '../diff/types';
|
|
13
|
+
|
|
14
|
+
export type MatrixGrouping = 'area' | 'layer' | 'folder' | 'package';
|
|
15
|
+
|
|
16
|
+
export interface BuildMatrixViewOptions {
|
|
17
|
+
groupBy?: MatrixGrouping;
|
|
18
|
+
onlyViolations?: boolean;
|
|
19
|
+
onlyCycles?: boolean;
|
|
20
|
+
top?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MatrixCell {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
imports: number;
|
|
27
|
+
violations: number;
|
|
28
|
+
cycleEdges: number;
|
|
29
|
+
edges: MatrixEdge[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MatrixEdge {
|
|
33
|
+
from: ModuleId;
|
|
34
|
+
to: ModuleId;
|
|
35
|
+
kind: ScanResult['edges'][number]['kind'];
|
|
36
|
+
specifier: string;
|
|
37
|
+
violation: boolean;
|
|
38
|
+
cycleEdge: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MatrixView {
|
|
42
|
+
grouping: MatrixGrouping;
|
|
43
|
+
groups: string[];
|
|
44
|
+
cells: MatrixCell[];
|
|
45
|
+
summary: {
|
|
46
|
+
modules: number;
|
|
47
|
+
imports: number;
|
|
48
|
+
groups: number;
|
|
49
|
+
cells: number;
|
|
50
|
+
violations: number;
|
|
51
|
+
cycleEdges: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ImpactView {
|
|
56
|
+
target: ModuleId;
|
|
57
|
+
imports: ModuleId[];
|
|
58
|
+
importers: ModuleId[];
|
|
59
|
+
affectedModules: ModuleId[];
|
|
60
|
+
affectedAreas: string[];
|
|
61
|
+
affectedFolders: string[];
|
|
62
|
+
cyclesTouched: string[];
|
|
63
|
+
violationsTouched: number;
|
|
64
|
+
relatedSignals: string[];
|
|
65
|
+
metrics: {
|
|
66
|
+
fanIn: number;
|
|
67
|
+
fanOut: number;
|
|
68
|
+
instability: number;
|
|
69
|
+
hotnessScore: number;
|
|
70
|
+
};
|
|
71
|
+
risk: 'low' | 'medium' | 'high';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ExplainView {
|
|
75
|
+
kind: 'signal' | 'cycle' | 'module' | 'project';
|
|
76
|
+
title: string;
|
|
77
|
+
severity?: string;
|
|
78
|
+
confidence?: string;
|
|
79
|
+
evidence: string[];
|
|
80
|
+
modules: ModuleId[];
|
|
81
|
+
nextSteps: string[];
|
|
82
|
+
cycle?: ExplainCycleDetails;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ExplainCycleDetails {
|
|
86
|
+
affectedAreas: string[];
|
|
87
|
+
affectedFolders: string[];
|
|
88
|
+
affectedLayers: string[];
|
|
89
|
+
edges: ExplainCycleEdge[];
|
|
90
|
+
suggestedBreakpoint: { from: ModuleId; to: ModuleId; reason: string } | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ExplainCycleEdge {
|
|
94
|
+
from: ModuleId;
|
|
95
|
+
to: ModuleId;
|
|
96
|
+
kind: ScanResult['edges'][number]['kind'];
|
|
97
|
+
specifier: string;
|
|
98
|
+
fromLayer: string;
|
|
99
|
+
toLayer: string;
|
|
100
|
+
fromFolder: string;
|
|
101
|
+
toFolder: string;
|
|
102
|
+
crossesLayer: boolean;
|
|
103
|
+
violatesBoundary: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface SignalBaselineView {
|
|
107
|
+
current: ArchitectureSignal[];
|
|
108
|
+
resolved: ArchitectureSignal[];
|
|
109
|
+
newSignals: ArchitectureSignal[];
|
|
110
|
+
regressedSignals: ArchitectureSignal[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ReviewRiskView {
|
|
114
|
+
score: number;
|
|
115
|
+
level: 'low' | 'medium' | 'high' | 'critical';
|
|
116
|
+
summary: string;
|
|
117
|
+
reasons: string[];
|
|
118
|
+
guidedActions: GuidedReviewAction[];
|
|
119
|
+
checkFirst: ModuleId[];
|
|
120
|
+
affectedAreas: string[];
|
|
121
|
+
baseline?: {
|
|
122
|
+
addedModules: number;
|
|
123
|
+
changedModules: number;
|
|
124
|
+
removedModules: number;
|
|
125
|
+
newCycles: number;
|
|
126
|
+
resolvedCycles: number;
|
|
127
|
+
newSignals: number;
|
|
128
|
+
regressedSignals: number;
|
|
129
|
+
resolvedSignals: number;
|
|
130
|
+
};
|
|
131
|
+
regressions: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface GuidedReviewAction {
|
|
135
|
+
kind: 'cycle' | 'rule' | 'contract' | 'signal' | 'hotspot' | 'lifecycle';
|
|
136
|
+
title: string;
|
|
137
|
+
target: ModuleId;
|
|
138
|
+
action: string;
|
|
139
|
+
evidence: string;
|
|
140
|
+
verify: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface OwnershipArea {
|
|
144
|
+
area: string;
|
|
145
|
+
modules: number;
|
|
146
|
+
findings: number;
|
|
147
|
+
riskScore: number;
|
|
148
|
+
primaryKind: ScanResult['modules'][number]['kind'];
|
|
149
|
+
ownerHint: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface OwnershipView {
|
|
153
|
+
areas: OwnershipArea[];
|
|
154
|
+
drift: OwnershipArea[];
|
|
155
|
+
unownedHotspots: ModuleId[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SemanticSurfaceModule {
|
|
159
|
+
id: ModuleId;
|
|
160
|
+
exports: number;
|
|
161
|
+
fanIn: number;
|
|
162
|
+
fanOut: number;
|
|
163
|
+
role: ScanResult['modules'][number]['kind'];
|
|
164
|
+
risk: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SemanticSurfaceView {
|
|
168
|
+
broadPublicModules: SemanticSurfaceModule[];
|
|
169
|
+
quietExports: SemanticSurfaceModule[];
|
|
170
|
+
typeClusters: SemanticSurfaceModule[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface LifecycleHygieneItem {
|
|
174
|
+
id: ModuleId;
|
|
175
|
+
reason: 'detached' | 'entry-candidate' | 'generated-pressure';
|
|
176
|
+
fanIn: number;
|
|
177
|
+
fanOut: number;
|
|
178
|
+
exports: number;
|
|
179
|
+
loc: number;
|
|
180
|
+
risk: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface LifecycleRiskModule {
|
|
184
|
+
id: ModuleId;
|
|
185
|
+
memoryRisks: number;
|
|
186
|
+
asyncLifecycleRisks: number;
|
|
187
|
+
totalRisks: number;
|
|
188
|
+
confidence: 'low' | 'medium' | 'high';
|
|
189
|
+
severity: 'low' | 'medium';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface SideEffectOwner {
|
|
193
|
+
id: ModuleId;
|
|
194
|
+
owner: string;
|
|
195
|
+
layer: string;
|
|
196
|
+
kind: ScanResult['modules'][number]['kind'];
|
|
197
|
+
memoryRisks: number;
|
|
198
|
+
asyncLifecycleRisks: number;
|
|
199
|
+
totalRisks: number;
|
|
200
|
+
placement: 'owned' | 'review';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface LifecycleHygieneView {
|
|
204
|
+
summary: {
|
|
205
|
+
removableCandidates: number;
|
|
206
|
+
entryCandidates: number;
|
|
207
|
+
generatedPressure: number;
|
|
208
|
+
memoryRisks: number;
|
|
209
|
+
asyncLifecycleRisks: number;
|
|
210
|
+
lifecycleRiskModules: number;
|
|
211
|
+
sideEffectOwners: number;
|
|
212
|
+
};
|
|
213
|
+
removableCandidates: LifecycleHygieneItem[];
|
|
214
|
+
entryCandidates: LifecycleHygieneItem[];
|
|
215
|
+
generatedPressure: LifecycleHygieneItem[];
|
|
216
|
+
lifecycleRiskModules: LifecycleRiskModule[];
|
|
217
|
+
sideEffectOwners: SideEffectOwner[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface TrendView {
|
|
221
|
+
direction: 'improved' | 'regressed' | 'stable';
|
|
222
|
+
summary: {
|
|
223
|
+
scoreDelta: number;
|
|
224
|
+
gradeBefore: string;
|
|
225
|
+
gradeAfter: string;
|
|
226
|
+
addedModules: number;
|
|
227
|
+
removedModules: number;
|
|
228
|
+
changedModules: number;
|
|
229
|
+
newCycles: number;
|
|
230
|
+
resolvedCycles: number;
|
|
231
|
+
newSignals: number;
|
|
232
|
+
regressedSignals: number;
|
|
233
|
+
resolvedSignals: number;
|
|
234
|
+
};
|
|
235
|
+
changes: string[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const SEVERITY_RANK: Record<SignalSeverity, number> = {
|
|
239
|
+
info: 0,
|
|
240
|
+
low: 1,
|
|
241
|
+
medium: 2,
|
|
242
|
+
high: 3,
|
|
243
|
+
critical: 4,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export function buildMatrixView(
|
|
247
|
+
scan: ScanResult,
|
|
248
|
+
options: MatrixGrouping | BuildMatrixViewOptions = {},
|
|
249
|
+
): MatrixView {
|
|
250
|
+
const opts: BuildMatrixViewOptions = typeof options === 'string' ? { groupBy: options } : options;
|
|
251
|
+
const grouping = opts.groupBy ?? 'area';
|
|
252
|
+
const moduleGroup = new Map(
|
|
253
|
+
scan.modules.map((module) => [module.id, groupModule(module.id, grouping)]),
|
|
254
|
+
);
|
|
255
|
+
const cycleEdges = cycleEdgeKeys(scan);
|
|
256
|
+
const violationEdges = new Set(
|
|
257
|
+
scan.layerViolations.map((violation) => edgeKey(violation.from, violation.to)),
|
|
258
|
+
);
|
|
259
|
+
const cellsByKey = new Map<string, MatrixCell>();
|
|
260
|
+
|
|
261
|
+
for (const edge of scan.edges) {
|
|
262
|
+
if (!edge.resolved) continue;
|
|
263
|
+
const from = moduleGroup.get(edge.from) ?? groupModule(edge.from, grouping);
|
|
264
|
+
const to = moduleGroup.get(edge.to) ?? groupModule(edge.to, grouping);
|
|
265
|
+
if (from === to) continue;
|
|
266
|
+
const key = `${from}\u0001${to}`;
|
|
267
|
+
const current = cellsByKey.get(key) ?? {
|
|
268
|
+
from,
|
|
269
|
+
to,
|
|
270
|
+
imports: 0,
|
|
271
|
+
violations: 0,
|
|
272
|
+
cycleEdges: 0,
|
|
273
|
+
edges: [],
|
|
274
|
+
};
|
|
275
|
+
const edgeHasViolation = violationEdges.has(edgeKey(edge.from, edge.to));
|
|
276
|
+
const edgeInCycle = cycleEdges.has(edgeKey(edge.from, edge.to));
|
|
277
|
+
current.imports += 1;
|
|
278
|
+
if (edgeHasViolation) current.violations += 1;
|
|
279
|
+
if (edgeInCycle) current.cycleEdges += 1;
|
|
280
|
+
current.edges.push({
|
|
281
|
+
from: edge.from,
|
|
282
|
+
to: edge.to,
|
|
283
|
+
kind: edge.kind,
|
|
284
|
+
specifier: edge.specifier,
|
|
285
|
+
violation: edgeHasViolation,
|
|
286
|
+
cycleEdge: edgeInCycle,
|
|
287
|
+
});
|
|
288
|
+
cellsByKey.set(key, current);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const groups = [...new Set([...moduleGroup.values()])].sort();
|
|
292
|
+
let cells = [...cellsByKey.values()];
|
|
293
|
+
if (opts.onlyViolations) cells = cells.filter((cell) => cell.violations > 0);
|
|
294
|
+
if (opts.onlyCycles) cells = cells.filter((cell) => cell.cycleEdges > 0);
|
|
295
|
+
cells.sort((a, b) => {
|
|
296
|
+
const weight =
|
|
297
|
+
b.violations * 1000 +
|
|
298
|
+
b.cycleEdges * 100 +
|
|
299
|
+
b.imports -
|
|
300
|
+
(a.violations * 1000 + a.cycleEdges * 100 + a.imports);
|
|
301
|
+
if (weight !== 0) return weight;
|
|
302
|
+
const from = a.from.localeCompare(b.from);
|
|
303
|
+
return from !== 0 ? from : a.to.localeCompare(b.to);
|
|
304
|
+
});
|
|
305
|
+
if (opts.top !== undefined) cells = cells.slice(0, opts.top);
|
|
306
|
+
return {
|
|
307
|
+
grouping,
|
|
308
|
+
groups,
|
|
309
|
+
cells,
|
|
310
|
+
summary: {
|
|
311
|
+
modules: scan.modules.length,
|
|
312
|
+
imports: scan.edges.filter((edge) => edge.resolved).length,
|
|
313
|
+
groups: groups.length,
|
|
314
|
+
cells: cells.length,
|
|
315
|
+
violations: cells.reduce((count, cell) => count + cell.violations, 0),
|
|
316
|
+
cycleEdges: cells.reduce((count, cell) => count + cell.cycleEdges, 0),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function buildImpactView(scan: ScanResult, target: ModuleId): ImpactView {
|
|
322
|
+
const imports = sortedUnique(
|
|
323
|
+
scan.edges.filter((edge) => edge.from === target).map((edge) => edge.to),
|
|
324
|
+
);
|
|
325
|
+
const importers = sortedUnique(
|
|
326
|
+
scan.edges.filter((edge) => edge.to === target).map((edge) => edge.from),
|
|
327
|
+
);
|
|
328
|
+
const affectedModules = transitiveImporters(scan, target);
|
|
329
|
+
const affectedAreas = sortedUnique(affectedModules.map(areaOf));
|
|
330
|
+
const affectedFolders = sortedUnique(affectedModules.map(folderOf));
|
|
331
|
+
const cyclesTouched = scan.cycles
|
|
332
|
+
.filter((cycle) => cycle.modules.includes(target))
|
|
333
|
+
.map((cycle) => cycle.id);
|
|
334
|
+
const violationsTouched = scan.layerViolations.filter(
|
|
335
|
+
(violation) => violation.from === target || violation.to === target,
|
|
336
|
+
).length;
|
|
337
|
+
const relatedSignals = sortedUnique(
|
|
338
|
+
(scan.signals ?? [])
|
|
339
|
+
.filter((signal) => signal.modules.includes(target))
|
|
340
|
+
.map((signal) => signal.stableKey),
|
|
341
|
+
);
|
|
342
|
+
const metrics = scan.metrics[target];
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
target,
|
|
346
|
+
imports,
|
|
347
|
+
importers,
|
|
348
|
+
affectedModules,
|
|
349
|
+
affectedAreas,
|
|
350
|
+
affectedFolders,
|
|
351
|
+
cyclesTouched,
|
|
352
|
+
violationsTouched,
|
|
353
|
+
relatedSignals,
|
|
354
|
+
metrics: {
|
|
355
|
+
fanIn: metrics?.fanIn ?? 0,
|
|
356
|
+
fanOut: metrics?.fanOut ?? 0,
|
|
357
|
+
instability: metrics?.instability ?? 0,
|
|
358
|
+
hotnessScore: metrics?.hotnessScore ?? 0,
|
|
359
|
+
},
|
|
360
|
+
risk: impactRisk({
|
|
361
|
+
affectedModules: affectedModules.length,
|
|
362
|
+
cyclesTouched: cyclesTouched.length,
|
|
363
|
+
violationsTouched,
|
|
364
|
+
}),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function resolveImpactTarget(scan: ScanResult, query: string): ModuleId | null {
|
|
369
|
+
return findModuleMatches(scan, query, 1)[0] ?? null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function findModuleMatches(scan: ScanResult, query: string, limit = 10): ModuleId[] {
|
|
373
|
+
if (scan.modules.some((module) => module.id === query)) return [query];
|
|
374
|
+
const matches = scan.modules
|
|
375
|
+
.map((module) => module.id)
|
|
376
|
+
.filter((id) => id.includes(query))
|
|
377
|
+
.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
378
|
+
return matches.slice(0, limit);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function buildExplainView(
|
|
382
|
+
scan: ScanResult,
|
|
383
|
+
options: { signal?: string; cycle?: string; module?: string },
|
|
384
|
+
): ExplainView {
|
|
385
|
+
if (options.signal) {
|
|
386
|
+
const signal = (scan.signals ?? []).find(
|
|
387
|
+
(item) => item.stableKey === options.signal || item.id === options.signal,
|
|
388
|
+
);
|
|
389
|
+
if (signal) {
|
|
390
|
+
return {
|
|
391
|
+
kind: 'signal',
|
|
392
|
+
title: signal.title,
|
|
393
|
+
severity: signal.severity,
|
|
394
|
+
confidence: signal.confidence,
|
|
395
|
+
evidence: signal.evidence.map((item) => item.message),
|
|
396
|
+
modules: signal.modules,
|
|
397
|
+
nextSteps: signalNextSteps(signal),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (options.cycle) {
|
|
403
|
+
const cycle = scan.cycles.find((item) => item.id === options.cycle);
|
|
404
|
+
if (cycle) {
|
|
405
|
+
const details = explainCycleDetails(scan, cycle);
|
|
406
|
+
return {
|
|
407
|
+
kind: 'cycle',
|
|
408
|
+
title: `Cycle ${cycle.id}`,
|
|
409
|
+
severity: cycle.severity,
|
|
410
|
+
evidence: [
|
|
411
|
+
`${cycle.length} modules`,
|
|
412
|
+
`Affected areas: ${details.affectedAreas.join(', ')}; layers: ${details.affectedLayers.join(', ')}`,
|
|
413
|
+
details.suggestedBreakpoint
|
|
414
|
+
? `Suggested break: ${details.suggestedBreakpoint.from} -> ${details.suggestedBreakpoint.to}`
|
|
415
|
+
: 'No deterministic break point available',
|
|
416
|
+
],
|
|
417
|
+
modules: cycle.modules,
|
|
418
|
+
cycle: details,
|
|
419
|
+
nextSteps: ['Review the suggested edge first.', 'Prefer type-only imports when safe.'],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (options.module) {
|
|
425
|
+
const target = resolveImpactTarget(scan, options.module);
|
|
426
|
+
const module = target ? scan.modules.find((item) => item.id === target) : undefined;
|
|
427
|
+
if (module) {
|
|
428
|
+
const metrics = scan.metrics[module.id];
|
|
429
|
+
const impact = buildImpactView(scan, module.id);
|
|
430
|
+
return {
|
|
431
|
+
kind: 'module',
|
|
432
|
+
title: module.id,
|
|
433
|
+
evidence: [
|
|
434
|
+
`kind=${module.kind}`,
|
|
435
|
+
`loc=${module.loc}`,
|
|
436
|
+
`fanIn=${metrics?.fanIn ?? 0}`,
|
|
437
|
+
`fanOut=${metrics?.fanOut ?? 0}`,
|
|
438
|
+
`affectedModules=${impact.affectedModules.length}`,
|
|
439
|
+
],
|
|
440
|
+
modules: [module.id],
|
|
441
|
+
nextSteps: moduleNextSteps(scan, module.id, impact),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const topSignal = [...(scan.signals ?? [])].sort((a, b) => b.ranking.score - a.ranking.score)[0];
|
|
447
|
+
return {
|
|
448
|
+
kind: 'project',
|
|
449
|
+
title: `${scan.project.name} architecture summary`,
|
|
450
|
+
evidence: [
|
|
451
|
+
`grade=${scan.archDebt.grade}`,
|
|
452
|
+
`cycles=${scan.cycles.length}`,
|
|
453
|
+
`layerViolations=${scan.layerViolations.length}`,
|
|
454
|
+
`contractViolations=${scan.contractViolations.length}`,
|
|
455
|
+
`configDiagnostics=${scan.configDiagnostics?.length ?? 0}`,
|
|
456
|
+
],
|
|
457
|
+
modules: topSignal?.modules ?? [],
|
|
458
|
+
nextSteps: projectNextSteps(scan),
|
|
459
|
+
...(topSignal?.severity ? { severity: topSignal.severity } : {}),
|
|
460
|
+
...(topSignal?.confidence ? { confidence: topSignal.confidence } : {}),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function explainCycleDetails(
|
|
465
|
+
scan: ScanResult,
|
|
466
|
+
cycle: ScanResult['cycles'][number],
|
|
467
|
+
): ExplainCycleDetails {
|
|
468
|
+
const violationEdges = new Set(
|
|
469
|
+
scan.layerViolations.map((violation) => edgeKey(violation.from, violation.to)),
|
|
470
|
+
);
|
|
471
|
+
const edges = cyclePathEdges(scan, cycle, violationEdges);
|
|
472
|
+
const fallbackBreakpoint =
|
|
473
|
+
edges.find((edge) => edge.violatesBoundary) ??
|
|
474
|
+
edges.find((edge) => edge.crossesLayer) ??
|
|
475
|
+
edges[0];
|
|
476
|
+
const suggestedBreakpoint = cycle.suggestedBreakpoint
|
|
477
|
+
? { ...cycle.suggestedBreakpoint, reason: 'analyzer' }
|
|
478
|
+
: fallbackBreakpoint
|
|
479
|
+
? {
|
|
480
|
+
from: fallbackBreakpoint.from,
|
|
481
|
+
to: fallbackBreakpoint.to,
|
|
482
|
+
reason: fallbackBreakpoint.violatesBoundary
|
|
483
|
+
? 'rule-violation'
|
|
484
|
+
: fallbackBreakpoint.crossesLayer
|
|
485
|
+
? 'cross-layer'
|
|
486
|
+
: 'cycle-edge',
|
|
487
|
+
}
|
|
488
|
+
: null;
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
affectedAreas: sortedUnique(cycle.modules.map(areaOf)),
|
|
492
|
+
affectedFolders: sortedUnique(cycle.modules.map(folderOf)),
|
|
493
|
+
affectedLayers: sortedUnique(cycle.modules.map((id) => projectLayerName(detectLayer(id)))),
|
|
494
|
+
edges,
|
|
495
|
+
suggestedBreakpoint,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function cyclePathEdges(
|
|
500
|
+
scan: ScanResult,
|
|
501
|
+
cycle: ScanResult['cycles'][number],
|
|
502
|
+
violationEdges: ReadonlySet<string>,
|
|
503
|
+
): ExplainCycleEdge[] {
|
|
504
|
+
const edgesByPair = new Map(scan.edges.map((edge) => [edgeKey(edge.from, edge.to), edge]));
|
|
505
|
+
const path: ExplainCycleEdge[] = [];
|
|
506
|
+
for (let index = 0; index < cycle.modules.length; index++) {
|
|
507
|
+
const from = cycle.modules[index];
|
|
508
|
+
const to = cycle.modules[(index + 1) % cycle.modules.length];
|
|
509
|
+
if (!from || !to) continue;
|
|
510
|
+
const edge = edgesByPair.get(edgeKey(from, to));
|
|
511
|
+
if (!edge) continue;
|
|
512
|
+
const fromLayer = projectLayerName(detectLayer(from));
|
|
513
|
+
const toLayer = projectLayerName(detectLayer(to));
|
|
514
|
+
path.push({
|
|
515
|
+
from,
|
|
516
|
+
to,
|
|
517
|
+
kind: edge.kind,
|
|
518
|
+
specifier: edge.specifier,
|
|
519
|
+
fromLayer,
|
|
520
|
+
toLayer,
|
|
521
|
+
fromFolder: folderOf(from),
|
|
522
|
+
toFolder: folderOf(to),
|
|
523
|
+
crossesLayer: fromLayer !== toLayer,
|
|
524
|
+
violatesBoundary: violationEdges.has(edgeKey(from, to)),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return path;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function buildSignalBaselineView(
|
|
531
|
+
baseline: ScanResult | null,
|
|
532
|
+
current: ScanResult,
|
|
533
|
+
): SignalBaselineView {
|
|
534
|
+
if (!baseline) {
|
|
535
|
+
const currentSignals = current.signals ?? [];
|
|
536
|
+
return {
|
|
537
|
+
current: currentSignals,
|
|
538
|
+
resolved: [],
|
|
539
|
+
newSignals: currentSignals,
|
|
540
|
+
regressedSignals: [],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const reconciled = reconcileSignalLifecycle(baseline.signals ?? [], current.signals ?? []);
|
|
544
|
+
return {
|
|
545
|
+
current: reconciled.current,
|
|
546
|
+
resolved: reconciled.resolved,
|
|
547
|
+
newSignals: reconciled.current.filter((signal) => signal.status === 'new'),
|
|
548
|
+
regressedSignals: reconciled.current.filter((signal) => signal.status === 'regressed'),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function countBaselineSignals(
|
|
553
|
+
view: SignalBaselineView,
|
|
554
|
+
kind: 'new' | 'regressed',
|
|
555
|
+
minSeverity: SignalSeverity,
|
|
556
|
+
): number {
|
|
557
|
+
const source = kind === 'new' ? view.newSignals : view.regressedSignals;
|
|
558
|
+
return source.filter(
|
|
559
|
+
(signal) =>
|
|
560
|
+
SEVERITY_RANK[signal.severity] >= SEVERITY_RANK[minSeverity] &&
|
|
561
|
+
canSignalFailCi(signal, { minSeverity }),
|
|
562
|
+
).length;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function groupModule(id: ModuleId, grouping: MatrixGrouping): string {
|
|
566
|
+
if (grouping === 'area') return areaOf(id);
|
|
567
|
+
if (grouping === 'layer') return projectLayerName(detectLayer(id));
|
|
568
|
+
if (grouping === 'package') return packageOf(id);
|
|
569
|
+
return folderOf(id);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function buildReviewRiskView(
|
|
573
|
+
scan: ScanResult,
|
|
574
|
+
options: { baseline?: ScanResult | null; diff?: ScanDiff | null } = {},
|
|
575
|
+
): ReviewRiskView {
|
|
576
|
+
const reasons: string[] = [];
|
|
577
|
+
const regressions: string[] = [];
|
|
578
|
+
const reviewCycles = scan.cycles.filter((cycle) => cycle.modules.some(isReviewModule));
|
|
579
|
+
const reviewViolations = scan.layerViolations.filter(
|
|
580
|
+
(violation) => isReviewModule(violation.from) && isReviewModule(violation.to),
|
|
581
|
+
);
|
|
582
|
+
const reviewContractViolations = scan.contractViolations.filter((violation) =>
|
|
583
|
+
violation.modules.some(isReviewModule),
|
|
584
|
+
);
|
|
585
|
+
const reviewHotZones = scan.hotZones.filter(isReviewModule);
|
|
586
|
+
const reviewSignals = (scan.signals ?? []).filter((signal) =>
|
|
587
|
+
signal.modules.some(isReviewModule),
|
|
588
|
+
);
|
|
589
|
+
const lifecycleHygiene = buildLifecycleHygieneView(scan);
|
|
590
|
+
const directCycles = reviewCycles.filter((cycle) => cycle.severity === 'direct').length;
|
|
591
|
+
const errorViolations = reviewViolations.filter(
|
|
592
|
+
(violation) => violation.severity === 'error',
|
|
593
|
+
).length;
|
|
594
|
+
const contractErrors = reviewContractViolations.filter(
|
|
595
|
+
(violation) => violation.severity === 'error',
|
|
596
|
+
).length;
|
|
597
|
+
const ciSignals = reviewSignals.filter((signal) =>
|
|
598
|
+
canSignalFailCi(signal, { minSeverity: 'high' }),
|
|
599
|
+
).length;
|
|
600
|
+
const hotspots = reviewHotZones.length;
|
|
601
|
+
const lifecycleReviewOwners = lifecycleHygiene.sideEffectOwners.filter(
|
|
602
|
+
(owner) => owner.placement === 'review',
|
|
603
|
+
).length;
|
|
604
|
+
const lifecycleRisks =
|
|
605
|
+
lifecycleHygiene.summary.memoryRisks + lifecycleHygiene.summary.asyncLifecycleRisks;
|
|
606
|
+
const baselineView = options.baseline ? buildSignalBaselineView(options.baseline, scan) : null;
|
|
607
|
+
const baseline = options.diff
|
|
608
|
+
? {
|
|
609
|
+
addedModules: options.diff.summary.addedModules,
|
|
610
|
+
changedModules: options.diff.summary.changedModules,
|
|
611
|
+
removedModules: options.diff.summary.removedModules,
|
|
612
|
+
newCycles: options.diff.summary.newCycles,
|
|
613
|
+
resolvedCycles: options.diff.summary.resolvedCycles,
|
|
614
|
+
newSignals: baselineView?.newSignals.length ?? 0,
|
|
615
|
+
regressedSignals: baselineView?.regressedSignals.length ?? 0,
|
|
616
|
+
resolvedSignals: baselineView?.resolved.length ?? 0,
|
|
617
|
+
}
|
|
618
|
+
: undefined;
|
|
619
|
+
let score = scan.archDebt.score;
|
|
620
|
+
score += directCycles * 12;
|
|
621
|
+
score += errorViolations * 10;
|
|
622
|
+
score += contractErrors * 10;
|
|
623
|
+
score += ciSignals * 8;
|
|
624
|
+
score += Math.min(15, hotspots * 2);
|
|
625
|
+
score += Math.min(22, lifecycleReviewOwners * 12 + lifecycleRisks * 3);
|
|
626
|
+
if (baseline) {
|
|
627
|
+
score += baseline.newCycles * 15;
|
|
628
|
+
score += baseline.regressedSignals * 10;
|
|
629
|
+
score += baseline.newSignals * 5;
|
|
630
|
+
score -= Math.min(15, baseline.resolvedCycles * 6 + baseline.resolvedSignals * 3);
|
|
631
|
+
}
|
|
632
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
633
|
+
|
|
634
|
+
if (directCycles > 0) reasons.push(`${directCycles} direct cycle(s)`);
|
|
635
|
+
if (errorViolations > 0) reasons.push(`${errorViolations} layer error(s)`);
|
|
636
|
+
if (contractErrors > 0) reasons.push(`${contractErrors} contract error(s)`);
|
|
637
|
+
if (ciSignals > 0) reasons.push(`${ciSignals} CI-safe high signal(s)`);
|
|
638
|
+
if (hotspots > 0) reasons.push(`${hotspots} hot zone(s)`);
|
|
639
|
+
if (lifecycleReviewOwners > 0) {
|
|
640
|
+
reasons.push(`${lifecycleReviewOwners} lifecycle owner review(s)`);
|
|
641
|
+
}
|
|
642
|
+
if (baseline) {
|
|
643
|
+
if (baseline.newCycles > 0) regressions.push(`${baseline.newCycles} new cycle(s)`);
|
|
644
|
+
if (baseline.regressedSignals > 0) {
|
|
645
|
+
regressions.push(`${baseline.regressedSignals} regressed signal(s)`);
|
|
646
|
+
}
|
|
647
|
+
if (baseline.newSignals > 0) regressions.push(`${baseline.newSignals} new signal(s)`);
|
|
648
|
+
if (baseline.changedModules > 0) {
|
|
649
|
+
regressions.push(`${baseline.changedModules} changed module(s)`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const level = score >= 80 ? 'critical' : score >= 55 ? 'high' : score >= 25 ? 'medium' : 'low';
|
|
654
|
+
const checkFirst = sortedUnique([
|
|
655
|
+
...reviewHotZones.slice(0, 5),
|
|
656
|
+
...reviewCycles
|
|
657
|
+
.slice(0, 3)
|
|
658
|
+
.flatMap((cycle) => cycle.modules.filter(isReviewModule).slice(0, 2)),
|
|
659
|
+
...reviewViolations.slice(0, 3).flatMap((violation) => [violation.from, violation.to]),
|
|
660
|
+
...lifecycleHygiene.sideEffectOwners
|
|
661
|
+
.filter((owner) => owner.placement === 'review')
|
|
662
|
+
.slice(0, 3)
|
|
663
|
+
.map((owner) => owner.id),
|
|
664
|
+
]).slice(0, 8);
|
|
665
|
+
const affectedAreas = sortedUnique(checkFirst.map(areaOf));
|
|
666
|
+
const guidedActions = buildGuidedReviewActions({
|
|
667
|
+
cycles: reviewCycles,
|
|
668
|
+
violations: reviewViolations,
|
|
669
|
+
contractViolations: reviewContractViolations,
|
|
670
|
+
signals: reviewSignals,
|
|
671
|
+
hotZones: reviewHotZones,
|
|
672
|
+
lifecycleOwners: lifecycleHygiene.sideEffectOwners,
|
|
673
|
+
});
|
|
674
|
+
return {
|
|
675
|
+
score,
|
|
676
|
+
level,
|
|
677
|
+
summary:
|
|
678
|
+
level === 'low'
|
|
679
|
+
? 'No blocking architecture risk found.'
|
|
680
|
+
: 'Review architecture risk before broad changes or release.',
|
|
681
|
+
reasons,
|
|
682
|
+
guidedActions,
|
|
683
|
+
checkFirst,
|
|
684
|
+
affectedAreas,
|
|
685
|
+
regressions,
|
|
686
|
+
...(baseline ? { baseline } : {}),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function findingModules(scan: ScanResult): Set<ModuleId> {
|
|
691
|
+
const ids = new Set<ModuleId>();
|
|
692
|
+
for (const id of scan.hotZones) ids.add(id);
|
|
693
|
+
for (const cycle of scan.cycles) {
|
|
694
|
+
for (const id of cycle.modules) ids.add(id);
|
|
695
|
+
}
|
|
696
|
+
for (const violation of scan.layerViolations) {
|
|
697
|
+
ids.add(violation.from);
|
|
698
|
+
ids.add(violation.to);
|
|
699
|
+
}
|
|
700
|
+
for (const violation of scan.contractViolations) {
|
|
701
|
+
for (const id of violation.modules) ids.add(id);
|
|
702
|
+
if (violation.edge) {
|
|
703
|
+
ids.add(violation.edge.from);
|
|
704
|
+
ids.add(violation.edge.to);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return ids;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function buildGuidedReviewActions(input: {
|
|
711
|
+
cycles: Cycle[];
|
|
712
|
+
violations: LayerViolation[];
|
|
713
|
+
contractViolations: ScanResult['contractViolations'];
|
|
714
|
+
signals: ArchitectureSignal[];
|
|
715
|
+
hotZones: ModuleId[];
|
|
716
|
+
lifecycleOwners: SideEffectOwner[];
|
|
717
|
+
}): GuidedReviewAction[] {
|
|
718
|
+
const actions: GuidedReviewAction[] = [];
|
|
719
|
+
const directCycle = input.cycles.find((cycle) => cycle.severity === 'direct') ?? input.cycles[0];
|
|
720
|
+
if (directCycle) {
|
|
721
|
+
actions.push({
|
|
722
|
+
kind: 'cycle',
|
|
723
|
+
title: 'Break cycle boundary',
|
|
724
|
+
target: directCycle.suggestedBreakpoint?.from ?? directCycle.modules[0] ?? 'project',
|
|
725
|
+
action: 'Move the narrowest dependency or introduce an explicit boundary.',
|
|
726
|
+
evidence: `${directCycle.length} module cycle`,
|
|
727
|
+
verify: 'Run review again and confirm the cycle no longer appears.',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const violation =
|
|
731
|
+
input.violations.find((item) => item.severity === 'error') ?? input.violations[0];
|
|
732
|
+
if (violation) {
|
|
733
|
+
actions.push({
|
|
734
|
+
kind: 'rule',
|
|
735
|
+
title: 'Fix layer boundary',
|
|
736
|
+
target: violation.from,
|
|
737
|
+
action: 'Move the dependency behind an allowed layer or adjust the rule.',
|
|
738
|
+
evidence: `${violation.fromLayer} -> ${violation.toLayer}`,
|
|
739
|
+
verify: 'Run check or review and confirm the layer violation count drops.',
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
const contractViolation =
|
|
743
|
+
input.contractViolations.find((item) => item.severity === 'error') ??
|
|
744
|
+
input.contractViolations[0];
|
|
745
|
+
if (contractViolation?.modules[0]) {
|
|
746
|
+
actions.push({
|
|
747
|
+
kind: 'contract',
|
|
748
|
+
title: 'Check contract boundary',
|
|
749
|
+
target: contractViolation.modules[0],
|
|
750
|
+
action: 'Move the dependency behind an allowed boundary or update the contract.',
|
|
751
|
+
evidence: contractViolation.message,
|
|
752
|
+
verify: 'Run review again and confirm the contract violation is gone or explicitly accepted.',
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const signal = input.signals.find((item) => canSignalFailCi(item, { minSeverity: 'high' }));
|
|
756
|
+
if (signal?.modules[0]) {
|
|
757
|
+
actions.push({
|
|
758
|
+
kind: 'signal',
|
|
759
|
+
title: 'Review CI-safe signal',
|
|
760
|
+
target: signal.modules[0],
|
|
761
|
+
action: 'Separate blocking findings from review-only observations.',
|
|
762
|
+
evidence: signal.evidence[0]?.message ?? signal.title,
|
|
763
|
+
verify: 'Run review against the baseline and confirm no new CI-safe signal remains.',
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const lifecycleOwner = input.lifecycleOwners.find((owner) => owner.placement === 'review');
|
|
767
|
+
if (lifecycleOwner) {
|
|
768
|
+
actions.push({
|
|
769
|
+
kind: 'lifecycle',
|
|
770
|
+
title: 'Assign lifecycle owner',
|
|
771
|
+
target: lifecycleOwner.id,
|
|
772
|
+
action: 'Move browser side effects to an explicit hook, service, store, or route boundary.',
|
|
773
|
+
evidence: `${lifecycleOwner.memoryRisks} memory, ${lifecycleOwner.asyncLifecycleRisks} async lifecycle`,
|
|
774
|
+
verify: 'Run hygiene and confirm the module is no longer listed as a review boundary.',
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const hotspot = input.hotZones[0];
|
|
778
|
+
if (hotspot) {
|
|
779
|
+
actions.push({
|
|
780
|
+
kind: 'hotspot',
|
|
781
|
+
title: 'Review hotspot impact',
|
|
782
|
+
target: hotspot,
|
|
783
|
+
action: 'Open impact before changing this module.',
|
|
784
|
+
evidence: 'Hot zone module',
|
|
785
|
+
verify: 'Run impact for this module and check the top importers before editing.',
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return actions.slice(0, 5);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function projectLayerName(layer: string): string {
|
|
792
|
+
return layer === 'unknown' ? 'project' : layer;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function buildOwnershipView(scan: ScanResult): OwnershipView {
|
|
796
|
+
const areas = new Map<string, OwnershipArea>();
|
|
797
|
+
const findingIds = findingModules(scan);
|
|
798
|
+
for (const module of scan.modules) {
|
|
799
|
+
if (!isReviewModule(module.id)) continue;
|
|
800
|
+
const area = areaOf(module.id);
|
|
801
|
+
const current = areas.get(area) ?? {
|
|
802
|
+
area,
|
|
803
|
+
modules: 0,
|
|
804
|
+
findings: 0,
|
|
805
|
+
riskScore: 0,
|
|
806
|
+
primaryKind: module.kind,
|
|
807
|
+
ownerHint: area,
|
|
808
|
+
};
|
|
809
|
+
current.modules += 1;
|
|
810
|
+
if (findingIds.has(module.id)) current.findings += 1;
|
|
811
|
+
const metrics = scan.metrics[module.id];
|
|
812
|
+
current.riskScore +=
|
|
813
|
+
(metrics?.hotnessScore ?? 0) +
|
|
814
|
+
(metrics?.couplingScore ?? 0) +
|
|
815
|
+
(metrics?.fanIn ?? 0) +
|
|
816
|
+
(metrics?.fanOut ?? 0) +
|
|
817
|
+
(findingIds.has(module.id) ? 10 : 0);
|
|
818
|
+
if (current.modules === 1 || module.kind !== 'module') current.primaryKind = module.kind;
|
|
819
|
+
areas.set(area, current);
|
|
820
|
+
}
|
|
821
|
+
const sorted = [...areas.values()].sort(
|
|
822
|
+
(a, b) => b.riskScore - a.riskScore || a.area.localeCompare(b.area),
|
|
823
|
+
);
|
|
824
|
+
return {
|
|
825
|
+
areas: sorted,
|
|
826
|
+
drift: sorted.filter(
|
|
827
|
+
(area) => area.modules >= 3 && area.findings > 0 && area.findings / area.modules >= 0.3,
|
|
828
|
+
),
|
|
829
|
+
unownedHotspots: scan.hotZones.filter(
|
|
830
|
+
(id) => isReviewModule(id) && (areaOf(id) === 'project' || folderOf(id) === '.'),
|
|
831
|
+
),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function buildSemanticSurfaceView(scan: ScanResult): SemanticSurfaceView {
|
|
836
|
+
const modules = scan.modules
|
|
837
|
+
.filter((module) => !module.isInfra && isReviewModule(module.id))
|
|
838
|
+
.map((module): SemanticSurfaceModule => {
|
|
839
|
+
const metrics = scan.metrics[module.id];
|
|
840
|
+
const exports = module.exports.length;
|
|
841
|
+
const fanIn = metrics?.fanIn ?? 0;
|
|
842
|
+
const fanOut = metrics?.fanOut ?? 0;
|
|
843
|
+
return {
|
|
844
|
+
id: module.id,
|
|
845
|
+
exports,
|
|
846
|
+
fanIn,
|
|
847
|
+
fanOut,
|
|
848
|
+
role: module.kind,
|
|
849
|
+
risk: exports * 2 + fanIn * 3 + fanOut,
|
|
850
|
+
};
|
|
851
|
+
});
|
|
852
|
+
const broadPublicModules = modules
|
|
853
|
+
.filter((module) => module.exports >= 8 || (module.exports >= 4 && module.fanIn >= 4))
|
|
854
|
+
.sort((a, b) => b.risk - a.risk || a.id.localeCompare(b.id))
|
|
855
|
+
.slice(0, 20);
|
|
856
|
+
const quietExports = modules
|
|
857
|
+
.filter(
|
|
858
|
+
(module) =>
|
|
859
|
+
module.exports > 0 &&
|
|
860
|
+
module.fanIn === 0 &&
|
|
861
|
+
module.role !== 'entry' &&
|
|
862
|
+
module.role !== 'test',
|
|
863
|
+
)
|
|
864
|
+
.sort((a, b) => b.exports - a.exports || a.id.localeCompare(b.id))
|
|
865
|
+
.slice(0, 20);
|
|
866
|
+
const typeClusters = modules
|
|
867
|
+
.filter(
|
|
868
|
+
(module) =>
|
|
869
|
+
module.role === 'schema' || /(^|\/)(types?|schemas?|models?)(\/|\.)/u.test(module.id),
|
|
870
|
+
)
|
|
871
|
+
.sort((a, b) => b.risk - a.risk || a.id.localeCompare(b.id))
|
|
872
|
+
.slice(0, 20);
|
|
873
|
+
return { broadPublicModules, quietExports, typeClusters };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export function buildLifecycleHygieneView(scan: ScanResult): LifecycleHygieneView {
|
|
877
|
+
const items = scan.modules
|
|
878
|
+
.filter((module) => isReviewModule(module.id))
|
|
879
|
+
.map((module): LifecycleHygieneItem => {
|
|
880
|
+
const metrics = scan.metrics[module.id];
|
|
881
|
+
const fanIn = metrics?.fanIn ?? 0;
|
|
882
|
+
const fanOut = metrics?.fanOut ?? 0;
|
|
883
|
+
const exports = module.exports.length;
|
|
884
|
+
const loc = module.loc;
|
|
885
|
+
return {
|
|
886
|
+
id: module.id,
|
|
887
|
+
reason: 'detached',
|
|
888
|
+
fanIn,
|
|
889
|
+
fanOut,
|
|
890
|
+
exports,
|
|
891
|
+
loc,
|
|
892
|
+
risk: loc + exports * 4 + fanIn * 3 + fanOut * 2,
|
|
893
|
+
};
|
|
894
|
+
})
|
|
895
|
+
.filter((item) => item.loc > 0);
|
|
896
|
+
|
|
897
|
+
const sourceModules = new Map(scan.modules.map((module) => [module.id, module]));
|
|
898
|
+
const removableCandidates = items
|
|
899
|
+
.filter((item) => {
|
|
900
|
+
const module = sourceModules.get(item.id);
|
|
901
|
+
return (
|
|
902
|
+
item.fanIn === 0 &&
|
|
903
|
+
item.fanOut === 0 &&
|
|
904
|
+
item.exports === 0 &&
|
|
905
|
+
module?.kind !== 'entry' &&
|
|
906
|
+
module?.kind !== 'test' &&
|
|
907
|
+
!module?.isGenerated &&
|
|
908
|
+
!module?.isInfra
|
|
909
|
+
);
|
|
910
|
+
})
|
|
911
|
+
.sort((a, b) => b.risk - a.risk || a.id.localeCompare(b.id))
|
|
912
|
+
.slice(0, 30);
|
|
913
|
+
|
|
914
|
+
const entryCandidates = items
|
|
915
|
+
.filter((item) => {
|
|
916
|
+
const module = sourceModules.get(item.id);
|
|
917
|
+
return (
|
|
918
|
+
item.fanIn === 0 &&
|
|
919
|
+
item.fanOut > 0 &&
|
|
920
|
+
module?.kind !== 'entry' &&
|
|
921
|
+
module?.kind !== 'test' &&
|
|
922
|
+
!module?.isGenerated
|
|
923
|
+
);
|
|
924
|
+
})
|
|
925
|
+
.map((item) => ({ ...item, reason: 'entry-candidate' as const }))
|
|
926
|
+
.sort((a, b) => b.fanOut - a.fanOut || b.risk - a.risk || a.id.localeCompare(b.id))
|
|
927
|
+
.slice(0, 30);
|
|
928
|
+
|
|
929
|
+
const generatedPressure = items
|
|
930
|
+
.filter((item) => {
|
|
931
|
+
const module = sourceModules.get(item.id);
|
|
932
|
+
return Boolean(module?.isGenerated) && (item.fanIn + item.fanOut >= 20 || item.loc >= 500);
|
|
933
|
+
})
|
|
934
|
+
.map((item) => ({ ...item, reason: 'generated-pressure' as const }))
|
|
935
|
+
.sort((a, b) => b.risk - a.risk || a.id.localeCompare(b.id))
|
|
936
|
+
.slice(0, 30);
|
|
937
|
+
const lifecycleRiskModules = buildLifecycleRiskModules(scan);
|
|
938
|
+
const sideEffectOwners = buildSideEffectOwners(scan);
|
|
939
|
+
|
|
940
|
+
return {
|
|
941
|
+
summary: {
|
|
942
|
+
removableCandidates: removableCandidates.length,
|
|
943
|
+
entryCandidates: entryCandidates.length,
|
|
944
|
+
generatedPressure: generatedPressure.length,
|
|
945
|
+
memoryRisks: scan.memoryRisks?.length ?? 0,
|
|
946
|
+
asyncLifecycleRisks: scan.asyncLifecycleRisks?.length ?? 0,
|
|
947
|
+
lifecycleRiskModules: lifecycleRiskModules.length,
|
|
948
|
+
sideEffectOwners: sideEffectOwners.length,
|
|
949
|
+
},
|
|
950
|
+
removableCandidates,
|
|
951
|
+
entryCandidates,
|
|
952
|
+
generatedPressure,
|
|
953
|
+
lifecycleRiskModules,
|
|
954
|
+
sideEffectOwners,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function buildSideEffectOwners(scan: ScanResult): SideEffectOwner[] {
|
|
959
|
+
const modules = new Map(scan.modules.map((module) => [module.id, module]));
|
|
960
|
+
const byModule = new Map<ModuleId, SideEffectOwner>();
|
|
961
|
+
for (const risk of scan.memoryRisks ?? []) {
|
|
962
|
+
const item = sideEffectOwnerFor(byModule, modules, risk.moduleId);
|
|
963
|
+
item.memoryRisks += 1;
|
|
964
|
+
item.totalRisks += 1;
|
|
965
|
+
}
|
|
966
|
+
for (const risk of scan.asyncLifecycleRisks ?? []) {
|
|
967
|
+
const item = sideEffectOwnerFor(byModule, modules, risk.moduleId);
|
|
968
|
+
item.asyncLifecycleRisks += 1;
|
|
969
|
+
item.totalRisks += 1;
|
|
970
|
+
}
|
|
971
|
+
return [...byModule.values()]
|
|
972
|
+
.sort(
|
|
973
|
+
(a, b) =>
|
|
974
|
+
placementRank(b.placement) - placementRank(a.placement) ||
|
|
975
|
+
b.totalRisks - a.totalRisks ||
|
|
976
|
+
a.id.localeCompare(b.id),
|
|
977
|
+
)
|
|
978
|
+
.slice(0, 30);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function sideEffectOwnerFor(
|
|
982
|
+
byModule: Map<ModuleId, SideEffectOwner>,
|
|
983
|
+
modules: Map<ModuleId, ScanResult['modules'][number]>,
|
|
984
|
+
id: ModuleId,
|
|
985
|
+
): SideEffectOwner {
|
|
986
|
+
const existing = byModule.get(id);
|
|
987
|
+
if (existing) return existing;
|
|
988
|
+
const module = modules.get(id);
|
|
989
|
+
const kind = module?.kind ?? 'module';
|
|
990
|
+
const item: SideEffectOwner = {
|
|
991
|
+
id,
|
|
992
|
+
owner: ownerOf(id),
|
|
993
|
+
layer: projectLayerName(detectLayer(id)),
|
|
994
|
+
kind,
|
|
995
|
+
memoryRisks: 0,
|
|
996
|
+
asyncLifecycleRisks: 0,
|
|
997
|
+
totalRisks: 0,
|
|
998
|
+
placement: sideEffectPlacement(id, kind),
|
|
999
|
+
};
|
|
1000
|
+
byModule.set(id, item);
|
|
1001
|
+
return item;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function sideEffectPlacement(
|
|
1005
|
+
id: ModuleId,
|
|
1006
|
+
kind: ScanResult['modules'][number]['kind'],
|
|
1007
|
+
): SideEffectOwner['placement'] {
|
|
1008
|
+
if (/\/shared\/(lib|utils?|model|schema|config)(\/|\.)/u.test(id)) return 'review';
|
|
1009
|
+
if (kind === 'component' || kind === 'composable' || kind === 'service') return 'owned';
|
|
1010
|
+
if (kind === 'store' || kind === 'route' || kind === 'entry' || kind === 'integration') {
|
|
1011
|
+
return 'owned';
|
|
1012
|
+
}
|
|
1013
|
+
return 'review';
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function placementRank(placement: SideEffectOwner['placement']): number {
|
|
1017
|
+
return placement === 'review' ? 1 : 0;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function buildLifecycleRiskModules(scan: ScanResult): LifecycleRiskModule[] {
|
|
1021
|
+
const byModule = new Map<ModuleId, LifecycleRiskModule>();
|
|
1022
|
+
for (const risk of scan.memoryRisks ?? []) {
|
|
1023
|
+
const item = lifecycleRiskItemFor(byModule, risk.moduleId);
|
|
1024
|
+
item.memoryRisks += 1;
|
|
1025
|
+
item.totalRisks += 1;
|
|
1026
|
+
item.confidence = higherConfidence(item.confidence, risk.confidence);
|
|
1027
|
+
item.severity = higherLifecycleSeverity(item.severity, risk.severity);
|
|
1028
|
+
}
|
|
1029
|
+
for (const risk of scan.asyncLifecycleRisks ?? []) {
|
|
1030
|
+
const item = lifecycleRiskItemFor(byModule, risk.moduleId);
|
|
1031
|
+
item.asyncLifecycleRisks += 1;
|
|
1032
|
+
item.totalRisks += 1;
|
|
1033
|
+
item.confidence = higherConfidence(item.confidence, risk.confidence);
|
|
1034
|
+
item.severity = higherLifecycleSeverity(item.severity, risk.severity);
|
|
1035
|
+
}
|
|
1036
|
+
return [...byModule.values()]
|
|
1037
|
+
.sort(
|
|
1038
|
+
(a, b) =>
|
|
1039
|
+
b.totalRisks - a.totalRisks ||
|
|
1040
|
+
confidenceRank(b.confidence) - confidenceRank(a.confidence) ||
|
|
1041
|
+
a.id.localeCompare(b.id),
|
|
1042
|
+
)
|
|
1043
|
+
.slice(0, 30);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function lifecycleRiskItemFor(
|
|
1047
|
+
byModule: Map<ModuleId, LifecycleRiskModule>,
|
|
1048
|
+
id: ModuleId,
|
|
1049
|
+
): LifecycleRiskModule {
|
|
1050
|
+
const existing = byModule.get(id);
|
|
1051
|
+
if (existing) return existing;
|
|
1052
|
+
const item: LifecycleRiskModule = {
|
|
1053
|
+
id,
|
|
1054
|
+
memoryRisks: 0,
|
|
1055
|
+
asyncLifecycleRisks: 0,
|
|
1056
|
+
totalRisks: 0,
|
|
1057
|
+
confidence: 'low',
|
|
1058
|
+
severity: 'low',
|
|
1059
|
+
};
|
|
1060
|
+
byModule.set(id, item);
|
|
1061
|
+
return item;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function higherConfidence<T extends 'low' | 'medium' | 'high'>(a: T, b: T): T {
|
|
1065
|
+
return confidenceRank(a) >= confidenceRank(b) ? a : b;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function higherLifecycleSeverity<T extends 'low' | 'medium'>(a: T, b: T): T {
|
|
1069
|
+
return (a === 'medium' || b === 'medium' ? 'medium' : 'low') as T;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export function buildTrendView(baseline: ScanResult, current: ScanResult): TrendView {
|
|
1073
|
+
const diff = diffScans(baseline, current);
|
|
1074
|
+
const signals = buildSignalBaselineView(baseline, current);
|
|
1075
|
+
const scoreDelta = current.archDebt.score - baseline.archDebt.score;
|
|
1076
|
+
const summary = {
|
|
1077
|
+
scoreDelta,
|
|
1078
|
+
gradeBefore: baseline.archDebt.grade,
|
|
1079
|
+
gradeAfter: current.archDebt.grade,
|
|
1080
|
+
addedModules: diff.summary.addedModules,
|
|
1081
|
+
removedModules: diff.summary.removedModules,
|
|
1082
|
+
changedModules: diff.summary.changedModules,
|
|
1083
|
+
newCycles: diff.summary.newCycles,
|
|
1084
|
+
resolvedCycles: diff.summary.resolvedCycles,
|
|
1085
|
+
newSignals: signals.newSignals.length,
|
|
1086
|
+
regressedSignals: signals.regressedSignals.length,
|
|
1087
|
+
resolvedSignals: signals.resolved.length,
|
|
1088
|
+
};
|
|
1089
|
+
const changes: string[] = [];
|
|
1090
|
+
if (summary.addedModules > 0) changes.push(`${summary.addedModules} added module(s)`);
|
|
1091
|
+
if (summary.changedModules > 0) changes.push(`${summary.changedModules} changed module(s)`);
|
|
1092
|
+
if (summary.newCycles > 0) changes.push(`${summary.newCycles} new cycle(s)`);
|
|
1093
|
+
if (summary.resolvedCycles > 0) changes.push(`${summary.resolvedCycles} resolved cycle(s)`);
|
|
1094
|
+
if (summary.newSignals > 0) changes.push(`${summary.newSignals} new signal(s)`);
|
|
1095
|
+
if (summary.regressedSignals > 0) {
|
|
1096
|
+
changes.push(`${summary.regressedSignals} regressed signal(s)`);
|
|
1097
|
+
}
|
|
1098
|
+
if (summary.resolvedSignals > 0) changes.push(`${summary.resolvedSignals} resolved signal(s)`);
|
|
1099
|
+
|
|
1100
|
+
const regressionWeight =
|
|
1101
|
+
Math.max(0, scoreDelta) +
|
|
1102
|
+
summary.newCycles * 12 +
|
|
1103
|
+
summary.regressedSignals * 10 +
|
|
1104
|
+
summary.newSignals * 4;
|
|
1105
|
+
const improvementWeight =
|
|
1106
|
+
Math.max(0, -scoreDelta) + summary.resolvedCycles * 8 + summary.resolvedSignals * 3;
|
|
1107
|
+
const direction =
|
|
1108
|
+
regressionWeight > improvementWeight + 5
|
|
1109
|
+
? 'regressed'
|
|
1110
|
+
: improvementWeight > regressionWeight + 5
|
|
1111
|
+
? 'improved'
|
|
1112
|
+
: 'stable';
|
|
1113
|
+
|
|
1114
|
+
return { direction, summary, changes };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const DOMAIN_ROOTS = new Set([
|
|
1118
|
+
'domains',
|
|
1119
|
+
'entities',
|
|
1120
|
+
'features',
|
|
1121
|
+
'mfes',
|
|
1122
|
+
'modules',
|
|
1123
|
+
'pages',
|
|
1124
|
+
'services',
|
|
1125
|
+
'shared',
|
|
1126
|
+
'widgets',
|
|
1127
|
+
]);
|
|
1128
|
+
|
|
1129
|
+
function areaOf(id: ModuleId): string {
|
|
1130
|
+
const parts = id.replace(/\\/gu, '/').split('/').filter(Boolean);
|
|
1131
|
+
const packagesIndex = parts.indexOf('packages');
|
|
1132
|
+
if (packagesIndex !== -1 && parts[packagesIndex + 1]) {
|
|
1133
|
+
return `packages/${parts[packagesIndex + 1]}`;
|
|
1134
|
+
}
|
|
1135
|
+
const appsIndex = parts.indexOf('apps');
|
|
1136
|
+
if (appsIndex !== -1 && parts[appsIndex + 1]) {
|
|
1137
|
+
return `apps/${parts[appsIndex + 1]}`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const srcIndex = parts.indexOf('src');
|
|
1141
|
+
const scope = srcIndex === -1 ? parts : parts.slice(srcIndex + 1);
|
|
1142
|
+
const first = scope[0];
|
|
1143
|
+
if (!first) return 'project';
|
|
1144
|
+
if (isEntryFile(first)) return 'app';
|
|
1145
|
+
if (DOMAIN_ROOTS.has(first) && scope[1] && !isSourceFile(scope[1])) {
|
|
1146
|
+
return `${first}/${scope[1]}`;
|
|
1147
|
+
}
|
|
1148
|
+
return stripExtension(first);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function ownerOf(id: ModuleId): string {
|
|
1152
|
+
const parts = id.replace(/\\/gu, '/').split('/').filter(Boolean);
|
|
1153
|
+
const srcIndex = parts.indexOf('src');
|
|
1154
|
+
if (srcIndex !== -1 && parts[srcIndex + 1]) {
|
|
1155
|
+
const head = parts.slice(srcIndex, srcIndex + 3);
|
|
1156
|
+
const third = head[2];
|
|
1157
|
+
if (third && !isSourceFile(third)) return head.join('/');
|
|
1158
|
+
return parts[srcIndex] ?? 'src';
|
|
1159
|
+
}
|
|
1160
|
+
if (parts.length >= 2) return parts.slice(0, 2).join('/');
|
|
1161
|
+
return parts[0] ?? 'project';
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function folderOf(id: ModuleId): string {
|
|
1165
|
+
const normalized = id.replace(/\\/gu, '/');
|
|
1166
|
+
const index = normalized.lastIndexOf('/');
|
|
1167
|
+
return index === -1 ? '.' : normalized.slice(0, index);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function packageOf(id: ModuleId): string {
|
|
1171
|
+
const parts = id.replace(/\\/gu, '/').split('/');
|
|
1172
|
+
const packageIndex = parts.indexOf('packages');
|
|
1173
|
+
if (packageIndex !== -1 && parts[packageIndex + 1]) {
|
|
1174
|
+
return `packages/${parts[packageIndex + 1]}`;
|
|
1175
|
+
}
|
|
1176
|
+
const appIndex = parts.indexOf('apps');
|
|
1177
|
+
if (appIndex !== -1 && parts[appIndex + 1]) {
|
|
1178
|
+
return `apps/${parts[appIndex + 1]}`;
|
|
1179
|
+
}
|
|
1180
|
+
return folderOf(id);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function isEntryFile(part: string): boolean {
|
|
1184
|
+
return /^(App|app|main|index)\.[cm]?[jt]sx?$/u.test(part) || /^(App|app)\.vue$/u.test(part);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function isSourceFile(part: string): boolean {
|
|
1188
|
+
return /\.[cm]?[jt]sx?$/u.test(part) || /\.(vue|svelte)$/u.test(part);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function isReviewModule(id: ModuleId): boolean {
|
|
1192
|
+
return !/(^|\/)(fixtures|test\/fixtures|__fixtures__|__tests__|__mocks__)(\/|$)|\.(test|spec)\./u.test(
|
|
1193
|
+
id,
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function stripExtension(part: string): string {
|
|
1198
|
+
return part.replace(/\.[^.]+$/u, '');
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function cycleEdgeKeys(scan: ScanResult): Set<string> {
|
|
1202
|
+
const keys = new Set<string>();
|
|
1203
|
+
for (const cycle of scan.cycles) {
|
|
1204
|
+
const members = new Set(cycle.modules);
|
|
1205
|
+
for (const edge of scan.edges) {
|
|
1206
|
+
if (members.has(edge.from) && members.has(edge.to)) keys.add(edgeKey(edge.from, edge.to));
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return keys;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function edgeKey(from: ModuleId, to: ModuleId): string {
|
|
1213
|
+
return `${from}\u0001${to}`;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function sortedUnique(values: readonly string[]): string[] {
|
|
1217
|
+
return [...new Set(values)].sort();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function confidenceRank(value: 'low' | 'medium' | 'high'): number {
|
|
1221
|
+
if (value === 'high') return 3;
|
|
1222
|
+
if (value === 'medium') return 2;
|
|
1223
|
+
return 1;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function transitiveImporters(scan: ScanResult, target: ModuleId): ModuleId[] {
|
|
1227
|
+
const byImported = new Map<ModuleId, ModuleId[]>();
|
|
1228
|
+
for (const edge of scan.edges) {
|
|
1229
|
+
const list = byImported.get(edge.to) ?? [];
|
|
1230
|
+
list.push(edge.from);
|
|
1231
|
+
byImported.set(edge.to, list);
|
|
1232
|
+
}
|
|
1233
|
+
const seen = new Set<ModuleId>();
|
|
1234
|
+
const queue = [...(byImported.get(target) ?? [])];
|
|
1235
|
+
while (queue.length > 0) {
|
|
1236
|
+
const next = queue.shift()!;
|
|
1237
|
+
if (next === target) continue;
|
|
1238
|
+
if (seen.has(next)) continue;
|
|
1239
|
+
seen.add(next);
|
|
1240
|
+
queue.push(...(byImported.get(next) ?? []));
|
|
1241
|
+
}
|
|
1242
|
+
return [...seen].sort();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function impactRisk(input: {
|
|
1246
|
+
affectedModules: number;
|
|
1247
|
+
cyclesTouched: number;
|
|
1248
|
+
violationsTouched: number;
|
|
1249
|
+
}): ImpactView['risk'] {
|
|
1250
|
+
if (input.cyclesTouched > 0 || input.violationsTouched > 1 || input.affectedModules >= 10) {
|
|
1251
|
+
return 'high';
|
|
1252
|
+
}
|
|
1253
|
+
if (input.violationsTouched > 0 || input.affectedModules >= 3) return 'medium';
|
|
1254
|
+
return 'low';
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function signalNextSteps(signal: ArchitectureSignal): string[] {
|
|
1258
|
+
if (signal.kind === 'contract-violation') {
|
|
1259
|
+
return ['Open the matching contract rule.', 'Fix the edge or adjust the declared boundary.'];
|
|
1260
|
+
}
|
|
1261
|
+
if (signal.kind.includes('cycle')) {
|
|
1262
|
+
return ['Inspect the cycle members.', 'Break the suggested feedback edge first.'];
|
|
1263
|
+
}
|
|
1264
|
+
if (signal.kind === 'bundle-bloat') {
|
|
1265
|
+
return [
|
|
1266
|
+
'Check the bundle stats source.',
|
|
1267
|
+
'Move heavy imports behind lazy boundaries when possible.',
|
|
1268
|
+
];
|
|
1269
|
+
}
|
|
1270
|
+
return ['Review the listed evidence.', 'Re-run the scan after the change.'];
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function moduleNextSteps(scan: ScanResult, moduleId: ModuleId, impact: ImpactView): string[] {
|
|
1274
|
+
if (impact.cyclesTouched.length > 0) {
|
|
1275
|
+
return ['Open cycles touching this module.', 'Break the smallest deterministic cycle first.'];
|
|
1276
|
+
}
|
|
1277
|
+
if (impact.violationsTouched > 0) {
|
|
1278
|
+
return ['Review layer and contract violations touching this module.'];
|
|
1279
|
+
}
|
|
1280
|
+
if ((scan.metrics[moduleId]?.fanIn ?? 0) > 5) {
|
|
1281
|
+
return ['Treat this module as a high-impact change target.'];
|
|
1282
|
+
}
|
|
1283
|
+
return ['No immediate architecture action for this module.'];
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function projectNextSteps(scan: ScanResult): string[] {
|
|
1287
|
+
if ((scan.configDiagnostics?.length ?? 0) > 0) {
|
|
1288
|
+
return ['Fix rules config diagnostics before enforcing project rules in CI.'];
|
|
1289
|
+
}
|
|
1290
|
+
if (scan.contractViolations.length > 0) return ['Resolve error-level contract violations first.'];
|
|
1291
|
+
if (scan.cycles.length > 0) return ['Break direct cycles before broad refactors.'];
|
|
1292
|
+
if (scan.hotZones.length > 0) return ['Review top hotspots for ownership and fan-in risk.'];
|
|
1293
|
+
return ['Keep the current baseline and fail CI on new regressions.'];
|
|
1294
|
+
}
|