@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,136 @@
|
|
|
1
|
+
import type { FileSource } from './fileSource';
|
|
2
|
+
import type { ModuleId } from './types';
|
|
3
|
+
import type { Framework } from './detect';
|
|
4
|
+
|
|
5
|
+
// entry-point sources, in priority order:
|
|
6
|
+
// 1. .archora.json -> entryPoints
|
|
7
|
+
// 2. index.html <script type="module" src=...>
|
|
8
|
+
// 3. framework conventions (nuxt pages, next app/pages, sveltekit routes)
|
|
9
|
+
// 4. fallback heuristic: src/main.* or src/index.*
|
|
10
|
+
export interface DiscoverEntryPointsInput {
|
|
11
|
+
source: FileSource;
|
|
12
|
+
moduleIds: Iterable<ModuleId>;
|
|
13
|
+
configEntryPoints?: string[];
|
|
14
|
+
framework?: Framework;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function discoverEntryPoints(input: DiscoverEntryPointsInput): Promise<ModuleId[]> {
|
|
18
|
+
const set = new Set<ModuleId>();
|
|
19
|
+
const moduleSet = new Set(input.moduleIds);
|
|
20
|
+
|
|
21
|
+
if (input.configEntryPoints && input.configEntryPoints.length > 0) {
|
|
22
|
+
for (const p of input.configEntryPoints) {
|
|
23
|
+
for (const m of matchPath(p, moduleSet)) set.add(m);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const candidate of ['index.html', 'public/index.html']) {
|
|
28
|
+
if (await input.source.exists(candidate)) {
|
|
29
|
+
let html: string;
|
|
30
|
+
try {
|
|
31
|
+
html = await input.source.read(candidate);
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const src of parseModuleScriptSrcs(html)) {
|
|
36
|
+
const norm = src.replace(/^\.?\//u, '');
|
|
37
|
+
if (moduleSet.has(norm)) set.add(norm);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const re of frameworkEntryPatterns(input.framework)) {
|
|
43
|
+
for (const id of moduleSet) if (re.test(id)) set.add(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (set.size > 0) return [...set];
|
|
47
|
+
|
|
48
|
+
const patterns = [
|
|
49
|
+
/(^|\/)src\/main\.[jt]sx?$/u,
|
|
50
|
+
/(^|\/)src\/index\.[jt]sx?$/u,
|
|
51
|
+
/^main\.[jt]sx?$/u,
|
|
52
|
+
/^index\.[jt]sx?$/u,
|
|
53
|
+
];
|
|
54
|
+
for (const re of patterns) {
|
|
55
|
+
for (const id of moduleSet) if (re.test(id)) set.add(id);
|
|
56
|
+
}
|
|
57
|
+
return [...set];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// framework-convention entries: file-system routing/layouts that aren't statically imported
|
|
61
|
+
function frameworkEntryPatterns(framework: Framework | undefined): RegExp[] {
|
|
62
|
+
switch (framework) {
|
|
63
|
+
case 'nuxt':
|
|
64
|
+
return [/^app\.vue$/u, /^error\.vue$/u, /^pages\/.+\.vue$/u, /^layouts\/.+\.vue$/u];
|
|
65
|
+
case 'next':
|
|
66
|
+
return [
|
|
67
|
+
/^pages\/.+\.[jt]sx?$/u,
|
|
68
|
+
/^src\/pages\/.+\.[jt]sx?$/u,
|
|
69
|
+
/^app\/.+\/(page|layout|loading|error|not-found|template)\.[jt]sx?$/u,
|
|
70
|
+
/^app\/(page|layout|loading|error|not-found|template)\.[jt]sx?$/u,
|
|
71
|
+
/^src\/app\/.+\/(page|layout|loading|error|not-found|template)\.[jt]sx?$/u,
|
|
72
|
+
/^src\/app\/(page|layout|loading|error|not-found|template)\.[jt]sx?$/u,
|
|
73
|
+
];
|
|
74
|
+
case 'svelte':
|
|
75
|
+
// sveltekit only - plain svelte SPAs hit the fallback below
|
|
76
|
+
return [
|
|
77
|
+
/(^|\/)src\/routes\/.*\+page\.svelte$/u,
|
|
78
|
+
/(^|\/)src\/routes\/.*\+layout\.svelte$/u,
|
|
79
|
+
/(^|\/)src\/routes\/.*\+page\.[jt]s$/u,
|
|
80
|
+
/(^|\/)src\/routes\/.*\+layout\.[jt]s$/u,
|
|
81
|
+
/(^|\/)src\/routes\/.*\+server\.[jt]s$/u,
|
|
82
|
+
];
|
|
83
|
+
default:
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseModuleScriptSrcs(html: string): string[] {
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
const re = /<script\b[^>]*\stype\s*=\s*['"]module['"][^>]*>/giu;
|
|
91
|
+
let m: RegExpExecArray | null;
|
|
92
|
+
while ((m = re.exec(html)) !== null) {
|
|
93
|
+
const tag = m[0];
|
|
94
|
+
const srcMatch = tag.match(/\ssrc\s*=\s*['"]([^'"]+)['"]/iu);
|
|
95
|
+
if (srcMatch?.[1]) out.push(srcMatch[1]);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function matchPath(pattern: string, moduleSet: Set<ModuleId>): ModuleId[] {
|
|
101
|
+
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
102
|
+
return moduleSet.has(pattern) ? [pattern] : [];
|
|
103
|
+
}
|
|
104
|
+
const re = simpleGlobToRegex(pattern);
|
|
105
|
+
const out: ModuleId[] = [];
|
|
106
|
+
for (const m of moduleSet) if (re.test(m)) out.push(m);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function simpleGlobToRegex(glob: string): RegExp {
|
|
111
|
+
let re = '';
|
|
112
|
+
for (let i = 0; i < glob.length; i++) {
|
|
113
|
+
const c = glob[i]!;
|
|
114
|
+
if (c === '*') {
|
|
115
|
+
const next = glob[i + 1];
|
|
116
|
+
if (next === '*') {
|
|
117
|
+
if (glob[i + 2] === '/') {
|
|
118
|
+
re += '(?:.*/)?';
|
|
119
|
+
i += 2;
|
|
120
|
+
} else {
|
|
121
|
+
re += '.*';
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
re += '[^/]*';
|
|
126
|
+
}
|
|
127
|
+
} else if (c === '?') {
|
|
128
|
+
re += '[^/]';
|
|
129
|
+
} else if (/[.+^$(){}|\\[\]]/u.test(c)) {
|
|
130
|
+
re += `\\${c}`;
|
|
131
|
+
} else {
|
|
132
|
+
re += c;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return new RegExp(`^${re}$`);
|
|
136
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { DependencyEdge, ModuleId } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edge identity within a SCC. Format `from\u0001to` is shared with
|
|
5
|
+
* `graph/buildElements.ts` and `graph/index.ts` (`applyDiff` / `highlightPath`)
|
|
6
|
+
* so we can pass feedback edges to the Cytoscape layer without re-keying.
|
|
7
|
+
*/
|
|
8
|
+
export type EdgeKey = string;
|
|
9
|
+
|
|
10
|
+
export function edgeKey(from: ModuleId, to: ModuleId): EdgeKey {
|
|
11
|
+
return `${from}\u0001${to}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseEdgeKey(key: EdgeKey): { from: ModuleId; to: ModuleId } {
|
|
15
|
+
const i = key.indexOf('\u0001');
|
|
16
|
+
return { from: key.slice(0, i), to: key.slice(i + 1) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FeedbackArcSetResult {
|
|
20
|
+
/** Edges to remove to make the SCC acyclic (greedy approximation). */
|
|
21
|
+
feedback: Set<EdgeKey>;
|
|
22
|
+
/** Internal edges of the SCC (excluding type-only and duplicate parallel edges). */
|
|
23
|
+
internal: DependencyEdge[];
|
|
24
|
+
/** Topological order produced by Eades-Lin-Smyth. */
|
|
25
|
+
order: ModuleId[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Greedy Feedback Arc Set (Eades-Lin-Smyth 1993) on a single SCC. Type-only
|
|
30
|
+
* edges are excluded; `cycles.ts` already does the same at SCC detection.
|
|
31
|
+
* For a singleton SCC with self-loop the self-edge is the only feedback edge.
|
|
32
|
+
*/
|
|
33
|
+
export function feedbackArcSet(scc: ModuleId[], allEdges: DependencyEdge[]): FeedbackArcSetResult {
|
|
34
|
+
const inSet = new Set(scc);
|
|
35
|
+
|
|
36
|
+
// dedupe parallel edges by (from,to) for the algorithm; keep originals around
|
|
37
|
+
const internalByKey = new Map<EdgeKey, DependencyEdge>();
|
|
38
|
+
for (const e of allEdges) {
|
|
39
|
+
if (e.kind === 'type-only') continue;
|
|
40
|
+
if (!inSet.has(e.from) || !inSet.has(e.to)) continue;
|
|
41
|
+
const k = edgeKey(e.from, e.to);
|
|
42
|
+
if (!internalByKey.has(k)) internalByKey.set(k, e);
|
|
43
|
+
}
|
|
44
|
+
const internal = [...internalByKey.values()];
|
|
45
|
+
|
|
46
|
+
if (scc.length === 1) {
|
|
47
|
+
const only = scc[0]!;
|
|
48
|
+
const self = edgeKey(only, only);
|
|
49
|
+
return {
|
|
50
|
+
feedback: internalByKey.has(self) ? new Set([self]) : new Set(),
|
|
51
|
+
internal,
|
|
52
|
+
order: [...scc],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// adjacency on the SCC subgraph; self-loops handled separately (algorithm
|
|
57
|
+
// can't strip a vertex that points to itself without removing the loop first)
|
|
58
|
+
const out = new Map<ModuleId, Set<ModuleId>>();
|
|
59
|
+
const inn = new Map<ModuleId, Set<ModuleId>>();
|
|
60
|
+
const selfLoops = new Set<ModuleId>();
|
|
61
|
+
for (const m of scc) {
|
|
62
|
+
out.set(m, new Set());
|
|
63
|
+
inn.set(m, new Set());
|
|
64
|
+
}
|
|
65
|
+
for (const e of internal) {
|
|
66
|
+
if (e.from === e.to) {
|
|
67
|
+
selfLoops.add(e.from);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
out.get(e.from)!.add(e.to);
|
|
71
|
+
inn.get(e.to)!.add(e.from);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const remaining = new Set(scc);
|
|
75
|
+
const head: ModuleId[] = [];
|
|
76
|
+
const tail: ModuleId[] = [];
|
|
77
|
+
|
|
78
|
+
const removeVertex = (v: ModuleId): void => {
|
|
79
|
+
remaining.delete(v);
|
|
80
|
+
for (const w of out.get(v)!) inn.get(w)!.delete(v);
|
|
81
|
+
for (const w of inn.get(v)!) out.get(w)!.delete(v);
|
|
82
|
+
out.get(v)!.clear();
|
|
83
|
+
inn.get(v)!.clear();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
while (remaining.size > 0) {
|
|
87
|
+
let stripped = true;
|
|
88
|
+
while (stripped) {
|
|
89
|
+
stripped = false;
|
|
90
|
+
// strip sinks (out-degree 0) -> prepend to tail
|
|
91
|
+
for (const v of [...remaining]) {
|
|
92
|
+
if (out.get(v)!.size === 0) {
|
|
93
|
+
tail.unshift(v);
|
|
94
|
+
removeVertex(v);
|
|
95
|
+
stripped = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// strip sources (in-degree 0) -> append to head
|
|
99
|
+
for (const v of [...remaining]) {
|
|
100
|
+
if (inn.get(v)!.size === 0) {
|
|
101
|
+
head.push(v);
|
|
102
|
+
removeVertex(v);
|
|
103
|
+
stripped = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (remaining.size === 0) break;
|
|
108
|
+
// pick vertex with max delta = outDeg - inDeg, tie-break on id for stability
|
|
109
|
+
let best: ModuleId | null = null;
|
|
110
|
+
let bestDelta = -Infinity;
|
|
111
|
+
for (const v of remaining) {
|
|
112
|
+
const d = out.get(v)!.size - inn.get(v)!.size;
|
|
113
|
+
if (d > bestDelta || (d === bestDelta && best !== null && v < best)) {
|
|
114
|
+
best = v;
|
|
115
|
+
bestDelta = d;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
head.push(best!);
|
|
119
|
+
removeVertex(best!);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const order = [...head, ...tail];
|
|
123
|
+
const pos = new Map<ModuleId, number>();
|
|
124
|
+
order.forEach((id, i) => pos.set(id, i));
|
|
125
|
+
|
|
126
|
+
const feedback = new Set<EdgeKey>();
|
|
127
|
+
for (const e of internal) {
|
|
128
|
+
if (e.from === e.to) {
|
|
129
|
+
feedback.add(edgeKey(e.from, e.to));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const pf = pos.get(e.from);
|
|
133
|
+
const pt = pos.get(e.to);
|
|
134
|
+
if (pf !== undefined && pt !== undefined && pf > pt) {
|
|
135
|
+
feedback.add(edgeKey(e.from, e.to));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { feedback, internal, order };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Distinct elementary cycles broken by removing each feedback edge. Exact via
|
|
144
|
+
* Johnson 1975 for SCC up to `exactLimit`; otherwise an estimate capped at
|
|
145
|
+
* `pathCap` simple paths to→from with `partial: true` (the ranking by broken
|
|
146
|
+
* count stays meaningful in both modes).
|
|
147
|
+
*
|
|
148
|
+
* Both enumerations are super-polynomial in the worst case: a dense SCC has
|
|
149
|
+
* factorially many elementary cycles and simple paths, so an uncapped run never
|
|
150
|
+
* returns on a real-world barrel-shaped cluster (the whole reason a scan could
|
|
151
|
+
* hang). These results only RANK feedback edges for a suggested breakpoint, so
|
|
152
|
+
* a bounded estimate is sufficient. `cycleCap` bounds Johnson's output and
|
|
153
|
+
* `stepCap` bounds the simple-path DFS *work* (not just the paths found —
|
|
154
|
+
* `pathCap` alone leaves the dead-end exploration unbounded). Anything past a
|
|
155
|
+
* cap is reported via `partial` / `totalPartial`.
|
|
156
|
+
*/
|
|
157
|
+
export interface BrokenCyclesResult {
|
|
158
|
+
byEdge: Map<EdgeKey, { broken: number; partial: boolean }>;
|
|
159
|
+
totalCycles: number;
|
|
160
|
+
totalPartial: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const DEFAULT_EXACT_LIMIT = 20;
|
|
164
|
+
const DEFAULT_PATH_CAP = 200;
|
|
165
|
+
/** Max elementary cycles Johnson enumerates before reporting a partial result. */
|
|
166
|
+
const DEFAULT_CYCLE_CAP = 100_000;
|
|
167
|
+
/** Max DFS node-visits per simple-path count before reporting a partial result. */
|
|
168
|
+
const DEFAULT_PATH_STEP_CAP = 200_000;
|
|
169
|
+
|
|
170
|
+
export function countBrokenCycles(
|
|
171
|
+
scc: ModuleId[],
|
|
172
|
+
internalEdges: DependencyEdge[],
|
|
173
|
+
feedback: Set<EdgeKey>,
|
|
174
|
+
opts: { exactLimit?: number; pathCap?: number; cycleCap?: number; stepCap?: number } = {},
|
|
175
|
+
): BrokenCyclesResult {
|
|
176
|
+
const exactLimit = opts.exactLimit ?? DEFAULT_EXACT_LIMIT;
|
|
177
|
+
const pathCap = opts.pathCap ?? DEFAULT_PATH_CAP;
|
|
178
|
+
const cycleCap = opts.cycleCap ?? DEFAULT_CYCLE_CAP;
|
|
179
|
+
const stepCap = opts.stepCap ?? DEFAULT_PATH_STEP_CAP;
|
|
180
|
+
|
|
181
|
+
// adjacency on internal edges (deduped)
|
|
182
|
+
const adj = new Map<ModuleId, Set<ModuleId>>();
|
|
183
|
+
for (const m of scc) adj.set(m, new Set());
|
|
184
|
+
for (const e of internalEdges) adj.get(e.from)?.add(e.to);
|
|
185
|
+
|
|
186
|
+
if (scc.length <= exactLimit) {
|
|
187
|
+
const { cycles, capped } = enumerateElementaryCycles(scc, adj, cycleCap);
|
|
188
|
+
const byEdge = new Map<EdgeKey, { broken: number; partial: boolean }>();
|
|
189
|
+
for (const k of feedback) byEdge.set(k, { broken: 0, partial: capped });
|
|
190
|
+
for (const cyc of cycles) {
|
|
191
|
+
for (let i = 0; i < cyc.length; i++) {
|
|
192
|
+
const a = cyc[i]!;
|
|
193
|
+
const b = cyc[(i + 1) % cyc.length]!;
|
|
194
|
+
const k = edgeKey(a, b);
|
|
195
|
+
if (feedback.has(k)) {
|
|
196
|
+
const cur = byEdge.get(k)!;
|
|
197
|
+
cur.broken++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// When enumeration was capped the counts are lower bounds, so the total is
|
|
202
|
+
// unknown (-1, matching the large-SCC convention) and the result partial.
|
|
203
|
+
return { byEdge, totalCycles: capped ? -1 : cycles.length, totalPartial: capped };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// estimate via simple-path enumeration with cap; for each feedback edge a→b
|
|
207
|
+
// count distinct simple paths b→a in the SCC (each = one elementary cycle
|
|
208
|
+
// through that edge). Total cycle count is undefined for large SCC.
|
|
209
|
+
const byEdge = new Map<EdgeKey, { broken: number; partial: boolean }>();
|
|
210
|
+
for (const k of feedback) {
|
|
211
|
+
const { from, to } = parseEdgeKey(k);
|
|
212
|
+
const { count, capped } = countSimplePaths(to, from, adj, pathCap, stepCap);
|
|
213
|
+
byEdge.set(k, { broken: count, partial: capped });
|
|
214
|
+
}
|
|
215
|
+
return { byEdge, totalCycles: -1, totalPartial: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Johnson 1975: enumerate elementary cycles in a digraph. Used only for SCC up
|
|
219
|
+
// to ~20 nodes; in the worst case (complete digraph K_20) that's astronomically
|
|
220
|
+
// many cycles, so enumeration stops once `cap` cycles are found and reports
|
|
221
|
+
// `capped` — real SCCs are far sparser and finish well under the cap.
|
|
222
|
+
function enumerateElementaryCycles(
|
|
223
|
+
vertices: ModuleId[],
|
|
224
|
+
adj: Map<ModuleId, Set<ModuleId>>,
|
|
225
|
+
cap: number,
|
|
226
|
+
): { cycles: ModuleId[][]; capped: boolean } {
|
|
227
|
+
const cycles: ModuleId[][] = [];
|
|
228
|
+
let capped = false;
|
|
229
|
+
const blocked = new Set<ModuleId>();
|
|
230
|
+
const blockMap = new Map<ModuleId, Set<ModuleId>>();
|
|
231
|
+
const stack: ModuleId[] = [];
|
|
232
|
+
|
|
233
|
+
const unblock = (u: ModuleId): void => {
|
|
234
|
+
blocked.delete(u);
|
|
235
|
+
const set = blockMap.get(u);
|
|
236
|
+
if (!set) return;
|
|
237
|
+
for (const w of [...set]) {
|
|
238
|
+
set.delete(w);
|
|
239
|
+
if (blocked.has(w)) unblock(w);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// sort vertices for stable output; Johnson works on any ordering
|
|
244
|
+
const sorted = [...vertices].sort();
|
|
245
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
246
|
+
if (capped) break;
|
|
247
|
+
const start = sorted[i]!;
|
|
248
|
+
// restrict to subgraph induced by sorted[i..]
|
|
249
|
+
const sub = new Set(sorted.slice(i));
|
|
250
|
+
blocked.clear();
|
|
251
|
+
blockMap.clear();
|
|
252
|
+
for (const v of sub) blockMap.set(v, new Set());
|
|
253
|
+
|
|
254
|
+
const circuit = (v: ModuleId): boolean => {
|
|
255
|
+
if (cycles.length >= cap) {
|
|
256
|
+
capped = true;
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
let found = false;
|
|
260
|
+
stack.push(v);
|
|
261
|
+
blocked.add(v);
|
|
262
|
+
for (const w of adj.get(v) ?? []) {
|
|
263
|
+
if (capped) break;
|
|
264
|
+
if (!sub.has(w)) continue;
|
|
265
|
+
if (w === start) {
|
|
266
|
+
if (cycles.length >= cap) {
|
|
267
|
+
capped = true;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
cycles.push([...stack]);
|
|
271
|
+
found = true;
|
|
272
|
+
} else if (!blocked.has(w)) {
|
|
273
|
+
if (circuit(w)) found = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (found) {
|
|
277
|
+
unblock(v);
|
|
278
|
+
} else {
|
|
279
|
+
for (const w of adj.get(v) ?? []) {
|
|
280
|
+
if (!sub.has(w)) continue;
|
|
281
|
+
blockMap.get(w)?.add(v);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
stack.pop();
|
|
285
|
+
return found;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
circuit(start);
|
|
289
|
+
}
|
|
290
|
+
return { cycles, capped };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Count simple paths source→target, bounded on TWO axes: `cap` limits the paths
|
|
294
|
+
// counted, and `stepCap` limits total DFS node-visits. The step budget is the
|
|
295
|
+
// load-bearing one — counting simple paths is #P-complete, so a path cap alone
|
|
296
|
+
// leaves the exponential dead-end exploration unbounded and a dense SCC hangs.
|
|
297
|
+
function countSimplePaths(
|
|
298
|
+
source: ModuleId,
|
|
299
|
+
target: ModuleId,
|
|
300
|
+
adj: Map<ModuleId, Set<ModuleId>>,
|
|
301
|
+
cap: number,
|
|
302
|
+
stepCap: number,
|
|
303
|
+
): { count: number; capped: boolean } {
|
|
304
|
+
let count = 0;
|
|
305
|
+
let capped = false;
|
|
306
|
+
let steps = 0;
|
|
307
|
+
const visited = new Set<ModuleId>();
|
|
308
|
+
const dfs = (v: ModuleId): void => {
|
|
309
|
+
if (count >= cap || steps >= stepCap) {
|
|
310
|
+
capped = true;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
steps++;
|
|
314
|
+
if (v === target) {
|
|
315
|
+
count++;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
visited.add(v);
|
|
319
|
+
for (const w of adj.get(v) ?? []) {
|
|
320
|
+
if (count >= cap || steps >= stepCap) {
|
|
321
|
+
capped = true;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
if (visited.has(w)) continue;
|
|
325
|
+
dfs(w);
|
|
326
|
+
}
|
|
327
|
+
visited.delete(v);
|
|
328
|
+
};
|
|
329
|
+
if (source === target) return { count: 1, capped: false };
|
|
330
|
+
dfs(source);
|
|
331
|
+
return { count, capped };
|
|
332
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface FileSource {
|
|
2
|
+
rootPath: string;
|
|
3
|
+
list(): Promise<string[]>;
|
|
4
|
+
read(relativePath: string): Promise<string>;
|
|
5
|
+
readMany?(relativePaths: readonly string[]): Promise<Record<string, string>>;
|
|
6
|
+
/** True iff `relativePath` is a regular file (not a directory). */
|
|
7
|
+
exists(relativePath: string): Promise<boolean>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ModuleId, ModuleMetrics, ModuleNode } from './types';
|
|
2
|
+
|
|
3
|
+
export interface RankHotZonesInput {
|
|
4
|
+
modules: ModuleNode[];
|
|
5
|
+
metrics: Record<ModuleId, ModuleMetrics>;
|
|
6
|
+
topN?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function rankHotZones(input: RankHotZonesInput): ModuleId[] {
|
|
10
|
+
const { modules, metrics, topN = 10 } = input;
|
|
11
|
+
const candidates = modules
|
|
12
|
+
.filter((m) => !m.isInfra)
|
|
13
|
+
.map((m) => ({ id: m.id, score: metrics[m.id]?.hotnessScore ?? 0 }))
|
|
14
|
+
.filter((x) => x.score > 0);
|
|
15
|
+
candidates.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
16
|
+
return candidates.slice(0, topN).map((c) => c.id);
|
|
17
|
+
}
|