@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,265 @@
|
|
|
1
|
+
// Architectural contracts engine.
|
|
2
|
+
//
|
|
3
|
+
// Compiles user-defined boundary, budget and api-stability rules from
|
|
4
|
+
// `.archora.json -> contracts` into a list of `ContractViolation`s after a
|
|
5
|
+
// scan completes. The engine is *purely structural* - it operates on the
|
|
6
|
+
// already-built modules/edges/cycles/metrics tuple, no I/O, no parsing.
|
|
7
|
+
//
|
|
8
|
+
// Glob matching reuses the `ignore` package (same dialect as `layerOverrides`
|
|
9
|
+
// and `discover.extraIgnoreGlobs`), so users get a single mental model for
|
|
10
|
+
// every globbed field in our config.
|
|
11
|
+
|
|
12
|
+
import ignore, { type Ignore } from 'ignore';
|
|
13
|
+
|
|
14
|
+
import type { BoundaryRule, BudgetRule, ContractsConfig } from '../config/frontScopeConfig';
|
|
15
|
+
import type { Cycle, DependencyEdge, ModuleId, ModuleMetrics, ModuleNode } from './types';
|
|
16
|
+
|
|
17
|
+
export interface ContractViolation {
|
|
18
|
+
/** Stable id for grouping/diffing. Format: `<rule-kind>:<rule-name>:<idx>`. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Which kind of contract was violated. `api-stability` is only a stub now. */
|
|
21
|
+
kind: 'boundary' | 'budget' | 'api-stability' | 'rsc-leak';
|
|
22
|
+
ruleName: string;
|
|
23
|
+
severity: 'error' | 'warning';
|
|
24
|
+
/** Human-readable headline (CLI/UI use this verbatim). */
|
|
25
|
+
message: string;
|
|
26
|
+
/** Free-form `description` echoed from the rule, if present. */
|
|
27
|
+
description?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Module(s) that triggered the violation. For boundary rules these are the
|
|
30
|
+
* `from` and `to` of the offending edge; for budget rules - the offending
|
|
31
|
+
* module(s).
|
|
32
|
+
*/
|
|
33
|
+
modules: ModuleId[];
|
|
34
|
+
/** Set for boundary violations: identity of the offending edge. */
|
|
35
|
+
edge?: { from: ModuleId; to: ModuleId; specifier: string };
|
|
36
|
+
/** Numeric details for budget violations (`metric=value vs limit`). */
|
|
37
|
+
detail?: {
|
|
38
|
+
metric: 'fanIn' | 'fanOut' | 'loc' | 'cycles';
|
|
39
|
+
value: number;
|
|
40
|
+
limit: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CheckContractsInput {
|
|
45
|
+
modules: ModuleNode[];
|
|
46
|
+
edges: DependencyEdge[];
|
|
47
|
+
metrics: Record<ModuleId, ModuleMetrics>;
|
|
48
|
+
cycles: Cycle[];
|
|
49
|
+
contracts?: ContractsConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run every configured contract rule against a finished scan. Returns an
|
|
54
|
+
* empty array when no contracts are configured.
|
|
55
|
+
*/
|
|
56
|
+
export function checkContracts(input: CheckContractsInput): ContractViolation[] {
|
|
57
|
+
const cfg = input.contracts;
|
|
58
|
+
if (!cfg) return [];
|
|
59
|
+
const out: ContractViolation[] = [];
|
|
60
|
+
|
|
61
|
+
if (cfg.boundaries) {
|
|
62
|
+
for (const rule of cfg.boundaries) out.push(...evalBoundary(rule, input));
|
|
63
|
+
}
|
|
64
|
+
if (cfg.budgets) {
|
|
65
|
+
for (const rule of cfg.budgets) out.push(...evalBudget(rule, input));
|
|
66
|
+
}
|
|
67
|
+
// api-stability: schema only, full diff lives in 6.4. We surface a single
|
|
68
|
+
// informational violation per rule so users see the policy is recognized.
|
|
69
|
+
// Skipped here to avoid noise - revisit when 6.4 lands.
|
|
70
|
+
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Boundary ----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function evalBoundary(rule: BoundaryRule, input: CheckContractsInput): ContractViolation[] {
|
|
77
|
+
const fromMatcher = makeMatcher(rule.from);
|
|
78
|
+
const toMatcher = makeMatcher(rule.to);
|
|
79
|
+
const exceptMatcher = rule.except && rule.except.length > 0 ? makeMatcher(rule.except) : null;
|
|
80
|
+
if (!fromMatcher || !toMatcher) return [];
|
|
81
|
+
const severity = rule.severity ?? 'error';
|
|
82
|
+
// crossInstance: extract the segment matched by the first `*` (single-star)
|
|
83
|
+
// wildcard in each pattern. Edges where these segments are equal are
|
|
84
|
+
// considered same-instance (e.g. both modules under `features/auth/...`)
|
|
85
|
+
// and skipped. We only support the symmetric case (same wildcard depth in
|
|
86
|
+
// `from` and `to`) - asymmetric users can construct patterns explicitly.
|
|
87
|
+
const crossDepth = rule.crossInstance ? wildcardDepth(rule.from, rule.to) : -1;
|
|
88
|
+
const out: ContractViolation[] = [];
|
|
89
|
+
let idx = 0;
|
|
90
|
+
|
|
91
|
+
for (const e of input.edges) {
|
|
92
|
+
if (!e.resolved) continue;
|
|
93
|
+
// type-only imports don't carry runtime semantics; treating them as
|
|
94
|
+
// boundary violations would force users to either suppress with `except`
|
|
95
|
+
// or thread `import type` everywhere. We exclude them - mirrors how the
|
|
96
|
+
// built-in layer-violation check treats them.
|
|
97
|
+
if (e.kind === 'type-only') continue;
|
|
98
|
+
if (e.from === e.to) continue; // self-imports already produce a warning
|
|
99
|
+
if (!fromMatcher.ignores(e.from)) continue;
|
|
100
|
+
|
|
101
|
+
if (crossDepth >= 0 && segmentAt(e.from, crossDepth) === segmentAt(e.to, crossDepth)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const targetMatches = toMatcher.ignores(e.to);
|
|
106
|
+
const isException = exceptMatcher ? exceptMatcher.ignores(e.to) : false;
|
|
107
|
+
|
|
108
|
+
let trips = false;
|
|
109
|
+
if (rule.mode === 'must-not') {
|
|
110
|
+
// Forbidden edge: target matches AND is not in the whitelist.
|
|
111
|
+
trips = targetMatches && !isException;
|
|
112
|
+
} else {
|
|
113
|
+
// can-only: any edge from a `from`-module that DOESN'T land on `to` (or
|
|
114
|
+
// an exception) is forbidden. Edges that stay within `from`'s own glob
|
|
115
|
+
// are allowed - keeps internal refactors free.
|
|
116
|
+
const insideFromGlob = fromMatcher.ignores(e.to);
|
|
117
|
+
trips = !targetMatches && !isException && !insideFromGlob;
|
|
118
|
+
}
|
|
119
|
+
if (!trips) continue;
|
|
120
|
+
|
|
121
|
+
out.push({
|
|
122
|
+
id: `boundary:${rule.name}:${idx++}`,
|
|
123
|
+
kind: 'boundary',
|
|
124
|
+
ruleName: rule.name,
|
|
125
|
+
severity,
|
|
126
|
+
message:
|
|
127
|
+
rule.mode === 'must-not'
|
|
128
|
+
? `${shortId(e.from)} must not import ${shortId(e.to)} (rule "${rule.name}")`
|
|
129
|
+
: `${shortId(e.from)} can only import ${describeTo(rule)}, but imports ${shortId(e.to)} (rule "${rule.name}")`,
|
|
130
|
+
...(rule.description ? { description: rule.description } : {}),
|
|
131
|
+
modules: [e.from, e.to],
|
|
132
|
+
edge: { from: e.from, to: e.to, specifier: e.specifier },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describeTo(rule: BoundaryRule): string {
|
|
139
|
+
return rule.except && rule.except.length > 0
|
|
140
|
+
? `${rule.to} (except ${rule.except.length} whitelisted)`
|
|
141
|
+
: rule.to;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Budget ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function evalBudget(rule: BudgetRule, input: CheckContractsInput): ContractViolation[] {
|
|
147
|
+
const matcher = makeMatcher(rule.module);
|
|
148
|
+
if (!matcher) return [];
|
|
149
|
+
const severity = rule.severity ?? 'error';
|
|
150
|
+
const out: ContractViolation[] = [];
|
|
151
|
+
let idx = 0;
|
|
152
|
+
|
|
153
|
+
for (const m of input.modules) {
|
|
154
|
+
if (!matcher.ignores(m.id)) continue;
|
|
155
|
+
const mt = input.metrics[m.id];
|
|
156
|
+
if (mt === undefined) continue;
|
|
157
|
+
if (rule.maxFanIn !== undefined && mt.fanIn > rule.maxFanIn) {
|
|
158
|
+
out.push(budget(rule, severity, idx++, m.id, 'fanIn', mt.fanIn, rule.maxFanIn));
|
|
159
|
+
}
|
|
160
|
+
if (rule.maxFanOut !== undefined && mt.fanOut > rule.maxFanOut) {
|
|
161
|
+
out.push(budget(rule, severity, idx++, m.id, 'fanOut', mt.fanOut, rule.maxFanOut));
|
|
162
|
+
}
|
|
163
|
+
if (rule.maxLoc !== undefined && m.loc > rule.maxLoc) {
|
|
164
|
+
out.push(budget(rule, severity, idx++, m.id, 'loc', m.loc, rule.maxLoc));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Cycles: aggregate. A single budget breach is "this glob owns >N cycles
|
|
169
|
+
// (any cycle that touches at least one module under the glob)". We emit a
|
|
170
|
+
// single violation pointing at the offending modules.
|
|
171
|
+
if (rule.maxCycles !== undefined) {
|
|
172
|
+
const matched = input.cycles.filter((c) => c.modules.some((id) => matcher.ignores(id)));
|
|
173
|
+
if (matched.length > rule.maxCycles) {
|
|
174
|
+
const touched = unique(matched.flatMap((c) => c.modules.filter((id) => matcher.ignores(id))));
|
|
175
|
+
out.push({
|
|
176
|
+
id: `budget:${rule.name}:${idx++}`,
|
|
177
|
+
kind: 'budget',
|
|
178
|
+
ruleName: rule.name,
|
|
179
|
+
severity,
|
|
180
|
+
message: `${matched.length} cycle(s) touching "${rule.module}" exceed limit of ${rule.maxCycles} (rule "${rule.name}")`,
|
|
181
|
+
...(rule.description ? { description: rule.description } : {}),
|
|
182
|
+
modules: touched,
|
|
183
|
+
detail: { metric: 'cycles', value: matched.length, limit: rule.maxCycles },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function budget(
|
|
191
|
+
rule: BudgetRule,
|
|
192
|
+
severity: 'error' | 'warning',
|
|
193
|
+
idx: number,
|
|
194
|
+
moduleId: ModuleId,
|
|
195
|
+
metric: 'fanIn' | 'fanOut' | 'loc',
|
|
196
|
+
value: number,
|
|
197
|
+
limit: number,
|
|
198
|
+
): ContractViolation {
|
|
199
|
+
return {
|
|
200
|
+
id: `budget:${rule.name}:${idx}`,
|
|
201
|
+
kind: 'budget',
|
|
202
|
+
ruleName: rule.name,
|
|
203
|
+
severity,
|
|
204
|
+
message: `${shortId(moduleId)} ${metric}=${value} exceeds budget ${limit} (rule "${rule.name}")`,
|
|
205
|
+
...(rule.description ? { description: rule.description } : {}),
|
|
206
|
+
modules: [moduleId],
|
|
207
|
+
detail: { metric, value, limit },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Helpers -----------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Find the path-segment depth of the first single-star (`*`, not `**`)
|
|
215
|
+
* wildcard, only when both patterns share the same depth. Returns -1 when
|
|
216
|
+
* patterns differ structurally - the caller falls back to plain matching.
|
|
217
|
+
*/
|
|
218
|
+
function wildcardDepth(fromPattern: string, toPattern: string): number {
|
|
219
|
+
const fa = singleStarDepth(fromPattern);
|
|
220
|
+
const fb = singleStarDepth(toPattern);
|
|
221
|
+
if (fa === -1 || fb === -1) return -1;
|
|
222
|
+
return fa === fb ? fa : -1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function singleStarDepth(pattern: string): number {
|
|
226
|
+
const segs = pattern.split('/');
|
|
227
|
+
for (let i = 0; i < segs.length; i++) {
|
|
228
|
+
if (segs[i] === '*') return i;
|
|
229
|
+
}
|
|
230
|
+
return -1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function segmentAt(id: string, depth: number): string | undefined {
|
|
234
|
+
return id.split('/')[depth];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function makeMatcher(pattern: string | string[]): Ignore | null {
|
|
238
|
+
try {
|
|
239
|
+
const ig = ignore();
|
|
240
|
+
ig.add(pattern);
|
|
241
|
+
return ig;
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function shortId(moduleId: ModuleId): string {
|
|
248
|
+
// Match the displayShortId style without pulling its full implementation
|
|
249
|
+
// (which depends on the existing module list). The full path stays in
|
|
250
|
+
// `modules[]` for consumers that need it.
|
|
251
|
+
const segs = moduleId.split('/');
|
|
252
|
+
if (segs.length <= 3) return moduleId;
|
|
253
|
+
return `…/${segs.slice(-3).join('/')}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function unique<T>(arr: readonly T[]): T[] {
|
|
257
|
+
const seen = new Set<T>();
|
|
258
|
+
const out: T[] = [];
|
|
259
|
+
for (const x of arr) {
|
|
260
|
+
if (seen.has(x)) continue;
|
|
261
|
+
seen.add(x);
|
|
262
|
+
out.push(x);
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { DependencyEdge, ModuleId } from './types';
|
|
2
|
+
import { type EdgeKey, parseEdgeKey } from './feedbackArcSet';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Architectural patterns detected on the feedback arc set of a SCC. The label
|
|
6
|
+
* dictates the action advice shown in the UI - each pattern maps to a
|
|
7
|
+
* different idiom ("use useRouter()", "import sibling directly", ...).
|
|
8
|
+
*
|
|
9
|
+
* Order of evaluation is fixed: barrel-cycle is checked before hub-feedback,
|
|
10
|
+
* so a barrel-import that also looks like a feedback hub still gets the
|
|
11
|
+
* sibling-direct advice (the more specific fix).
|
|
12
|
+
*/
|
|
13
|
+
export type CyclePattern =
|
|
14
|
+
| { kind: 'mutual-pair'; a: ModuleId; b: ModuleId }
|
|
15
|
+
| { kind: 'barrel-cycle'; barrel: ModuleId; sibling: ModuleId }
|
|
16
|
+
| {
|
|
17
|
+
kind: 'hub-feedback';
|
|
18
|
+
hub: ModuleId;
|
|
19
|
+
incomingCount: number;
|
|
20
|
+
valueImports: number;
|
|
21
|
+
}
|
|
22
|
+
| { kind: 'long-chain'; length: number; bridge: EdgeKey }
|
|
23
|
+
| { kind: 'mixed' };
|
|
24
|
+
|
|
25
|
+
const HUB_THRESHOLD = 0.7;
|
|
26
|
+
const LONG_CHAIN_MIN = 8;
|
|
27
|
+
const LONG_CHAIN_MAX_FEEDBACK = 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pattern-match a SCC by its FAS feedback edges.
|
|
31
|
+
*
|
|
32
|
+
* Inputs:
|
|
33
|
+
* - scc: module ids in the SCC
|
|
34
|
+
* - feedback: result of `feedbackArcSet`
|
|
35
|
+
* - internalEdges: SCC-internal edges (deduped); used to count value vs
|
|
36
|
+
* type-only imports per hub
|
|
37
|
+
*/
|
|
38
|
+
export function classifyCyclePattern(args: {
|
|
39
|
+
scc: ModuleId[];
|
|
40
|
+
internalEdges: DependencyEdge[];
|
|
41
|
+
feedback: Set<EdgeKey>;
|
|
42
|
+
}): CyclePattern {
|
|
43
|
+
const { scc, internalEdges, feedback } = args;
|
|
44
|
+
const fbList = [...feedback].map(parseEdgeKey);
|
|
45
|
+
|
|
46
|
+
// 1. mutual-pair: SCC of exactly 2 with both directed edges. The FAS will
|
|
47
|
+
// only flag one as feedback, but the architecture issue is the pair.
|
|
48
|
+
if (scc.length === 2) {
|
|
49
|
+
const [a, b] = [...scc].sort();
|
|
50
|
+
const hasAB = internalEdges.some((e) => e.from === a && e.to === b);
|
|
51
|
+
const hasBA = internalEdges.some((e) => e.from === b && e.to === a);
|
|
52
|
+
if (hasAB && hasBA) return { kind: 'mutual-pair', a: a!, b: b! };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. barrel-cycle: SCC contains a single `index.*` module and at least one
|
|
56
|
+
// sibling that participates in the SCC. The canonical fix is to import
|
|
57
|
+
// the sibling directly bypassing the barrel; the FAS-picked feedback
|
|
58
|
+
// direction is a tie-breaker artefact and doesn't change the diagnosis.
|
|
59
|
+
if (fbList.length <= 2) {
|
|
60
|
+
const barrels = scc.filter(isBarrel);
|
|
61
|
+
if (barrels.length === 1) {
|
|
62
|
+
const barrel = barrels[0]!;
|
|
63
|
+
const dir = parentDir(barrel);
|
|
64
|
+
const sibling = scc.find((m) => m !== barrel && parentDir(m) === dir);
|
|
65
|
+
if (sibling) return { kind: 'barrel-cycle', barrel, sibling };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. hub-feedback: ≥70% of feedback edges point into the same target.
|
|
70
|
+
// Counts the number of value imports (anything not type-only) - the
|
|
71
|
+
// user advice ("use useRouter() / DI / split instance") only makes
|
|
72
|
+
// sense for value imports.
|
|
73
|
+
if (fbList.length >= 2) {
|
|
74
|
+
const targets = new Map<ModuleId, number>();
|
|
75
|
+
for (const f of fbList) targets.set(f.to, (targets.get(f.to) ?? 0) + 1);
|
|
76
|
+
let bestHub: ModuleId | null = null;
|
|
77
|
+
let bestCount = 0;
|
|
78
|
+
for (const [t, n] of targets) {
|
|
79
|
+
if (n > bestCount) {
|
|
80
|
+
bestCount = n;
|
|
81
|
+
bestHub = t;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (bestHub !== null && bestCount / fbList.length >= HUB_THRESHOLD) {
|
|
85
|
+
const valueImports = countValueImportsInto(bestHub, internalEdges, feedback);
|
|
86
|
+
return {
|
|
87
|
+
kind: 'hub-feedback',
|
|
88
|
+
hub: bestHub,
|
|
89
|
+
incomingCount: bestCount,
|
|
90
|
+
valueImports,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 4. long-chain: large SCC closed by 1-2 feedback edges. The "fix" is
|
|
96
|
+
// usually a misplaced shared type, not a re-architecture.
|
|
97
|
+
if (
|
|
98
|
+
scc.length > LONG_CHAIN_MIN &&
|
|
99
|
+
fbList.length <= LONG_CHAIN_MAX_FEEDBACK &&
|
|
100
|
+
fbList.length > 0
|
|
101
|
+
) {
|
|
102
|
+
const bridge = fbList[0]!;
|
|
103
|
+
return {
|
|
104
|
+
kind: 'long-chain',
|
|
105
|
+
length: scc.length,
|
|
106
|
+
bridge: `${bridge.from}\u0001${bridge.to}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { kind: 'mixed' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isBarrel(id: ModuleId): boolean {
|
|
114
|
+
const i = id.lastIndexOf('/');
|
|
115
|
+
const base = i === -1 ? id : id.slice(i + 1);
|
|
116
|
+
return /^index\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/.test(base);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parentDir(id: ModuleId): string {
|
|
120
|
+
const i = id.lastIndexOf('/');
|
|
121
|
+
return i === -1 ? '' : id.slice(0, i);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function countValueImportsInto(
|
|
125
|
+
target: ModuleId,
|
|
126
|
+
internalEdges: DependencyEdge[],
|
|
127
|
+
feedback: Set<EdgeKey>,
|
|
128
|
+
): number {
|
|
129
|
+
let n = 0;
|
|
130
|
+
for (const e of internalEdges) {
|
|
131
|
+
if (e.to !== target) continue;
|
|
132
|
+
if (e.kind === 'type-only') continue;
|
|
133
|
+
// count only feedback edges into this hub
|
|
134
|
+
if (!feedback.has(`${e.from}\u0001${e.to}`)) continue;
|
|
135
|
+
n++;
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Cycle, DependencyEdge, ModuleId, ModuleNode } from './types';
|
|
2
|
+
|
|
3
|
+
export function detectCycles(modules: ModuleNode[], edges: DependencyEdge[]): Cycle[] {
|
|
4
|
+
const adj = buildAdjacency(modules, edges);
|
|
5
|
+
const ids = modules.map((m) => m.id);
|
|
6
|
+
|
|
7
|
+
const indices = new Map<ModuleId, number>();
|
|
8
|
+
const lowlinks = new Map<ModuleId, number>();
|
|
9
|
+
const onStack = new Set<ModuleId>();
|
|
10
|
+
const stack: ModuleId[] = [];
|
|
11
|
+
let nextIndex = 0;
|
|
12
|
+
const sccs: ModuleId[][] = [];
|
|
13
|
+
|
|
14
|
+
const strongconnect = (v: ModuleId): void => {
|
|
15
|
+
indices.set(v, nextIndex);
|
|
16
|
+
lowlinks.set(v, nextIndex);
|
|
17
|
+
nextIndex++;
|
|
18
|
+
stack.push(v);
|
|
19
|
+
onStack.add(v);
|
|
20
|
+
|
|
21
|
+
for (const w of adj.get(v) ?? []) {
|
|
22
|
+
if (!indices.has(w)) {
|
|
23
|
+
strongconnect(w);
|
|
24
|
+
lowlinks.set(v, Math.min(lowlinks.get(v) ?? 0, lowlinks.get(w) ?? 0));
|
|
25
|
+
} else if (onStack.has(w)) {
|
|
26
|
+
lowlinks.set(v, Math.min(lowlinks.get(v) ?? 0, indices.get(w) ?? 0));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (lowlinks.get(v) === indices.get(v)) {
|
|
31
|
+
const component: ModuleId[] = [];
|
|
32
|
+
let w: ModuleId | undefined;
|
|
33
|
+
do {
|
|
34
|
+
w = stack.pop();
|
|
35
|
+
if (w === undefined) break;
|
|
36
|
+
onStack.delete(w);
|
|
37
|
+
component.push(w);
|
|
38
|
+
} while (w !== v);
|
|
39
|
+
sccs.push(component);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
if (!indices.has(id)) strongconnect(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const cycles: Cycle[] = [];
|
|
48
|
+
for (const comp of sccs) {
|
|
49
|
+
if (comp.length === 1) {
|
|
50
|
+
const only = comp[0];
|
|
51
|
+
if (only !== undefined && (adj.get(only) ?? []).includes(only)) {
|
|
52
|
+
cycles.push({
|
|
53
|
+
id: cycleId(comp),
|
|
54
|
+
modules: comp,
|
|
55
|
+
length: 1,
|
|
56
|
+
severity: 'direct',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
cycles.push({
|
|
62
|
+
id: cycleId(comp),
|
|
63
|
+
modules: [...comp].sort(),
|
|
64
|
+
length: comp.length,
|
|
65
|
+
severity: comp.length === 2 ? 'direct' : 'indirect',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
cycles.sort((a, b) => b.length - a.length || a.id.localeCompare(b.id));
|
|
69
|
+
return cycles;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildAdjacency(modules: ModuleNode[], edges: DependencyEdge[]): Map<ModuleId, ModuleId[]> {
|
|
73
|
+
const adj = new Map<ModuleId, ModuleId[]>();
|
|
74
|
+
for (const m of modules) adj.set(m.id, []);
|
|
75
|
+
for (const e of edges) {
|
|
76
|
+
if (e.kind === 'type-only') continue;
|
|
77
|
+
const list = adj.get(e.from);
|
|
78
|
+
if (list) list.push(e.to);
|
|
79
|
+
}
|
|
80
|
+
return adj;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Stable, short, content-addressed cycle id derived from the sorted member
|
|
84
|
+
// list. The concrete modules stay on `cycle.modules` for display. The older
|
|
85
|
+
// `modules.join('|')` form produced 2kB+ strings on dense indirect SCCs and
|
|
86
|
+
// blew up fix-plan ids, baseline diffs and report headers.
|
|
87
|
+
function cycleId(modules: ModuleId[]): string {
|
|
88
|
+
return `cycle:${fnv1a32([...modules].sort().join('\u0001'))}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fnv1a32(input: string): string {
|
|
92
|
+
let hash = 0x811c9dc5;
|
|
93
|
+
for (let i = 0; i < input.length; i++) {
|
|
94
|
+
hash ^= input.charCodeAt(i);
|
|
95
|
+
hash = Math.imul(hash, 0x01000193);
|
|
96
|
+
}
|
|
97
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FileSource } from './fileSource';
|
|
2
|
+
|
|
3
|
+
export type Framework = 'vue' | 'react' | 'svelte' | 'nuxt' | 'next' | 'generic' | 'unknown';
|
|
4
|
+
|
|
5
|
+
export interface DetectResult {
|
|
6
|
+
framework: Framework;
|
|
7
|
+
signals: Framework[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PRIORITY: Framework[] = ['nuxt', 'next', 'svelte', 'vue', 'react'];
|
|
11
|
+
|
|
12
|
+
export async function detectFramework(source: FileSource): Promise<DetectResult> {
|
|
13
|
+
let deps: Record<string, string>;
|
|
14
|
+
try {
|
|
15
|
+
const raw = await source.read('package.json');
|
|
16
|
+
const pkg = JSON.parse(raw) as {
|
|
17
|
+
dependencies?: Record<string, string>;
|
|
18
|
+
devDependencies?: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
21
|
+
} catch {
|
|
22
|
+
return { framework: 'generic', signals: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const signals: Framework[] = [];
|
|
26
|
+
if ('nuxt' in deps || 'nuxt3' in deps) signals.push('nuxt');
|
|
27
|
+
if ('next' in deps) signals.push('next');
|
|
28
|
+
if ('svelte' in deps) signals.push('svelte');
|
|
29
|
+
if ('vue' in deps) signals.push('vue');
|
|
30
|
+
if ('react' in deps) signals.push('react');
|
|
31
|
+
|
|
32
|
+
const framework = PRIORITY.find((f) => signals.includes(f)) ?? 'generic';
|
|
33
|
+
return { framework, signals };
|
|
34
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { FileSource } from './fileSource';
|
|
2
|
+
|
|
3
|
+
export interface DiscoverOptions {
|
|
4
|
+
excludeTests?: boolean;
|
|
5
|
+
excludeStories?: boolean;
|
|
6
|
+
excludeDeclarations?: boolean;
|
|
7
|
+
excludeConfigs?: boolean;
|
|
8
|
+
/** Glob patterns to skip (`.archora.json#ignore`). Supports `**`/`*`/`?`. */
|
|
9
|
+
extraIgnoreGlobs?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DiscoverResult {
|
|
13
|
+
files: string[];
|
|
14
|
+
byExt: Record<string, number>;
|
|
15
|
+
skipped: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULTS: Required<DiscoverOptions> = {
|
|
19
|
+
excludeTests: true,
|
|
20
|
+
excludeStories: true,
|
|
21
|
+
excludeDeclarations: true,
|
|
22
|
+
excludeConfigs: true,
|
|
23
|
+
extraIgnoreGlobs: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const TEST_RE = /(^|\/)__(tests|mocks|snapshots)__\/|\.(test|spec)\.[cm]?[jt]sx?$/u;
|
|
27
|
+
const STORIES_RE = /\.stories\.([cm]?[jt]sx?|vue)$/u;
|
|
28
|
+
const DTS_RE = /\.d\.[cm]?ts$/u;
|
|
29
|
+
const CONFIG_RE =
|
|
30
|
+
/(^|\/)(vite|vitest|rollup|webpack|esbuild|jest|cypress|playwright|tailwind|postcss|svgo|babel|eslint|prettier|stylelint|tsup|rolldown|nuxt|astro)\.config\.[cm]?[jt]sx?$/u;
|
|
31
|
+
const TEST_DIR_RE = /(^|\/)(cypress|playwright|e2e|\.storybook)\//u;
|
|
32
|
+
|
|
33
|
+
export const ALWAYS_SKIP_DIRS: ReadonlySet<string> = new Set([
|
|
34
|
+
'node_modules',
|
|
35
|
+
'.git',
|
|
36
|
+
'dist',
|
|
37
|
+
'build',
|
|
38
|
+
'out',
|
|
39
|
+
'.cache',
|
|
40
|
+
'.next',
|
|
41
|
+
'.nuxt',
|
|
42
|
+
'coverage',
|
|
43
|
+
'.turbo',
|
|
44
|
+
'.svelte-kit',
|
|
45
|
+
'.output',
|
|
46
|
+
'.idea',
|
|
47
|
+
'.vscode',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export const TEST_LIKE_DIRS: ReadonlySet<string> = new Set([
|
|
51
|
+
'__tests__',
|
|
52
|
+
'__mocks__',
|
|
53
|
+
'__snapshots__',
|
|
54
|
+
'cypress',
|
|
55
|
+
'playwright',
|
|
56
|
+
'.storybook',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export async function discoverFiles(
|
|
60
|
+
source: FileSource,
|
|
61
|
+
options: DiscoverOptions = {},
|
|
62
|
+
): Promise<DiscoverResult> {
|
|
63
|
+
const opts = { ...DEFAULTS, ...options };
|
|
64
|
+
const extraRegexes = opts.extraIgnoreGlobs.map(globToRegex);
|
|
65
|
+
const all = await source.list();
|
|
66
|
+
const files: string[] = [];
|
|
67
|
+
const byExt: Record<string, number> = {};
|
|
68
|
+
let skipped = 0;
|
|
69
|
+
|
|
70
|
+
for (const f of all) {
|
|
71
|
+
if (shouldSkip(f, opts, extraRegexes)) {
|
|
72
|
+
skipped++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
files.push(f);
|
|
76
|
+
const ext = extOf(f);
|
|
77
|
+
byExt[ext] = (byExt[ext] ?? 0) + 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { files, byExt, skipped };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function shouldSkip(rel: string, opts: Required<DiscoverOptions>, extra: RegExp[]): boolean {
|
|
84
|
+
if (opts.excludeDeclarations && DTS_RE.test(rel)) return true;
|
|
85
|
+
if (opts.excludeTests && (TEST_RE.test(rel) || TEST_DIR_RE.test(rel))) return true;
|
|
86
|
+
if (opts.excludeStories && STORIES_RE.test(rel)) return true;
|
|
87
|
+
if (opts.excludeConfigs && CONFIG_RE.test(rel)) return true;
|
|
88
|
+
if (extra.length > 0) {
|
|
89
|
+
const norm = rel.replace(/^\.\//u, '');
|
|
90
|
+
for (const re of extra) if (re.test(norm)) return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function compileGlob(glob: string): RegExp {
|
|
96
|
+
return globToRegex(glob);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function globToRegex(glob: string): RegExp {
|
|
100
|
+
const trimmed = glob.replace(/^\.\//u, '');
|
|
101
|
+
let re = '';
|
|
102
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
103
|
+
const c = trimmed[i]!;
|
|
104
|
+
if (c === '*') {
|
|
105
|
+
const next = trimmed[i + 1];
|
|
106
|
+
if (next === '*') {
|
|
107
|
+
if (trimmed[i + 2] === '/') {
|
|
108
|
+
re += '(?:.*/)?';
|
|
109
|
+
i += 2;
|
|
110
|
+
} else {
|
|
111
|
+
re += '.*';
|
|
112
|
+
i += 1;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
re += '[^/]*';
|
|
116
|
+
}
|
|
117
|
+
} else if (c === '?') {
|
|
118
|
+
re += '[^/]';
|
|
119
|
+
} else if (/[.+^$(){}|\\[\]]/u.test(c)) {
|
|
120
|
+
re += `\\${c}`;
|
|
121
|
+
} else {
|
|
122
|
+
re += c;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return new RegExp(`^${re}$`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function extOf(p: string): string {
|
|
129
|
+
const i = p.lastIndexOf('.');
|
|
130
|
+
return i === -1 ? '' : p.slice(i).toLowerCase();
|
|
131
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ModuleId } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filenames that can't stand on their own - dozens of `index.ts` /
|
|
5
|
+
* `+page.svelte` files in a SvelteKit project, several `main.ts` in a
|
|
6
|
+
* monorepo. For these we prepend the parent directory so the user can tell
|
|
7
|
+
* which file we mean. Specific names (`UserService.ts`, `cn.ts`, ...) stay
|
|
8
|
+
* bare to keep insight titles compact.
|
|
9
|
+
*/
|
|
10
|
+
const AMBIGUOUS_BASE =
|
|
11
|
+
/^(?:index|main|app|root)\.[a-z]+$|^\+(?:page|layout|server|error)(?:\.[a-z]+)*$/u;
|
|
12
|
+
|
|
13
|
+
export function displayShortId(id: ModuleId): string {
|
|
14
|
+
const i = id.lastIndexOf('/');
|
|
15
|
+
if (i === -1) return id;
|
|
16
|
+
const base = id.slice(i + 1);
|
|
17
|
+
if (!AMBIGUOUS_BASE.test(base)) return base;
|
|
18
|
+
const parent = id.slice(0, i);
|
|
19
|
+
const j = parent.lastIndexOf('/');
|
|
20
|
+
return j === -1 ? id : `${parent.slice(j + 1)}/${base}`;
|
|
21
|
+
}
|