@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,684 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnalyzerWarning,
|
|
3
|
+
AsyncLifecycleRiskFinding,
|
|
4
|
+
ArchitectureInsight,
|
|
5
|
+
ArchitectureSignal,
|
|
6
|
+
ModuleId,
|
|
7
|
+
MemoryRiskFinding,
|
|
8
|
+
ParsedFileSummary,
|
|
9
|
+
Recommendation,
|
|
10
|
+
SignalActionability,
|
|
11
|
+
SignalConfidence,
|
|
12
|
+
SignalEvidence,
|
|
13
|
+
SignalMaturity,
|
|
14
|
+
SignalSeverity,
|
|
15
|
+
Suppression,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
export interface BuildArchitectureSignalsInput {
|
|
19
|
+
recommendations: readonly Recommendation[];
|
|
20
|
+
warnings: readonly AnalyzerWarning[];
|
|
21
|
+
parserFacts?: readonly ParsedFileSummary[];
|
|
22
|
+
insightLimit?: number;
|
|
23
|
+
minInsightSeverity?: SignalSeverity;
|
|
24
|
+
minInsightConfidence?: SignalConfidence;
|
|
25
|
+
memoryRisks?: readonly MemoryRiskFinding[];
|
|
26
|
+
asyncLifecycleRisks?: readonly AsyncLifecycleRiskFinding[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BuildArchitectureSignalsResult {
|
|
30
|
+
signals: ArchitectureSignal[];
|
|
31
|
+
insights: ArchitectureInsight[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TOP_INSIGHTS = 8;
|
|
35
|
+
const DEFAULT_CI_MIN_SEVERITY: SignalSeverity = 'high';
|
|
36
|
+
const SEVERITY_RANK: Record<SignalSeverity, number> = {
|
|
37
|
+
info: 0,
|
|
38
|
+
low: 1,
|
|
39
|
+
medium: 2,
|
|
40
|
+
high: 3,
|
|
41
|
+
critical: 4,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function buildArchitectureSignals(
|
|
45
|
+
input: BuildArchitectureSignalsInput,
|
|
46
|
+
): BuildArchitectureSignalsResult {
|
|
47
|
+
const parserQuality = buildParserQualityIndex(input.parserFacts ?? []);
|
|
48
|
+
const signals = input.recommendations.map((rec) => signalFromRecommendation(rec, parserQuality));
|
|
49
|
+
for (const warning of input.warnings) {
|
|
50
|
+
if (warning.code !== 'resolve-failed') continue;
|
|
51
|
+
signals.push(signalFromWarning(warning));
|
|
52
|
+
}
|
|
53
|
+
for (const risk of input.memoryRisks ?? []) {
|
|
54
|
+
signals.push(signalFromMemoryRisk(risk));
|
|
55
|
+
}
|
|
56
|
+
for (const risk of input.asyncLifecycleRisks ?? []) {
|
|
57
|
+
signals.push(signalFromAsyncLifecycleRisk(risk));
|
|
58
|
+
}
|
|
59
|
+
const ranked = [...signals].sort((a, b) => b.ranking.score - a.ranking.score);
|
|
60
|
+
const insightOptions: InsightOptions = {};
|
|
61
|
+
if (input.insightLimit !== undefined) insightOptions.limit = input.insightLimit;
|
|
62
|
+
if (input.minInsightSeverity) insightOptions.minSeverity = input.minInsightSeverity;
|
|
63
|
+
if (input.minInsightConfidence) insightOptions.minConfidence = input.minInsightConfidence;
|
|
64
|
+
const insights = buildInsights(ranked, insightOptions);
|
|
65
|
+
return { signals, insights };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CanSignalFailCiOptions {
|
|
69
|
+
minSeverity?: SignalSeverity;
|
|
70
|
+
includeBeta?: boolean;
|
|
71
|
+
includeExperimental?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ReconcileSignalLifecycleResult {
|
|
75
|
+
current: ArchitectureSignal[];
|
|
76
|
+
resolved: ArchitectureSignal[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ApplySignalSuppressionsResult {
|
|
80
|
+
signals: ArchitectureSignal[];
|
|
81
|
+
staleSuppressions: Suppression[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function projectSignalsToRecommendations(
|
|
85
|
+
signals: readonly ArchitectureSignal[],
|
|
86
|
+
existing: readonly Recommendation[] = [],
|
|
87
|
+
): Recommendation[] {
|
|
88
|
+
const existingIds = new Set(existing.map((item) => item.id));
|
|
89
|
+
const projected: Recommendation[] = [];
|
|
90
|
+
for (const signal of signals) {
|
|
91
|
+
if (signal.legacyRecommendationId) continue;
|
|
92
|
+
const kind = recommendationKindForSignal(signal);
|
|
93
|
+
if (!kind) continue;
|
|
94
|
+
const id = `signal:${signal.stableKey}`;
|
|
95
|
+
if (existingIds.has(id)) continue;
|
|
96
|
+
projected.push({
|
|
97
|
+
id,
|
|
98
|
+
kind,
|
|
99
|
+
modules: signal.modules,
|
|
100
|
+
params: {
|
|
101
|
+
signal: signal.title,
|
|
102
|
+
severity: signal.severity,
|
|
103
|
+
stableKey: signal.stableKey,
|
|
104
|
+
},
|
|
105
|
+
weight: signal.ranking.score / 100,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return [...existing, ...projected];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function canSignalFailCi(
|
|
112
|
+
signal: ArchitectureSignal,
|
|
113
|
+
options: CanSignalFailCiOptions = {},
|
|
114
|
+
): boolean {
|
|
115
|
+
if (signal.suppressed) return false;
|
|
116
|
+
if (signal.status === 'resolved') return false;
|
|
117
|
+
if (signal.confidence !== 'high') return false;
|
|
118
|
+
if (!severityAtLeast(signal.severity, options.minSeverity ?? DEFAULT_CI_MIN_SEVERITY)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (signal.maturity === 'stable') return true;
|
|
122
|
+
if (signal.maturity === 'beta') return options.includeBeta === true;
|
|
123
|
+
return options.includeExperimental === true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function reconcileSignalLifecycle(
|
|
127
|
+
previous: readonly ArchitectureSignal[],
|
|
128
|
+
current: readonly ArchitectureSignal[],
|
|
129
|
+
): ReconcileSignalLifecycleResult {
|
|
130
|
+
const previousByKey = new Map(previous.map((signal) => [signal.stableKey, signal]));
|
|
131
|
+
const currentKeys = new Set(current.map((signal) => signal.stableKey));
|
|
132
|
+
const reconciled = current.map((signal): ArchitectureSignal => {
|
|
133
|
+
const prior = previousByKey.get(signal.stableKey);
|
|
134
|
+
if (!prior) return { ...signal, status: 'new' };
|
|
135
|
+
return {
|
|
136
|
+
...signal,
|
|
137
|
+
status: isSignalRegression(prior, signal) ? 'regressed' : 'existing',
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
const resolved = previous
|
|
141
|
+
.filter((signal) => !currentKeys.has(signal.stableKey))
|
|
142
|
+
.map((signal): ArchitectureSignal => ({ ...signal, status: 'resolved' }));
|
|
143
|
+
return { current: reconciled, resolved };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function applySignalSuppressions(
|
|
147
|
+
signals: readonly ArchitectureSignal[],
|
|
148
|
+
suppressions: readonly Suppression[],
|
|
149
|
+
context: { now?: Date | string } = {},
|
|
150
|
+
): ApplySignalSuppressionsResult {
|
|
151
|
+
const nowMs = normalizeDateMs(context.now ?? new Date());
|
|
152
|
+
const signalKeys = new Set(signals.map((signal) => signal.stableKey));
|
|
153
|
+
const active = suppressions.filter((suppression) => !isSuppressionExpired(suppression, nowMs));
|
|
154
|
+
const applied = signals.map((signal): ArchitectureSignal => {
|
|
155
|
+
const suppression = active.find((candidate) => matchesSuppression(signal, candidate));
|
|
156
|
+
if (!suppression) return signal;
|
|
157
|
+
return {
|
|
158
|
+
...signal,
|
|
159
|
+
suppressed: true,
|
|
160
|
+
suppressionReason: suppression.reason,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
const staleSuppressions = suppressions
|
|
164
|
+
.filter((suppression) => !signalKeys.has(suppression.stableKey))
|
|
165
|
+
.map((suppression): Suppression => ({ ...suppression, status: 'stale' }));
|
|
166
|
+
return { signals: applied, staleSuppressions };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function signalFromRecommendation(
|
|
170
|
+
rec: Recommendation,
|
|
171
|
+
parserQuality: ReadonlyMap<ModuleId, ParserQuality>,
|
|
172
|
+
): ArchitectureSignal {
|
|
173
|
+
const classification = applyParserQuality(
|
|
174
|
+
classifyRecommendation(rec),
|
|
175
|
+
rec.modules,
|
|
176
|
+
parserQuality,
|
|
177
|
+
);
|
|
178
|
+
const modules = [...rec.modules];
|
|
179
|
+
const evidence: SignalEvidence[] = [
|
|
180
|
+
{
|
|
181
|
+
kind: classification.evidenceKind,
|
|
182
|
+
message: evidenceMessage(rec),
|
|
183
|
+
modules,
|
|
184
|
+
confidence: classification.confidence,
|
|
185
|
+
source: 'legacy-recommendation',
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
const limitations = classification.limitations;
|
|
189
|
+
const stableKey = buildStableSignalKey({
|
|
190
|
+
kind: rec.kind,
|
|
191
|
+
modules,
|
|
192
|
+
evidence,
|
|
193
|
+
params: rec.params,
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
id: `signal:${rec.id}`,
|
|
197
|
+
stableKey,
|
|
198
|
+
kind: rec.kind,
|
|
199
|
+
title: titleFor(rec),
|
|
200
|
+
severity: classification.severity,
|
|
201
|
+
confidence: classification.confidence,
|
|
202
|
+
actionability: classification.actionability,
|
|
203
|
+
status: 'new',
|
|
204
|
+
maturity: classification.maturity,
|
|
205
|
+
modules,
|
|
206
|
+
evidence,
|
|
207
|
+
limitations,
|
|
208
|
+
ranking: rankSignal({
|
|
209
|
+
severity: classification.severity,
|
|
210
|
+
confidence: classification.confidence,
|
|
211
|
+
maturity: classification.maturity,
|
|
212
|
+
weight: rec.weight,
|
|
213
|
+
limitations,
|
|
214
|
+
}),
|
|
215
|
+
legacyRecommendationId: rec.id,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface InsightOptions {
|
|
220
|
+
limit?: number;
|
|
221
|
+
minSeverity?: SignalSeverity;
|
|
222
|
+
minConfidence?: SignalConfidence;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function recommendationKindForSignal(signal: ArchitectureSignal): Recommendation['kind'] | null {
|
|
226
|
+
switch (signal.kind) {
|
|
227
|
+
case 'contract-violation':
|
|
228
|
+
case 'bundle-bloat':
|
|
229
|
+
case 'cycle-break-candidate':
|
|
230
|
+
case 'cycle-break-cluster':
|
|
231
|
+
case 'type-only-candidate':
|
|
232
|
+
case 'misplaced-by-layer':
|
|
233
|
+
case 'temporal-coupling':
|
|
234
|
+
return signal.kind;
|
|
235
|
+
default:
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isInsightEligible(signal: ArchitectureSignal, options: InsightOptions = {}): boolean {
|
|
241
|
+
if (signal.maturity === 'experimental') return false;
|
|
242
|
+
if (confidenceRank(signal.confidence) < confidenceRank(options.minConfidence ?? 'medium')) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
if (signal.actionability === 'none') return false;
|
|
246
|
+
if (!severityAtLeast(signal.severity, options.minSeverity ?? 'medium')) return false;
|
|
247
|
+
if (signal.evidence.length === 0) return false;
|
|
248
|
+
return signal.evidence.some((evidence) => evidence.kind !== 'heuristic');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function buildInsights(
|
|
252
|
+
ranked: readonly ArchitectureSignal[],
|
|
253
|
+
options: InsightOptions,
|
|
254
|
+
): ArchitectureInsight[] {
|
|
255
|
+
const groups = new Map<string, ArchitectureSignal[]>();
|
|
256
|
+
for (const signal of ranked) {
|
|
257
|
+
if (!isInsightEligible(signal, options)) continue;
|
|
258
|
+
const key = rootCauseKey(signal);
|
|
259
|
+
const group = groups.get(key);
|
|
260
|
+
if (group) {
|
|
261
|
+
group.push(signal);
|
|
262
|
+
} else {
|
|
263
|
+
groups.set(key, [signal]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return [...groups.entries()]
|
|
268
|
+
.map(([key, group], index): ArchitectureInsight => {
|
|
269
|
+
const [top] = group;
|
|
270
|
+
if (!top) throw new Error(`empty signal group: ${key}`);
|
|
271
|
+
const modules = normalizeList(group.flatMap((signal) => signal.modules));
|
|
272
|
+
const rankingScore = group.reduce((sum, signal) => sum + signal.ranking.score, 0);
|
|
273
|
+
const related = group.length > 1 ? ` (+${group.length - 1} related)` : '';
|
|
274
|
+
return {
|
|
275
|
+
id: `insight:${index}:${key}`,
|
|
276
|
+
title: `${top.title}${related}`,
|
|
277
|
+
severity: highestSeverity(group),
|
|
278
|
+
confidence: lowestConfidence(group),
|
|
279
|
+
signals: group.map((signal) => signal.id),
|
|
280
|
+
modules,
|
|
281
|
+
rankingScore,
|
|
282
|
+
summary: top.evidence[0]?.message ?? top.title,
|
|
283
|
+
};
|
|
284
|
+
})
|
|
285
|
+
.sort((a, b) => b.rankingScore - a.rankingScore)
|
|
286
|
+
.slice(0, options.limit ?? TOP_INSIGHTS);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function rootCauseKey(signal: ArchitectureSignal): string {
|
|
290
|
+
const primaryEvidence = signal.evidence.find((item) => item.kind !== 'heuristic');
|
|
291
|
+
const primaryModule = signal.modules[0] ?? primaryEvidence?.modules?.[0] ?? 'project';
|
|
292
|
+
return normalizeText(`module:${primaryModule}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function highestSeverity(signals: readonly ArchitectureSignal[]): SignalSeverity {
|
|
296
|
+
return signals.reduce<SignalSeverity>(
|
|
297
|
+
(current, signal) =>
|
|
298
|
+
SEVERITY_RANK[signal.severity] > SEVERITY_RANK[current] ? signal.severity : current,
|
|
299
|
+
'info',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function lowestConfidence(signals: readonly ArchitectureSignal[]): SignalConfidence {
|
|
304
|
+
return signals.reduce<SignalConfidence>(
|
|
305
|
+
(current, signal) =>
|
|
306
|
+
confidenceRank(signal.confidence) < confidenceRank(current) ? signal.confidence : current,
|
|
307
|
+
'high',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function signalFromWarning(warning: AnalyzerWarning): ArchitectureSignal {
|
|
312
|
+
const modules = warning.file ? [warning.file] : [];
|
|
313
|
+
const stableKey = stableSignalKey(`warning:${warning.code}`, modules, [
|
|
314
|
+
{
|
|
315
|
+
kind: 'parser-fact',
|
|
316
|
+
message: warning.detail ?? warning.message,
|
|
317
|
+
modules,
|
|
318
|
+
confidence: 'medium',
|
|
319
|
+
...(warning.file ? { source: warning.file } : {}),
|
|
320
|
+
},
|
|
321
|
+
]);
|
|
322
|
+
return {
|
|
323
|
+
id: `signal:${stableKey}`,
|
|
324
|
+
stableKey,
|
|
325
|
+
kind: `warning:${warning.code}`,
|
|
326
|
+
title: warning.message,
|
|
327
|
+
severity: 'low',
|
|
328
|
+
confidence: 'medium',
|
|
329
|
+
actionability: 'manual',
|
|
330
|
+
status: 'new',
|
|
331
|
+
maturity: 'beta',
|
|
332
|
+
modules,
|
|
333
|
+
evidence: [
|
|
334
|
+
{
|
|
335
|
+
kind: 'parser-fact',
|
|
336
|
+
message: warning.detail ?? warning.message,
|
|
337
|
+
modules,
|
|
338
|
+
confidence: 'medium',
|
|
339
|
+
...(warning.file ? { source: warning.file } : {}),
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
limitations: [
|
|
343
|
+
'warning projection is compatibility-only until suppression/baseline lifecycle lands',
|
|
344
|
+
],
|
|
345
|
+
ranking: rankSignal({
|
|
346
|
+
severity: 'low',
|
|
347
|
+
confidence: 'medium',
|
|
348
|
+
maturity: 'beta',
|
|
349
|
+
weight: 1,
|
|
350
|
+
limitations: [],
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function signalFromMemoryRisk(risk: MemoryRiskFinding): ArchitectureSignal {
|
|
356
|
+
const modules = [risk.moduleId];
|
|
357
|
+
const evidence: SignalEvidence[] = risk.evidence.map((item) => ({
|
|
358
|
+
kind: 'memory',
|
|
359
|
+
message: item.message,
|
|
360
|
+
modules,
|
|
361
|
+
confidence: risk.confidence,
|
|
362
|
+
source: item.line ? `${risk.moduleId}:${item.line}` : risk.moduleId,
|
|
363
|
+
}));
|
|
364
|
+
const stableKey = stableSignalKey(`memory-risk:${risk.kind}`, modules, evidence);
|
|
365
|
+
return {
|
|
366
|
+
id: `signal:${risk.id}`,
|
|
367
|
+
stableKey,
|
|
368
|
+
kind: 'memory-risk',
|
|
369
|
+
title: titleForMemoryRisk(risk),
|
|
370
|
+
severity: risk.severity,
|
|
371
|
+
confidence: risk.confidence,
|
|
372
|
+
actionability: 'manual',
|
|
373
|
+
status: 'new',
|
|
374
|
+
maturity: 'beta',
|
|
375
|
+
modules,
|
|
376
|
+
evidence,
|
|
377
|
+
limitations: ['static cleanup heuristic; not runtime leak proof'],
|
|
378
|
+
ranking: rankSignal({
|
|
379
|
+
severity: risk.severity,
|
|
380
|
+
confidence: risk.confidence,
|
|
381
|
+
maturity: 'beta',
|
|
382
|
+
weight: risk.confidence === 'high' ? 8 : 4,
|
|
383
|
+
limitations: ['static cleanup heuristic'],
|
|
384
|
+
}),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function signalFromAsyncLifecycleRisk(risk: AsyncLifecycleRiskFinding): ArchitectureSignal {
|
|
389
|
+
const modules = [risk.moduleId];
|
|
390
|
+
const evidence: SignalEvidence[] = risk.evidence.map((item) => ({
|
|
391
|
+
kind: 'async-lifecycle',
|
|
392
|
+
message: item.message,
|
|
393
|
+
modules,
|
|
394
|
+
confidence: risk.confidence,
|
|
395
|
+
source: item.line ? `${risk.moduleId}:${item.line}` : risk.moduleId,
|
|
396
|
+
}));
|
|
397
|
+
const stableKey = stableSignalKey(`async-lifecycle-risk:${risk.kind}`, modules, evidence);
|
|
398
|
+
return {
|
|
399
|
+
id: `signal:${risk.id}`,
|
|
400
|
+
stableKey,
|
|
401
|
+
kind: 'async-lifecycle-risk',
|
|
402
|
+
title: 'Async lifecycle cleanup risk',
|
|
403
|
+
severity: risk.severity,
|
|
404
|
+
confidence: risk.confidence,
|
|
405
|
+
actionability: 'manual',
|
|
406
|
+
status: 'new',
|
|
407
|
+
maturity: 'beta',
|
|
408
|
+
modules,
|
|
409
|
+
evidence,
|
|
410
|
+
limitations: ['static async lifecycle heuristic; not runtime proof'],
|
|
411
|
+
ranking: rankSignal({
|
|
412
|
+
severity: risk.severity,
|
|
413
|
+
confidence: risk.confidence,
|
|
414
|
+
maturity: 'beta',
|
|
415
|
+
weight: risk.confidence === 'high' ? 8 : 4,
|
|
416
|
+
limitations: ['static async lifecycle heuristic'],
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function classifyRecommendation(rec: Recommendation): {
|
|
422
|
+
severity: SignalSeverity;
|
|
423
|
+
confidence: SignalConfidence;
|
|
424
|
+
actionability: SignalActionability;
|
|
425
|
+
maturity: SignalMaturity;
|
|
426
|
+
evidenceKind: SignalEvidence['kind'];
|
|
427
|
+
limitations: string[];
|
|
428
|
+
} {
|
|
429
|
+
switch (rec.kind) {
|
|
430
|
+
case 'contract-violation':
|
|
431
|
+
return {
|
|
432
|
+
severity: rec.params.severity === 'error' ? 'high' : 'medium',
|
|
433
|
+
confidence: 'high',
|
|
434
|
+
actionability: 'manual',
|
|
435
|
+
maturity: 'stable',
|
|
436
|
+
evidenceKind: 'contract',
|
|
437
|
+
limitations: [],
|
|
438
|
+
};
|
|
439
|
+
case 'bundle-bloat':
|
|
440
|
+
return {
|
|
441
|
+
severity: rec.params.severity === 'high' ? 'high' : 'medium',
|
|
442
|
+
confidence: 'high',
|
|
443
|
+
actionability: 'manual',
|
|
444
|
+
maturity: 'beta',
|
|
445
|
+
evidenceKind: 'bundle',
|
|
446
|
+
limitations: [],
|
|
447
|
+
};
|
|
448
|
+
case 'cycle-break-cluster':
|
|
449
|
+
case 'cycle-break-candidate':
|
|
450
|
+
case 'type-only-candidate':
|
|
451
|
+
return {
|
|
452
|
+
severity: 'medium',
|
|
453
|
+
confidence: 'high',
|
|
454
|
+
actionability: rec.kind === 'type-only-candidate' ? 'guided' : 'manual',
|
|
455
|
+
maturity: 'stable',
|
|
456
|
+
evidenceKind: 'cycle',
|
|
457
|
+
limitations: [],
|
|
458
|
+
};
|
|
459
|
+
case 'temporal-coupling':
|
|
460
|
+
return {
|
|
461
|
+
severity: 'medium',
|
|
462
|
+
confidence: 'medium',
|
|
463
|
+
actionability: 'manual',
|
|
464
|
+
maturity: 'beta',
|
|
465
|
+
evidenceKind: 'temporal',
|
|
466
|
+
limitations: ['requires representative git history window'],
|
|
467
|
+
};
|
|
468
|
+
case 'misplaced-by-layer':
|
|
469
|
+
return {
|
|
470
|
+
severity: 'medium',
|
|
471
|
+
confidence: 'medium',
|
|
472
|
+
actionability: 'manual',
|
|
473
|
+
maturity: 'beta',
|
|
474
|
+
evidenceKind: 'layer',
|
|
475
|
+
limitations: ['layer detection can be path-convention dependent'],
|
|
476
|
+
};
|
|
477
|
+
default:
|
|
478
|
+
return {
|
|
479
|
+
severity: 'low',
|
|
480
|
+
confidence: 'low',
|
|
481
|
+
actionability: 'manual',
|
|
482
|
+
maturity: 'experimental',
|
|
483
|
+
evidenceKind: 'heuristic',
|
|
484
|
+
limitations: ['heuristic-only signal; not eligible for CI failure by default'],
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
type RecommendationClassification = ReturnType<typeof classifyRecommendation>;
|
|
490
|
+
|
|
491
|
+
interface ParserQuality {
|
|
492
|
+
approximateImports: number;
|
|
493
|
+
lowConfidenceImports: number;
|
|
494
|
+
limitations: number;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function buildParserQualityIndex(
|
|
498
|
+
parserFacts: readonly ParsedFileSummary[],
|
|
499
|
+
): ReadonlyMap<ModuleId, ParserQuality> {
|
|
500
|
+
const index = new Map<ModuleId, ParserQuality>();
|
|
501
|
+
for (const fact of parserFacts) {
|
|
502
|
+
const approximateImports = fact.imports.filter((importFact) => importFact.approximate).length;
|
|
503
|
+
const lowConfidenceImports = fact.imports.filter(
|
|
504
|
+
(importFact) => importFact.confidence === 'low',
|
|
505
|
+
).length;
|
|
506
|
+
const limitations = fact.limitations.length;
|
|
507
|
+
if (approximateImports === 0 && lowConfidenceImports === 0 && limitations === 0) continue;
|
|
508
|
+
index.set(fact.relPath, {
|
|
509
|
+
approximateImports,
|
|
510
|
+
lowConfidenceImports,
|
|
511
|
+
limitations,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return index;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function applyParserQuality(
|
|
518
|
+
classification: RecommendationClassification,
|
|
519
|
+
modules: readonly ModuleId[],
|
|
520
|
+
parserQuality: ReadonlyMap<ModuleId, ParserQuality>,
|
|
521
|
+
): RecommendationClassification {
|
|
522
|
+
const impacted = modules
|
|
523
|
+
.map((moduleId) => parserQuality.get(moduleId))
|
|
524
|
+
.filter((quality): quality is ParserQuality => quality !== undefined);
|
|
525
|
+
if (impacted.length === 0) return classification;
|
|
526
|
+
if (classification.evidenceKind === 'bundle' || classification.evidenceKind === 'temporal') {
|
|
527
|
+
return classification;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const approximateImports = impacted.reduce((sum, quality) => sum + quality.approximateImports, 0);
|
|
531
|
+
const lowConfidenceImports = impacted.reduce(
|
|
532
|
+
(sum, quality) => sum + quality.lowConfidenceImports,
|
|
533
|
+
0,
|
|
534
|
+
);
|
|
535
|
+
const limitations = impacted.reduce((sum, quality) => sum + quality.limitations, 0);
|
|
536
|
+
const confidence =
|
|
537
|
+
classification.confidence === 'high' && (approximateImports > 0 || lowConfidenceImports > 0)
|
|
538
|
+
? 'medium'
|
|
539
|
+
: classification.confidence;
|
|
540
|
+
return {
|
|
541
|
+
...classification,
|
|
542
|
+
confidence,
|
|
543
|
+
limitations: [
|
|
544
|
+
...classification.limitations,
|
|
545
|
+
`parser uncertainty on involved modules: approximateImports=${approximateImports}, lowConfidenceImports=${lowConfidenceImports}, limitations=${limitations}`,
|
|
546
|
+
],
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function rankSignal(input: {
|
|
551
|
+
severity: SignalSeverity;
|
|
552
|
+
confidence: SignalConfidence;
|
|
553
|
+
maturity: SignalMaturity;
|
|
554
|
+
weight: number;
|
|
555
|
+
limitations: readonly string[];
|
|
556
|
+
}): ArchitectureSignal['ranking'] {
|
|
557
|
+
const severityScore = { info: 5, low: 15, medium: 35, high: 65, critical: 90 }[input.severity];
|
|
558
|
+
const confidenceScore = { low: 0.55, medium: 0.8, high: 1 }[input.confidence];
|
|
559
|
+
const maturityPenalty =
|
|
560
|
+
input.maturity === 'experimental' ? 12 : input.maturity === 'beta' ? 4 : 0;
|
|
561
|
+
const noisePenalty =
|
|
562
|
+
input.confidence === 'low' ? 22 : input.limitations.length * 3 + maturityPenalty;
|
|
563
|
+
const score = Math.max(
|
|
564
|
+
0,
|
|
565
|
+
severityScore * confidenceScore + Math.min(20, input.weight) - noisePenalty,
|
|
566
|
+
);
|
|
567
|
+
return {
|
|
568
|
+
score,
|
|
569
|
+
reasons: [
|
|
570
|
+
`severity:${input.severity}`,
|
|
571
|
+
`confidence:${input.confidence}`,
|
|
572
|
+
`maturity:${input.maturity}`,
|
|
573
|
+
],
|
|
574
|
+
noisePenalty,
|
|
575
|
+
noveltyBoost: 0,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function isSignalRegression(previous: ArchitectureSignal, current: ArchitectureSignal): boolean {
|
|
580
|
+
return (
|
|
581
|
+
SEVERITY_RANK[current.severity] > SEVERITY_RANK[previous.severity] ||
|
|
582
|
+
confidenceRank(current.confidence) > confidenceRank(previous.confidence)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function confidenceRank(confidence: SignalConfidence): number {
|
|
587
|
+
return { low: 0, medium: 1, high: 2 }[confidence];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function normalizeDateMs(value: Date | string): number {
|
|
591
|
+
return value instanceof Date ? value.getTime() : Date.parse(value);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function isSuppressionExpired(suppression: Suppression, nowMs: number): boolean {
|
|
595
|
+
if (!suppression.expiresAt) return false;
|
|
596
|
+
const expiresAt = Date.parse(suppression.expiresAt);
|
|
597
|
+
return Number.isFinite(expiresAt) && expiresAt <= nowMs;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function matchesSuppression(signal: ArchitectureSignal, suppression: Suppression): boolean {
|
|
601
|
+
if (signal.stableKey !== suppression.stableKey) return false;
|
|
602
|
+
if (suppression.moduleId && !signal.modules.includes(suppression.moduleId)) return false;
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function severityAtLeast(actual: SignalSeverity, min: SignalSeverity): boolean {
|
|
607
|
+
return SEVERITY_RANK[actual] >= SEVERITY_RANK[min];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function stableSignalKey(
|
|
611
|
+
kind: string,
|
|
612
|
+
modules: readonly ModuleId[],
|
|
613
|
+
evidence: readonly SignalEvidence[],
|
|
614
|
+
): string {
|
|
615
|
+
return buildStableSignalKey({ kind, modules, evidence });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function buildStableSignalKey(input: {
|
|
619
|
+
kind: string;
|
|
620
|
+
modules: readonly ModuleId[];
|
|
621
|
+
evidence: readonly SignalEvidence[];
|
|
622
|
+
params?: Record<string, unknown>;
|
|
623
|
+
}): string {
|
|
624
|
+
const modules = normalizeList(input.modules);
|
|
625
|
+
const evidence = input.evidence
|
|
626
|
+
.map((item) =>
|
|
627
|
+
[
|
|
628
|
+
item.kind,
|
|
629
|
+
item.confidence,
|
|
630
|
+
normalizeText(item.source ?? ''),
|
|
631
|
+
normalizeText(item.message),
|
|
632
|
+
normalizeList(item.modules ?? []).join(','),
|
|
633
|
+
].join('~'),
|
|
634
|
+
)
|
|
635
|
+
.sort();
|
|
636
|
+
const params = input.params ? stableJson(input.params) : '';
|
|
637
|
+
return [input.kind, modules.join(','), evidence.join('|'), params].map(normalizeText).join(':');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function normalizeList(values: readonly string[]): string[] {
|
|
641
|
+
return [...new Set(values.map(normalizePathLike))].sort();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function normalizePathLike(value: string): string {
|
|
645
|
+
return value.replace(/\\/gu, '/').replace(/\/+/gu, '/').trim();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function normalizeText(value: string): string {
|
|
649
|
+
return normalizePathLike(value).replace(/\s+/gu, ' ');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function stableJson(value: unknown): string {
|
|
653
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).sort().join(',')}]`;
|
|
654
|
+
if (value && typeof value === 'object') {
|
|
655
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
656
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
657
|
+
.map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`)
|
|
658
|
+
.join(',')}}`;
|
|
659
|
+
}
|
|
660
|
+
return JSON.stringify(value);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function titleFor(rec: Recommendation): string {
|
|
664
|
+
return rec.kind.replace(/-/gu, ' ');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function titleForMemoryRisk(risk: MemoryRiskFinding): string {
|
|
668
|
+
switch (risk.kind) {
|
|
669
|
+
case 'event-listener-cleanup':
|
|
670
|
+
return 'Event listener cleanup risk';
|
|
671
|
+
case 'timer-cleanup':
|
|
672
|
+
return 'Timer cleanup risk';
|
|
673
|
+
case 'observer-cleanup':
|
|
674
|
+
return 'Observer cleanup risk';
|
|
675
|
+
case 'object-url-cleanup':
|
|
676
|
+
return 'Object URL cleanup risk';
|
|
677
|
+
case 'subscription-cleanup':
|
|
678
|
+
return 'Subscription cleanup risk';
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function evidenceMessage(rec: Recommendation): string {
|
|
683
|
+
return `${rec.kind} legacy recommendation`;
|
|
684
|
+
}
|