@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,444 @@
|
|
|
1
|
+
import type { FileSource } from './fileSource';
|
|
2
|
+
import type {
|
|
3
|
+
AnalyzerWarning,
|
|
4
|
+
ModuleId,
|
|
5
|
+
ProjectRef,
|
|
6
|
+
ScanProgressCallback,
|
|
7
|
+
ScanResult,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { discoverFiles, compileGlob, type DiscoverOptions } from './discover';
|
|
10
|
+
import { createResolver } from './resolve';
|
|
11
|
+
import { loadAliases } from './loadAliases';
|
|
12
|
+
import { buildGraph } from './buildGraph';
|
|
13
|
+
import { detectFramework, type Framework } from './detect';
|
|
14
|
+
import { detectCycles } from './cycles';
|
|
15
|
+
import { countBrokenCycles, parseEdgeKey } from './feedbackArcSet';
|
|
16
|
+
import { computeMetrics } from './metrics';
|
|
17
|
+
import { rankHotZones } from './hotZones';
|
|
18
|
+
import { detectLayerViolations } from './layers';
|
|
19
|
+
import { computeArchDebt } from './archDebt';
|
|
20
|
+
import { computeRecommendations } from './recommendations';
|
|
21
|
+
import { applySignalSuppressions, buildArchitectureSignals } from './signals';
|
|
22
|
+
import { discoverEntryPoints } from './entryPoints';
|
|
23
|
+
import {
|
|
24
|
+
loadArchoraConfigWithDiagnostics,
|
|
25
|
+
resolveGeneratedPatterns,
|
|
26
|
+
} from '../config/frontScopeConfig';
|
|
27
|
+
import { feedbackArcSet } from './feedbackArcSet';
|
|
28
|
+
import type { FeedbackArcSetResult } from './feedbackArcSet';
|
|
29
|
+
import { findTypeOnlyCandidates, type TypeOnlyCandidate } from './typeOnlyCandidates';
|
|
30
|
+
import { checkContracts } from './contracts';
|
|
31
|
+
import { detectRscLeaks } from './rsc';
|
|
32
|
+
import { detectMemoryRisks } from './memoryRisk';
|
|
33
|
+
import { detectAsyncLifecycleRisks } from './asyncLifecycleRisk';
|
|
34
|
+
import { analyzeBundle, parseBundleStats } from './bundle';
|
|
35
|
+
import type { BundleThresholds, ParsedBundleStats } from './bundle/types';
|
|
36
|
+
import { computeChurn } from '../git/computeChurn';
|
|
37
|
+
import { computeTemporalCoupling } from '../git/computeTemporalCoupling';
|
|
38
|
+
import type { ChurnByModule, GitHistory, TemporalCoupling } from '../git/types';
|
|
39
|
+
|
|
40
|
+
export type { FileSource } from './fileSource';
|
|
41
|
+
export * from './types';
|
|
42
|
+
export { incrementalAnalyze, type IncrementalAnalyzeInput } from './incremental';
|
|
43
|
+
export {
|
|
44
|
+
buildArchitectureSignals,
|
|
45
|
+
applySignalSuppressions,
|
|
46
|
+
canSignalFailCi,
|
|
47
|
+
projectSignalsToRecommendations,
|
|
48
|
+
reconcileSignalLifecycle,
|
|
49
|
+
type ApplySignalSuppressionsResult,
|
|
50
|
+
type BuildArchitectureSignalsInput,
|
|
51
|
+
type BuildArchitectureSignalsResult,
|
|
52
|
+
type CanSignalFailCiOptions,
|
|
53
|
+
type ReconcileSignalLifecycleResult,
|
|
54
|
+
} from './signals';
|
|
55
|
+
|
|
56
|
+
export interface AnalyzeOptions {
|
|
57
|
+
topHotZones?: number;
|
|
58
|
+
onProgress?: ScanProgressCallback;
|
|
59
|
+
discover?: DiscoverOptions;
|
|
60
|
+
/** Pre-parsed bundler stats. When supplied, bundle-aware analysis runs. */
|
|
61
|
+
bundleStats?: ParsedBundleStats;
|
|
62
|
+
/** Optional override for bundle bloat thresholds. */
|
|
63
|
+
bundleThresholds?: Partial<BundleThresholds>;
|
|
64
|
+
/**
|
|
65
|
+
* Pre-loaded git history. Browsers can't shell out, so the caller (CLI,
|
|
66
|
+
* Tauri bridge) reads `git log` and hands the parsed result here. When
|
|
67
|
+
* supplied, the analyzer computes per-module churn.
|
|
68
|
+
*/
|
|
69
|
+
gitHistory?: GitHistory;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function analyze(
|
|
73
|
+
source: FileSource,
|
|
74
|
+
options: AnalyzeOptions = {},
|
|
75
|
+
): Promise<ScanResult> {
|
|
76
|
+
const start = Date.now();
|
|
77
|
+
const warnings: AnalyzerWarning[] = [];
|
|
78
|
+
const onProgress = options.onProgress;
|
|
79
|
+
|
|
80
|
+
onProgress?.({ phase: 'discover' });
|
|
81
|
+
const project = await detectProject(source, warnings);
|
|
82
|
+
const aliases = await loadAliases(source, project);
|
|
83
|
+
const {
|
|
84
|
+
config,
|
|
85
|
+
diagnostics: configDiagnostics,
|
|
86
|
+
file: configFile,
|
|
87
|
+
} = await loadArchoraConfigWithDiagnostics(source);
|
|
88
|
+
const configStatus = {
|
|
89
|
+
state: configDiagnostics.some((diagnostic) => diagnostic.severity === 'error')
|
|
90
|
+
? 'invalid'
|
|
91
|
+
: configFile
|
|
92
|
+
? 'loaded'
|
|
93
|
+
: 'not-configured',
|
|
94
|
+
file: configFile,
|
|
95
|
+
} satisfies ScanResult['configStatus'];
|
|
96
|
+
|
|
97
|
+
const generatedPatterns = resolveGeneratedPatterns(config.analysis?.generated);
|
|
98
|
+
const generatedMode = config.analysis?.generated?.mode;
|
|
99
|
+
const extraIgnore = [
|
|
100
|
+
...(options.discover?.extraIgnoreGlobs ?? []),
|
|
101
|
+
...(config.ignore ?? []),
|
|
102
|
+
...(generatedMode === 'exclude' ? generatedPatterns : []),
|
|
103
|
+
];
|
|
104
|
+
const discoverOpts: DiscoverOptions = {
|
|
105
|
+
...(options.discover ?? {}),
|
|
106
|
+
...(extraIgnore.length > 0 ? { extraIgnoreGlobs: extraIgnore } : {}),
|
|
107
|
+
};
|
|
108
|
+
const { files } = await discoverFiles(source, discoverOpts);
|
|
109
|
+
const resolver = createResolver(source, { aliases });
|
|
110
|
+
|
|
111
|
+
const {
|
|
112
|
+
modules,
|
|
113
|
+
edges,
|
|
114
|
+
parserFacts,
|
|
115
|
+
warnings: graphWarnings,
|
|
116
|
+
} = await buildGraph({
|
|
117
|
+
source,
|
|
118
|
+
files,
|
|
119
|
+
resolver,
|
|
120
|
+
aliases,
|
|
121
|
+
config,
|
|
122
|
+
framework: project.detectedFramework as Framework,
|
|
123
|
+
...(onProgress ? { onProgress } : {}),
|
|
124
|
+
});
|
|
125
|
+
warnings.push(...graphWarnings);
|
|
126
|
+
|
|
127
|
+
if (generatedMode === 'classify' && generatedPatterns.length > 0) {
|
|
128
|
+
const matchers = generatedPatterns.map(compileGlob);
|
|
129
|
+
for (const m of modules) {
|
|
130
|
+
if (matchers.some((re) => re.test(m.id))) m.isGenerated = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
onProgress?.({ phase: 'graph' });
|
|
135
|
+
let cycles = detectCycles(modules, edges);
|
|
136
|
+
|
|
137
|
+
// Value-syntax imports whose bindings are referenced only in type positions
|
|
138
|
+
// are erased by the compiler — they never form a runtime cycle. madge reports
|
|
139
|
+
// them even with `skipTypeImports` because it keys on syntax, not usage. Probe
|
|
140
|
+
// every import that sits inside a cycle, drop the provably type-only ones and
|
|
141
|
+
// re-detect, so Archora never counts a cycle that does not exist at runtime.
|
|
142
|
+
// The candidates still flow into recommendations as the cheapest fix.
|
|
143
|
+
const inCycleEdges = collectInCycleEdges(cycles, edges);
|
|
144
|
+
let typeOnlyCandidates: TypeOnlyCandidate[] = [];
|
|
145
|
+
let cycleEdges = edges;
|
|
146
|
+
if (inCycleEdges.length > 0) {
|
|
147
|
+
typeOnlyCandidates = await findTypeOnlyCandidates({ edges: inCycleEdges, source, modules });
|
|
148
|
+
if (typeOnlyCandidates.length > 0) {
|
|
149
|
+
const erased = new Set(typeOnlyCandidates.map((c) => erasedKey(c.from, c.to, c.specifier)));
|
|
150
|
+
cycleEdges = edges.filter(
|
|
151
|
+
(e) => e.kind === 'type-only' || !erased.has(erasedKey(e.from, e.to, e.specifier)),
|
|
152
|
+
);
|
|
153
|
+
cycles = detectCycles(modules, cycleEdges);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Compute FAS per surviving SCC for cycle.suggestedBreakpoint, using the
|
|
158
|
+
// post-erasure edge set so a suggested break always cites an edge that
|
|
159
|
+
// genuinely closes the cycle.
|
|
160
|
+
const feedbackEdgesByCycle = cycles.map((c) => ({
|
|
161
|
+
cycle: c,
|
|
162
|
+
fas: feedbackArcSet(c.modules, cycleEdges),
|
|
163
|
+
}));
|
|
164
|
+
for (const { cycle, fas } of feedbackEdgesByCycle) {
|
|
165
|
+
const bp = pickSuggestedBreakpoint(fas, cycle.modules);
|
|
166
|
+
if (bp) cycle.suggestedBreakpoint = bp;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
onProgress?.({ phase: 'cycles' });
|
|
170
|
+
const entries = await discoverEntryPoints({
|
|
171
|
+
source,
|
|
172
|
+
moduleIds: modules.map((m) => m.id),
|
|
173
|
+
framework: project.detectedFramework as Framework,
|
|
174
|
+
...(config.entryPoints ? { configEntryPoints: config.entryPoints } : {}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
onProgress?.({ phase: 'metrics' });
|
|
178
|
+
const metrics = computeMetrics({ modules, edges, cycles, entries });
|
|
179
|
+
|
|
180
|
+
for (const m of modules) {
|
|
181
|
+
if (entries.includes(m.id) && m.kind === 'unknown') m.kind = 'entry';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hotZones = rankHotZones({ modules, metrics, topN: options.topHotZones ?? 10 });
|
|
185
|
+
const layerViolations = detectLayerViolations(modules, edges, config.layerOverrides);
|
|
186
|
+
const archDebt = computeArchDebt({
|
|
187
|
+
modules,
|
|
188
|
+
cycles,
|
|
189
|
+
layerViolations,
|
|
190
|
+
metrics,
|
|
191
|
+
hotZoneCount: hotZones.length,
|
|
192
|
+
});
|
|
193
|
+
const contractViolations = checkContracts({
|
|
194
|
+
modules,
|
|
195
|
+
edges,
|
|
196
|
+
metrics,
|
|
197
|
+
cycles,
|
|
198
|
+
...(config.contracts ? { contracts: config.contracts } : {}),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const rscLeaks = detectRscLeaks({ modules, edges });
|
|
202
|
+
if (rscLeaks.length > 0) contractViolations.unshift(...rscLeaks);
|
|
203
|
+
|
|
204
|
+
const bundleThresholds = {
|
|
205
|
+
...(config.bundle ?? {}),
|
|
206
|
+
...(options.bundleThresholds ?? {}),
|
|
207
|
+
};
|
|
208
|
+
const effectiveBundleStats = options.bundleStats ?? (await tryAutoLoadBundleStats(source));
|
|
209
|
+
const bundle = effectiveBundleStats
|
|
210
|
+
? analyzeBundle({
|
|
211
|
+
modules,
|
|
212
|
+
stats: effectiveBundleStats,
|
|
213
|
+
...(Object.keys(bundleThresholds).length > 0 ? { thresholds: bundleThresholds } : {}),
|
|
214
|
+
})
|
|
215
|
+
: undefined;
|
|
216
|
+
|
|
217
|
+
// Compute temporal coupling first when history is present so that
|
|
218
|
+
// recommendations can include it. `computeRecommendations` is the only
|
|
219
|
+
// place where ordering matters — both blocks below feed into it.
|
|
220
|
+
let churn: ChurnByModule | undefined;
|
|
221
|
+
let temporalCoupling: TemporalCoupling[] | undefined;
|
|
222
|
+
if (options.gitHistory) {
|
|
223
|
+
churn = computeChurn({ modules, history: options.gitHistory });
|
|
224
|
+
temporalCoupling = computeTemporalCoupling({
|
|
225
|
+
modules,
|
|
226
|
+
edges,
|
|
227
|
+
history: options.gitHistory,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const memoryRisks = await detectMemoryRisks({
|
|
232
|
+
source,
|
|
233
|
+
modules,
|
|
234
|
+
framework: project.detectedFramework,
|
|
235
|
+
});
|
|
236
|
+
const asyncLifecycleRisks = await detectAsyncLifecycleRisks({
|
|
237
|
+
source,
|
|
238
|
+
modules,
|
|
239
|
+
framework: project.detectedFramework,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const recommendations = computeRecommendations({
|
|
243
|
+
modules,
|
|
244
|
+
edges,
|
|
245
|
+
metrics,
|
|
246
|
+
cycles,
|
|
247
|
+
layerViolations,
|
|
248
|
+
hotZones,
|
|
249
|
+
entries,
|
|
250
|
+
typeOnlyCandidates,
|
|
251
|
+
contractViolations,
|
|
252
|
+
...(bundle ? { bundleBloat: bundle.bloat } : {}),
|
|
253
|
+
...(temporalCoupling ? { temporalCoupling } : {}),
|
|
254
|
+
});
|
|
255
|
+
const builtSignals = buildArchitectureSignals({
|
|
256
|
+
recommendations,
|
|
257
|
+
warnings,
|
|
258
|
+
parserFacts,
|
|
259
|
+
...(config.signals?.insightLimit !== undefined
|
|
260
|
+
? { insightLimit: config.signals.insightLimit }
|
|
261
|
+
: {}),
|
|
262
|
+
...(config.signals?.minInsightSeverity
|
|
263
|
+
? { minInsightSeverity: config.signals.minInsightSeverity }
|
|
264
|
+
: {}),
|
|
265
|
+
...(config.signals?.minInsightConfidence
|
|
266
|
+
? { minInsightConfidence: config.signals.minInsightConfidence }
|
|
267
|
+
: {}),
|
|
268
|
+
...(memoryRisks.length > 0 ? { memoryRisks } : {}),
|
|
269
|
+
...(asyncLifecycleRisks.length > 0 ? { asyncLifecycleRisks } : {}),
|
|
270
|
+
});
|
|
271
|
+
const signals =
|
|
272
|
+
config.signals?.suppressions && config.signals.suppressions.length > 0
|
|
273
|
+
? applySignalSuppressions(builtSignals.signals, config.signals.suppressions).signals
|
|
274
|
+
: builtSignals.signals;
|
|
275
|
+
const { insights } = builtSignals;
|
|
276
|
+
|
|
277
|
+
onProgress?.({ phase: 'done' });
|
|
278
|
+
return {
|
|
279
|
+
project,
|
|
280
|
+
modules,
|
|
281
|
+
edges,
|
|
282
|
+
cycles,
|
|
283
|
+
metrics,
|
|
284
|
+
hotZones,
|
|
285
|
+
layerViolations,
|
|
286
|
+
archDebt,
|
|
287
|
+
recommendations,
|
|
288
|
+
signals,
|
|
289
|
+
insights,
|
|
290
|
+
parserFacts,
|
|
291
|
+
contractViolations,
|
|
292
|
+
configStatus,
|
|
293
|
+
...(configDiagnostics.length > 0 ? { configDiagnostics } : {}),
|
|
294
|
+
...(bundle ? { bundle } : {}),
|
|
295
|
+
...(churn ? { churn, gitHistory: options.gitHistory } : {}),
|
|
296
|
+
...(temporalCoupling && temporalCoupling.length > 0 ? { temporalCoupling } : {}),
|
|
297
|
+
...(memoryRisks.length > 0 ? { memoryRisks } : {}),
|
|
298
|
+
...(asyncLifecycleRisks.length > 0 ? { asyncLifecycleRisks } : {}),
|
|
299
|
+
scannedAt: new Date().toISOString(),
|
|
300
|
+
durationMs: Date.now() - start,
|
|
301
|
+
warnings,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function detectProject(source: FileSource, warnings: AnalyzerWarning[]): Promise<ProjectRef> {
|
|
306
|
+
const rootPath = source.rootPath;
|
|
307
|
+
const name = basename(rootPath);
|
|
308
|
+
const id = stableHash(rootPath);
|
|
309
|
+
|
|
310
|
+
const { framework: detectedFramework } = await detectFramework(source);
|
|
311
|
+
|
|
312
|
+
let tsconfigPath: string | undefined;
|
|
313
|
+
for (const candidate of [
|
|
314
|
+
'tsconfig.json',
|
|
315
|
+
'tsconfig.app.json',
|
|
316
|
+
'tsconfig.base.json',
|
|
317
|
+
'jsconfig.json',
|
|
318
|
+
]) {
|
|
319
|
+
if (await source.exists(candidate)) {
|
|
320
|
+
tsconfigPath = candidate;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!tsconfigPath) {
|
|
325
|
+
warnings.push({ code: 'tsconfig-missing', message: 'No tsconfig.json/jsconfig.json found' });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
id,
|
|
330
|
+
name,
|
|
331
|
+
rootPath,
|
|
332
|
+
detectedFramework,
|
|
333
|
+
...(tsconfigPath !== undefined ? { tsconfigPath } : {}),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function basename(p: string): string {
|
|
338
|
+
const norm = p.replace(/\\/gu, '/').replace(/\/+$/u, '');
|
|
339
|
+
const i = norm.lastIndexOf('/');
|
|
340
|
+
return i === -1 ? norm : norm.slice(i + 1);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Best single edge to break the SCC, ranked by `broken desc, from asc`
|
|
344
|
+
// to match recommendations.ts. For SCC larger than `countBrokenCycles`
|
|
345
|
+
// exact limit the ranking falls back to capped simple-path counts; for
|
|
346
|
+
// SCC with no recoverable internal edges returns undefined.
|
|
347
|
+
function pickSuggestedBreakpoint(
|
|
348
|
+
fas: FeedbackArcSetResult,
|
|
349
|
+
scc: ModuleId[],
|
|
350
|
+
): { from: ModuleId; to: ModuleId } | undefined {
|
|
351
|
+
if (fas.feedback.size === 0) return undefined;
|
|
352
|
+
const broken = countBrokenCycles(scc, fas.internal, fas.feedback);
|
|
353
|
+
const ranked = [...fas.feedback]
|
|
354
|
+
.map((k) => {
|
|
355
|
+
const stats = broken.byEdge.get(k);
|
|
356
|
+
return { key: k, broken: stats?.broken ?? 0 };
|
|
357
|
+
})
|
|
358
|
+
.sort((a, b) => {
|
|
359
|
+
if (b.broken !== a.broken) return b.broken - a.broken;
|
|
360
|
+
return a.key.localeCompare(b.key);
|
|
361
|
+
});
|
|
362
|
+
const best = ranked[0];
|
|
363
|
+
if (!best) return undefined;
|
|
364
|
+
const { from, to } = parseEdgeKey(best.key);
|
|
365
|
+
// Skip self-loops on multi-node SCC: length-1 cycles already surface
|
|
366
|
+
// the module on its own card.
|
|
367
|
+
if (from === to && scc.length > 1) {
|
|
368
|
+
const nonSelf = ranked.find(({ key }) => {
|
|
369
|
+
const parsed = parseEdgeKey(key);
|
|
370
|
+
return parsed.from !== parsed.to;
|
|
371
|
+
});
|
|
372
|
+
if (!nonSelf) return undefined;
|
|
373
|
+
return parseEdgeKey(nonSelf.key);
|
|
374
|
+
}
|
|
375
|
+
return { from, to };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function erasedKey(from: string, to: string, specifier: string): string {
|
|
379
|
+
return `${from}\u0001${to}\u0001${specifier}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Every distinct (from, to, specifier) import that sits *inside* a cycle — both
|
|
384
|
+
* endpoints in the same SCC. Probing all of them (not just the feedback arc set)
|
|
385
|
+
* lets us spot a type-only edge wherever it lives in the loop, so a phantom
|
|
386
|
+
* cycle is dropped even when the erasable import is not the one FAS would pick.
|
|
387
|
+
*/
|
|
388
|
+
function collectInCycleEdges(
|
|
389
|
+
cycles: { modules: ModuleId[] }[],
|
|
390
|
+
edges: { from: string; to: string; specifier: string; kind: string }[],
|
|
391
|
+
): { from: string; to: string; specifier: string }[] {
|
|
392
|
+
if (cycles.length === 0) return [];
|
|
393
|
+
const cycleOf = new Map<ModuleId, number>();
|
|
394
|
+
cycles.forEach((c, i) => {
|
|
395
|
+
for (const m of c.modules) cycleOf.set(m, i);
|
|
396
|
+
});
|
|
397
|
+
const seen = new Set<string>();
|
|
398
|
+
const out: { from: string; to: string; specifier: string }[] = [];
|
|
399
|
+
for (const e of edges) {
|
|
400
|
+
if (e.kind === 'type-only') continue;
|
|
401
|
+
const ci = cycleOf.get(e.from);
|
|
402
|
+
if (ci === undefined || cycleOf.get(e.to) !== ci) continue;
|
|
403
|
+
const key = erasedKey(e.from, e.to, e.specifier);
|
|
404
|
+
if (seen.has(key)) continue;
|
|
405
|
+
seen.add(key);
|
|
406
|
+
out.push({ from: e.from, to: e.to, specifier: e.specifier });
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function stableHash(input: string): string {
|
|
412
|
+
let h1 = 0xdeadbeef;
|
|
413
|
+
let h2 = 0x41c6ce57;
|
|
414
|
+
for (let i = 0; i < input.length; i++) {
|
|
415
|
+
const ch = input.charCodeAt(i);
|
|
416
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
417
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
418
|
+
}
|
|
419
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
420
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
421
|
+
return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Auto-load bundle stats from common locations next to the project root
|
|
426
|
+
* (bundle-stats.json, dist/stats.json). Returns undefined when nothing is
|
|
427
|
+
* found or parsing fails - silent so the UI / CLI never errors out on a
|
|
428
|
+
* malformed stats file the user didn't ask us to read.
|
|
429
|
+
*/
|
|
430
|
+
async function tryAutoLoadBundleStats(source: FileSource): Promise<ParsedBundleStats | undefined> {
|
|
431
|
+
const candidates = ['bundle-stats.json', 'dist/stats.json'];
|
|
432
|
+
for (const candidate of candidates) {
|
|
433
|
+
if (!(await source.exists(candidate))) continue;
|
|
434
|
+
try {
|
|
435
|
+
const text = await source.read(candidate);
|
|
436
|
+
const json = JSON.parse(text) as unknown;
|
|
437
|
+
const parsed = parseBundleStats(json, { rootPath: source.rootPath });
|
|
438
|
+
if (parsed.format !== 'unknown') return parsed;
|
|
439
|
+
} catch {
|
|
440
|
+
// ignore - next candidate
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import ignore, { type Ignore } from 'ignore';
|
|
2
|
+
import type { DependencyEdge, ModuleId, ModuleNode } from './types';
|
|
3
|
+
|
|
4
|
+
// FSD layers, lower index = higher. imports allowed top-down only.
|
|
5
|
+
// 'unknown' = outside FSD canon; edges to/from it are not flagged.
|
|
6
|
+
export type Layer =
|
|
7
|
+
| 'app'
|
|
8
|
+
| 'pages'
|
|
9
|
+
| 'widgets'
|
|
10
|
+
| 'features'
|
|
11
|
+
| 'entities'
|
|
12
|
+
| 'shared'
|
|
13
|
+
| 'core'
|
|
14
|
+
| 'unknown';
|
|
15
|
+
|
|
16
|
+
export const LAYER_ORDER: Layer[] = [
|
|
17
|
+
'app',
|
|
18
|
+
'pages',
|
|
19
|
+
'widgets',
|
|
20
|
+
'features',
|
|
21
|
+
'entities',
|
|
22
|
+
'shared',
|
|
23
|
+
'core',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const LAYER_RANK: Record<Layer, number> = {
|
|
27
|
+
app: 0,
|
|
28
|
+
pages: 1,
|
|
29
|
+
widgets: 2,
|
|
30
|
+
features: 3,
|
|
31
|
+
entities: 4,
|
|
32
|
+
shared: 5,
|
|
33
|
+
core: 6,
|
|
34
|
+
unknown: -1,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface LayerViolation {
|
|
38
|
+
edgeId: string;
|
|
39
|
+
from: ModuleId;
|
|
40
|
+
to: ModuleId;
|
|
41
|
+
fromLayer: Layer;
|
|
42
|
+
toLayer: Layer;
|
|
43
|
+
severity: 'error' | 'warning';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** glob → layer-name (string, not narrowed - we validate at lookup time). */
|
|
47
|
+
export type LayerOverrides = Record<string, string>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compile `{ glob: layer }` to a fast lookup. Each entry uses gitignore-style
|
|
51
|
+
* `ignore` matcher (same syntax as the rest of the project). Order is
|
|
52
|
+
* preserved: first matching pattern wins, mirroring the user's mental model.
|
|
53
|
+
*/
|
|
54
|
+
interface CompiledOverride {
|
|
55
|
+
pattern: string;
|
|
56
|
+
layer: Layer;
|
|
57
|
+
matcher: Ignore;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compileOverrides(overrides: LayerOverrides | undefined): CompiledOverride[] {
|
|
61
|
+
if (!overrides) return [];
|
|
62
|
+
const out: CompiledOverride[] = [];
|
|
63
|
+
for (const [pattern, raw] of Object.entries(overrides)) {
|
|
64
|
+
if (!pattern) continue;
|
|
65
|
+
if (!isLayer(raw)) continue;
|
|
66
|
+
try {
|
|
67
|
+
out.push({ pattern, layer: raw, matcher: ignore().add(pattern) });
|
|
68
|
+
} catch {
|
|
69
|
+
// bad glob - skip silently; the editor surfaces validation up-front
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchOverride(compiled: CompiledOverride[], moduleId: ModuleId): Layer | null {
|
|
76
|
+
if (compiled.length === 0) return null;
|
|
77
|
+
for (const c of compiled) {
|
|
78
|
+
if (c.matcher.ignores(moduleId)) return c.layer;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// layer = override glob (if matches) → first `src/<layer>/...` segment → unknown.
|
|
84
|
+
export function detectLayer(moduleId: ModuleId, overrides?: LayerOverrides): Layer {
|
|
85
|
+
const compiled = compileOverrides(overrides);
|
|
86
|
+
return detectLayerInner(moduleId, compiled);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function detectLayerInner(moduleId: ModuleId, compiled: CompiledOverride[]): Layer {
|
|
90
|
+
const overridden = matchOverride(compiled, moduleId);
|
|
91
|
+
if (overridden) return overridden;
|
|
92
|
+
const segments = moduleId.split('/');
|
|
93
|
+
for (const seg of segments) {
|
|
94
|
+
if (seg === 'src') continue;
|
|
95
|
+
if (isLayer(seg)) return seg;
|
|
96
|
+
return 'unknown';
|
|
97
|
+
}
|
|
98
|
+
return 'unknown';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isLayer(s: string): s is Layer {
|
|
102
|
+
return s in LAYER_RANK && s !== 'unknown';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function detectLayerViolations(
|
|
106
|
+
modules: ModuleNode[],
|
|
107
|
+
edges: DependencyEdge[],
|
|
108
|
+
overrides?: LayerOverrides,
|
|
109
|
+
): LayerViolation[] {
|
|
110
|
+
const compiled = compileOverrides(overrides);
|
|
111
|
+
const layerOf = new Map<ModuleId, Layer>();
|
|
112
|
+
for (const m of modules) layerOf.set(m.id, detectLayerInner(m.id, compiled));
|
|
113
|
+
|
|
114
|
+
const violations: LayerViolation[] = [];
|
|
115
|
+
for (const e of edges) {
|
|
116
|
+
if (!e.resolved) continue;
|
|
117
|
+
if (e.kind === 'type-only') continue;
|
|
118
|
+
const fromLayer = layerOf.get(e.from);
|
|
119
|
+
const toLayer = layerOf.get(e.to);
|
|
120
|
+
if (!fromLayer || !toLayer) continue;
|
|
121
|
+
if (fromLayer === 'unknown' || toLayer === 'unknown') continue;
|
|
122
|
+
const fromRank = LAYER_RANK[fromLayer];
|
|
123
|
+
const toRank = LAYER_RANK[toLayer];
|
|
124
|
+
if (fromRank > toRank) {
|
|
125
|
+
// deep -> top inversion = error; adjacent = warning
|
|
126
|
+
violations.push({
|
|
127
|
+
edgeId: edgeId(e.from, e.to),
|
|
128
|
+
from: e.from,
|
|
129
|
+
to: e.to,
|
|
130
|
+
fromLayer,
|
|
131
|
+
toLayer,
|
|
132
|
+
severity: toRank <= 1 && fromRank >= 4 ? 'error' : 'warning',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return violations;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Re-run layer assignment + violation detection against an existing
|
|
141
|
+
* `ScanResult`-shaped input without re-parsing files. Used by the layer-rules
|
|
142
|
+
* editor to provide live preview while the user is typing.
|
|
143
|
+
*/
|
|
144
|
+
export interface RecomputeLayersInput {
|
|
145
|
+
modules: ModuleNode[];
|
|
146
|
+
edges: DependencyEdge[];
|
|
147
|
+
overrides?: LayerOverrides;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface RecomputeLayersOutput {
|
|
151
|
+
violations: LayerViolation[];
|
|
152
|
+
byModule: Record<ModuleId, Layer>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function recomputeLayers(input: RecomputeLayersInput): RecomputeLayersOutput {
|
|
156
|
+
const compiled = compileOverrides(input.overrides);
|
|
157
|
+
const byModule: Record<ModuleId, Layer> = {};
|
|
158
|
+
for (const m of input.modules) byModule[m.id] = detectLayerInner(m.id, compiled);
|
|
159
|
+
const violations = detectLayerViolations(input.modules, input.edges, input.overrides);
|
|
160
|
+
return { violations, byModule };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validate a single override pattern. Returns null when fine, or an error
|
|
165
|
+
* code when the glob is empty / produces a matcher rejection. Layer names
|
|
166
|
+
* are validated separately (UI uses `LAYER_ORDER` to populate the dropdown).
|
|
167
|
+
*/
|
|
168
|
+
export type LayerOverrideError = 'empty' | 'invalid-glob' | 'unknown-layer';
|
|
169
|
+
|
|
170
|
+
export function validateLayerOverride(pattern: string, layer: string): LayerOverrideError | null {
|
|
171
|
+
if (!pattern.trim()) return 'empty';
|
|
172
|
+
if (!isLayer(layer)) return 'unknown-layer';
|
|
173
|
+
try {
|
|
174
|
+
ignore().add(pattern);
|
|
175
|
+
} catch {
|
|
176
|
+
return 'invalid-glob';
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function edgeId(from: ModuleId, to: ModuleId): string {
|
|
182
|
+
return `${from}\u0001${to}`;
|
|
183
|
+
}
|