@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.
Files changed (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. 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
+ }