newpr 1.0.24 → 1.0.25
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/package.json +1 -1
- package/src/stack/delta.test.ts +5 -3
- package/src/stack/delta.ts +1 -42
- package/src/stack/feasibility.test.ts +111 -0
- package/src/stack/feasibility.ts +40 -4
- package/src/stack/import-deps.test.ts +200 -1
- package/src/stack/import-deps.ts +114 -22
- package/src/stack/quality-gate.ts +10 -18
- package/src/web/client/components/StackDagView.tsx +51 -35
- package/src/web/client/hooks/useStack.ts +27 -2
- package/src/web/client/panels/StackPanel.tsx +167 -5
- package/src/web/server/routes.ts +3 -2
- package/src/web/server/stack-manager.ts +113 -9
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
package/src/stack/delta.test.ts
CHANGED
|
@@ -119,7 +119,7 @@ describe("extractDeltas", () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
describe("computeGroupStats", () => {
|
|
122
|
-
test("uses
|
|
122
|
+
test("uses first dependency as baseline (matching publish base branch) for multi-parent DAG groups", async () => {
|
|
123
123
|
const repoPath = mkdtempSync(join(tmpdir(), "group-stats-dag-test-"));
|
|
124
124
|
try {
|
|
125
125
|
await Bun.$`git init ${repoPath}`.quiet();
|
|
@@ -180,14 +180,16 @@ describe("computeGroupStats", () => {
|
|
|
180
180
|
expect(stats.get("A")?.deletions).toBe(1);
|
|
181
181
|
expect(stats.get("B")?.additions).toBe(1);
|
|
182
182
|
expect(stats.get("B")?.deletions).toBe(1);
|
|
183
|
-
expect(stats.get("C")?.additions).toBe(
|
|
184
|
-
expect(stats.get("C")?.deletions).toBe(
|
|
183
|
+
expect(stats.get("C")?.additions).toBe(2);
|
|
184
|
+
expect(stats.get("C")?.deletions).toBe(2);
|
|
185
185
|
expect(detailed.get("A")?.file_stats["a.txt"]?.additions).toBe(1);
|
|
186
186
|
expect(detailed.get("A")?.file_stats["a.txt"]?.deletions).toBe(1);
|
|
187
187
|
expect(detailed.get("B")?.file_stats["b.txt"]?.additions).toBe(1);
|
|
188
188
|
expect(detailed.get("B")?.file_stats["b.txt"]?.deletions).toBe(1);
|
|
189
189
|
expect(detailed.get("C")?.file_stats["c.txt"]?.additions).toBe(1);
|
|
190
190
|
expect(detailed.get("C")?.file_stats["c.txt"]?.deletions).toBe(1);
|
|
191
|
+
expect(detailed.get("C")?.file_stats["b.txt"]?.additions).toBe(1);
|
|
192
|
+
expect(detailed.get("C")?.file_stats["b.txt"]?.deletions).toBe(1);
|
|
191
193
|
} finally {
|
|
192
194
|
rmSync(repoPath, { recursive: true, force: true });
|
|
193
195
|
}
|
package/src/stack/delta.ts
CHANGED
|
@@ -196,48 +196,7 @@ async function resolveParentTree(
|
|
|
196
196
|
return resolveTree(repoPath, baseSha);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
return expectedTrees.get(parentIds[0]!) ?? resolveTree(repoPath, baseSha);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const parentTrees = parentIds.map((p) => expectedTrees.get(p)).filter((t): t is string => Boolean(t));
|
|
204
|
-
if (parentTrees.length === 0) return resolveTree(repoPath, baseSha);
|
|
205
|
-
if (parentTrees.length === 1) return parentTrees[0]!;
|
|
206
|
-
|
|
207
|
-
const syntheticCommitByTree = new Map<string, string>();
|
|
208
|
-
const ensureSyntheticCommit = async (treeSha: string): Promise<string | null> => {
|
|
209
|
-
const existing = syntheticCommitByTree.get(treeSha);
|
|
210
|
-
if (existing) return existing;
|
|
211
|
-
|
|
212
|
-
const result = await Bun.$`git -C ${repoPath} commit-tree ${treeSha} -p ${baseSha} -m "newpr synthetic parent"`.quiet().nothrow();
|
|
213
|
-
if (result.exitCode !== 0) return null;
|
|
214
|
-
const commitSha = result.stdout.toString().trim();
|
|
215
|
-
if (!commitSha) return null;
|
|
216
|
-
syntheticCommitByTree.set(treeSha, commitSha);
|
|
217
|
-
return commitSha;
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
let mergedTree = parentTrees[0]!;
|
|
221
|
-
let mergedCommit = await ensureSyntheticCommit(mergedTree);
|
|
222
|
-
if (!mergedCommit) return null;
|
|
223
|
-
|
|
224
|
-
for (let i = 1; i < parentTrees.length; i++) {
|
|
225
|
-
const nextCommit = await ensureSyntheticCommit(parentTrees[i]!);
|
|
226
|
-
if (!nextCommit) return null;
|
|
227
|
-
|
|
228
|
-
const mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedCommit} ${nextCommit}`.quiet().nothrow();
|
|
229
|
-
if (mergeResult.exitCode !== 0) return null;
|
|
230
|
-
const nextTree = mergeResult.stdout.toString().trim().split("\n")[0]?.trim();
|
|
231
|
-
if (!nextTree) return null;
|
|
232
|
-
mergedTree = nextTree;
|
|
233
|
-
|
|
234
|
-
const mergedCommitResult = await Bun.$`git -C ${repoPath} commit-tree ${mergedTree} -p ${mergedCommit} -p ${nextCommit} -m "newpr synthetic merged parent"`.quiet().nothrow();
|
|
235
|
-
if (mergedCommitResult.exitCode !== 0) return null;
|
|
236
|
-
mergedCommit = mergedCommitResult.stdout.toString().trim();
|
|
237
|
-
if (!mergedCommit) return null;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return mergedTree;
|
|
199
|
+
return expectedTrees.get(parentIds[0]!) ?? resolveTree(repoPath, baseSha);
|
|
241
200
|
}
|
|
242
201
|
|
|
243
202
|
export interface StackGroupComputedStats {
|
|
@@ -181,4 +181,115 @@ describe("checkFeasibility", () => {
|
|
|
181
181
|
const order = result.ordered_group_ids!;
|
|
182
182
|
expect(order.indexOf("group-a")).toBeLessThan(order.indexOf("group-b"));
|
|
183
183
|
});
|
|
184
|
+
|
|
185
|
+
test("dependency edge wins over conflicting path-order edge", () => {
|
|
186
|
+
const deltas: DeltaEntry[] = [
|
|
187
|
+
makeDelta("c1", "base", [{ status: "A", path: "ui.tsx" }]),
|
|
188
|
+
makeDelta("c2", "c1", [{ status: "A", path: "lib.ts" }]),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const ownership = new Map([
|
|
192
|
+
["ui.tsx", "ui-group"],
|
|
193
|
+
["lib.ts", "lib-group"],
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
const declaredDeps = new Map([
|
|
197
|
+
["ui-group", ["lib-group"]],
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
|
|
201
|
+
|
|
202
|
+
expect(result.feasible).toBe(true);
|
|
203
|
+
const order = result.ordered_group_ids!;
|
|
204
|
+
expect(order.indexOf("lib-group")).toBeLessThan(order.indexOf("ui-group"));
|
|
205
|
+
expect(result.dependency_edges).toBeDefined();
|
|
206
|
+
const depEdge = result.dependency_edges!.find(
|
|
207
|
+
(e) => e.from === "lib-group" && e.to === "ui-group",
|
|
208
|
+
);
|
|
209
|
+
expect(depEdge).toBeDefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("dependency edge preserved when path-order creates opposing cycle", () => {
|
|
213
|
+
const deltas: DeltaEntry[] = [
|
|
214
|
+
makeDelta("c1", "base", [
|
|
215
|
+
{ status: "A", path: "ui.tsx" },
|
|
216
|
+
{ status: "A", path: "hook.ts" },
|
|
217
|
+
]),
|
|
218
|
+
makeDelta("c2", "c1", [{ status: "M", path: "ui.tsx" }]),
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
const ownership = new Map([
|
|
222
|
+
["ui.tsx", "ui-group"],
|
|
223
|
+
["hook.ts", "lib-group"],
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
const declaredDeps = new Map([
|
|
227
|
+
["ui-group", ["lib-group"]],
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
|
|
231
|
+
|
|
232
|
+
expect(result.feasible).toBe(true);
|
|
233
|
+
const order = result.ordered_group_ids!;
|
|
234
|
+
expect(order.indexOf("lib-group")).toBeLessThan(order.indexOf("ui-group"));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("mutual dependency edges both survive when no path-order edges exist", () => {
|
|
238
|
+
const deltas: DeltaEntry[] = [
|
|
239
|
+
makeDelta("c1", "base", [
|
|
240
|
+
{ status: "A", path: "a.ts" },
|
|
241
|
+
{ status: "A", path: "b.ts" },
|
|
242
|
+
]),
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const ownership = new Map([
|
|
246
|
+
["a.ts", "group-a"],
|
|
247
|
+
["b.ts", "group-b"],
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const declaredDeps = new Map([
|
|
251
|
+
["group-a", ["group-b"]],
|
|
252
|
+
["group-b", ["group-a"]],
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
|
|
256
|
+
|
|
257
|
+
expect(result.feasible).toBe(true);
|
|
258
|
+
expect(result.ordered_group_ids).toHaveLength(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("layer hint yields to import dep when they conflict", () => {
|
|
262
|
+
const deltas: DeltaEntry[] = [
|
|
263
|
+
makeDelta("c1", "base", [
|
|
264
|
+
{ status: "A", path: "api.ts" },
|
|
265
|
+
{ status: "A", path: "ui.tsx" },
|
|
266
|
+
{ status: "A", path: "types.ts" },
|
|
267
|
+
]),
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const ownership = new Map([
|
|
271
|
+
["api.ts", "api-group"],
|
|
272
|
+
["ui.tsx", "ui-group"],
|
|
273
|
+
["types.ts", "types-group"],
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const declaredDeps = new Map([
|
|
277
|
+
["ui-group", ["api-group"]],
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
const layerHints = new Map([
|
|
281
|
+
["api-group", ["ui-group"]],
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
const result = checkFeasibility({
|
|
285
|
+
deltas,
|
|
286
|
+
ownership,
|
|
287
|
+
declared_deps: declaredDeps,
|
|
288
|
+
layer_hints: layerHints,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.feasible).toBe(true);
|
|
292
|
+
const order = result.ordered_group_ids!;
|
|
293
|
+
expect(order.indexOf("api-group")).toBeLessThan(order.indexOf("ui-group"));
|
|
294
|
+
});
|
|
184
295
|
});
|
package/src/stack/feasibility.ts
CHANGED
|
@@ -9,10 +9,11 @@ interface FeasibilityInput {
|
|
|
9
9
|
deltas: DeltaEntry[];
|
|
10
10
|
ownership: Map<string, string>;
|
|
11
11
|
declared_deps?: Map<string, string[]>;
|
|
12
|
+
layer_hints?: Map<string, string[]>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function checkFeasibility(input: FeasibilityInput): FeasibilityResult {
|
|
15
|
-
const { deltas, ownership, declared_deps } = input;
|
|
16
|
+
const { deltas, ownership, declared_deps, layer_hints } = input;
|
|
16
17
|
|
|
17
18
|
const allGroups = new Set<string>(ownership.values());
|
|
18
19
|
if (allGroups.size <= 1) {
|
|
@@ -28,6 +29,9 @@ export function checkFeasibility(input: FeasibilityInput): FeasibilityResult {
|
|
|
28
29
|
if (declared_deps) {
|
|
29
30
|
addDeclaredDepEdges(declared_deps, allGroups, edges);
|
|
30
31
|
}
|
|
32
|
+
if (layer_hints) {
|
|
33
|
+
addLayerHintEdges(layer_hints, allGroups, edges);
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
const deduped = deduplicateEdges(edges);
|
|
33
37
|
const acyclic = breakAllCycles(deduped);
|
|
@@ -37,11 +41,22 @@ export function checkFeasibility(input: FeasibilityInput): FeasibilityResult {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
function breakAllCycles(edges: ConstraintEdge[]): ConstraintEdge[] {
|
|
40
|
-
const
|
|
44
|
+
const edgeMap = new Map<string, ConstraintEdge>();
|
|
45
|
+
for (const e of edges) edgeMap.set(`${e.from}→${e.to}`, e);
|
|
46
|
+
|
|
41
47
|
const withoutMutual: ConstraintEdge[] = [];
|
|
42
48
|
for (const edge of edges) {
|
|
43
49
|
const reverseKey = `${edge.to}→${edge.from}`;
|
|
44
|
-
|
|
50
|
+
const reverse = edgeMap.get(reverseKey);
|
|
51
|
+
if (!reverse) {
|
|
52
|
+
// No mutual conflict — keep
|
|
53
|
+
withoutMutual.push(edge);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Mutual conflict: drop the LESS important edge.
|
|
57
|
+
// dependency > path-order (dependency is build-critical)
|
|
58
|
+
if (edge.kind === "path-order" && reverse.kind === "dependency") continue;
|
|
59
|
+
// Same kind: keep both, let breakRemainingCycles handle it
|
|
45
60
|
withoutMutual.push(edge);
|
|
46
61
|
}
|
|
47
62
|
return breakRemainingCycles(withoutMutual);
|
|
@@ -77,7 +92,7 @@ function breakRemainingCycles(edges: ConstraintEdge[]): ConstraintEdge[] {
|
|
|
77
92
|
|
|
78
93
|
const prioritized = [...edges].sort((a, b) => {
|
|
79
94
|
const kindScore = (e: ConstraintEdge) =>
|
|
80
|
-
e.kind === "
|
|
95
|
+
e.kind === "dependency" ? 0 : e.kind === "path-order" ? 1 : 2;
|
|
81
96
|
return kindScore(a) - kindScore(b);
|
|
82
97
|
});
|
|
83
98
|
|
|
@@ -174,6 +189,27 @@ function addDeclaredDepEdges(
|
|
|
174
189
|
}
|
|
175
190
|
}
|
|
176
191
|
|
|
192
|
+
function addLayerHintEdges(
|
|
193
|
+
layerHints: Map<string, string[]>,
|
|
194
|
+
allGroups: Set<string>,
|
|
195
|
+
edges: ConstraintEdge[],
|
|
196
|
+
): void {
|
|
197
|
+
for (const [groupId, deps] of layerHints) {
|
|
198
|
+
if (!allGroups.has(groupId)) continue;
|
|
199
|
+
|
|
200
|
+
for (const depGroupId of deps) {
|
|
201
|
+
if (!allGroups.has(depGroupId)) continue;
|
|
202
|
+
if (groupId === depGroupId) continue;
|
|
203
|
+
|
|
204
|
+
edges.push({
|
|
205
|
+
from: depGroupId,
|
|
206
|
+
to: groupId,
|
|
207
|
+
kind: "path-order",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
177
213
|
function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
|
|
178
214
|
const seen = new Set<string>();
|
|
179
215
|
const result: ConstraintEdge[] = [];
|
|
@@ -2,7 +2,7 @@ import { describe, test, expect, afterAll } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { analyzeImportDependencies } from "./import-deps.ts";
|
|
5
|
+
import { analyzeImportDependencies, rebuildGroupDeps, mergeImportCycleGroups } from "./import-deps.ts";
|
|
6
6
|
|
|
7
7
|
const tmpDirs: string[] = [];
|
|
8
8
|
|
|
@@ -251,4 +251,203 @@ describe("analyzeImportDependencies", () => {
|
|
|
251
251
|
expect(result.fileDeps.get("src/b.ts")).toContain("src/a.ts");
|
|
252
252
|
expect(result.groupDeps.size).toBe(0);
|
|
253
253
|
});
|
|
254
|
+
|
|
255
|
+
test("FSD feature: ui/ imports api/ across groups", async () => {
|
|
256
|
+
const repo = makeTmpRepo();
|
|
257
|
+
await initRepo(repo);
|
|
258
|
+
await commitFiles(repo, { "dummy.txt": "init" }, "init");
|
|
259
|
+
|
|
260
|
+
const headSha = await commitFiles(repo, {
|
|
261
|
+
"src/features/chat-trace/api/use-chat-trace-summary-query.ts":
|
|
262
|
+
"export function useChatTraceSummaryQuery() { return {}; }",
|
|
263
|
+
"src/features/chat-trace/ui/chat-trace-table/chat-trace-table.body.tsx": [
|
|
264
|
+
"import {",
|
|
265
|
+
" useChatTraceSummaryQuery,",
|
|
266
|
+
"} from '../../api/use-chat-trace-summary-query';",
|
|
267
|
+
"",
|
|
268
|
+
"export function Body() { return useChatTraceSummaryQuery(); }",
|
|
269
|
+
].join("\n"),
|
|
270
|
+
}, "fsd feature");
|
|
271
|
+
|
|
272
|
+
const ownership = new Map([
|
|
273
|
+
["src/features/chat-trace/api/use-chat-trace-summary-query.ts", "API"],
|
|
274
|
+
["src/features/chat-trace/ui/chat-trace-table/chat-trace-table.body.tsx", "UI"],
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const result = await analyzeImportDependencies(repo, headSha, [...ownership.keys()], ownership);
|
|
278
|
+
|
|
279
|
+
expect(result.fileDeps.get("src/features/chat-trace/ui/chat-trace-table/chat-trace-table.body.tsx"))
|
|
280
|
+
.toContain("src/features/chat-trace/api/use-chat-trace-summary-query.ts");
|
|
281
|
+
expect(result.groupDeps.get("UI")).toContain("API");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("rebuildGroupDeps", () => {
|
|
286
|
+
test("recomputes group deps after file reassignment", () => {
|
|
287
|
+
const fileDeps = new Map([
|
|
288
|
+
["src/ui/button.ts", ["src/api/query.ts"]],
|
|
289
|
+
["src/api/query.ts", ["src/types/schema.ts"]],
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
const originalOwnership = new Map([
|
|
293
|
+
["src/ui/button.ts", "UI"],
|
|
294
|
+
["src/api/query.ts", "API"],
|
|
295
|
+
["src/types/schema.ts", "Types"],
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
const original = rebuildGroupDeps(fileDeps, originalOwnership);
|
|
299
|
+
expect(original.get("UI")).toContain("API");
|
|
300
|
+
expect(original.get("API")).toContain("Types");
|
|
301
|
+
|
|
302
|
+
const reassignedOwnership = new Map([
|
|
303
|
+
["src/ui/button.ts", "Feature"],
|
|
304
|
+
["src/api/query.ts", "Feature"],
|
|
305
|
+
["src/types/schema.ts", "Types"],
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
const rebuilt = rebuildGroupDeps(fileDeps, reassignedOwnership);
|
|
309
|
+
expect(rebuilt.has("UI")).toBe(false);
|
|
310
|
+
expect(rebuilt.has("API")).toBe(false);
|
|
311
|
+
expect(rebuilt.get("Feature")).toContain("Types");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("drops stale edges when files move to same group", () => {
|
|
315
|
+
const fileDeps = new Map([
|
|
316
|
+
["src/a.ts", ["src/b.ts"]],
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const before = rebuildGroupDeps(fileDeps, new Map([
|
|
320
|
+
["src/a.ts", "GroupA"],
|
|
321
|
+
["src/b.ts", "GroupB"],
|
|
322
|
+
]));
|
|
323
|
+
expect(before.get("GroupA")).toContain("GroupB");
|
|
324
|
+
|
|
325
|
+
const after = rebuildGroupDeps(fileDeps, new Map([
|
|
326
|
+
["src/a.ts", "Merged"],
|
|
327
|
+
["src/b.ts", "Merged"],
|
|
328
|
+
]));
|
|
329
|
+
expect(after.size).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("mergeImportCycleGroups", () => {
|
|
334
|
+
test("merges groups in a pairwise cycle (A↔B)", () => {
|
|
335
|
+
const groups = [
|
|
336
|
+
{ name: "GroupA", files: ["a.ts"], description: "Group A", key_changes: ["added a.ts"] },
|
|
337
|
+
{ name: "GroupB", files: ["b.ts"], description: "Group B", key_changes: ["added b.ts"] },
|
|
338
|
+
{ name: "GroupC", files: ["c.ts"], description: "Group C", key_changes: ["added c.ts"] },
|
|
339
|
+
];
|
|
340
|
+
const ownership = new Map([
|
|
341
|
+
["a.ts", "GroupA"],
|
|
342
|
+
["b.ts", "GroupB"],
|
|
343
|
+
["c.ts", "GroupC"],
|
|
344
|
+
]);
|
|
345
|
+
// A→B and B→A form a cycle; C depends on A (no cycle)
|
|
346
|
+
const groupDeps = new Map([
|
|
347
|
+
["GroupA", ["GroupB"]],
|
|
348
|
+
["GroupB", ["GroupA"]],
|
|
349
|
+
["GroupC", ["GroupA"]],
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const result = mergeImportCycleGroups(groups, ownership, groupDeps);
|
|
353
|
+
|
|
354
|
+
// A and B merged into one group, C remains
|
|
355
|
+
expect(result.groups).toHaveLength(2);
|
|
356
|
+
expect(result.mergedCycles).toHaveLength(1);
|
|
357
|
+
expect(result.mergedCycles[0]).toHaveLength(2);
|
|
358
|
+
|
|
359
|
+
// Survivor group has files from both A and B
|
|
360
|
+
const survivor = result.groups.find((g) => g.files.includes("a.ts"))!;
|
|
361
|
+
expect(survivor).toBeDefined();
|
|
362
|
+
expect(survivor.files).toContain("b.ts");
|
|
363
|
+
expect(survivor.key_changes).toContain("added a.ts");
|
|
364
|
+
expect(survivor.key_changes).toContain("added b.ts");
|
|
365
|
+
|
|
366
|
+
// Ownership updated: b.ts now points to survivor
|
|
367
|
+
expect(result.ownership.get("b.ts")).toBe(survivor.name);
|
|
368
|
+
expect(result.ownership.get("a.ts")).toBe(survivor.name);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("merges groups in a transitive cycle (A→B→C→A)", () => {
|
|
372
|
+
const groups = [
|
|
373
|
+
{ name: "GroupA", files: ["a.ts"], description: "Group A" },
|
|
374
|
+
{ name: "GroupB", files: ["b.ts"], description: "Group B" },
|
|
375
|
+
{ name: "GroupC", files: ["c.ts"], description: "Group C" },
|
|
376
|
+
{ name: "GroupD", files: ["d.ts"], description: "Group D" },
|
|
377
|
+
];
|
|
378
|
+
const ownership = new Map([
|
|
379
|
+
["a.ts", "GroupA"],
|
|
380
|
+
["b.ts", "GroupB"],
|
|
381
|
+
["c.ts", "GroupC"],
|
|
382
|
+
["d.ts", "GroupD"],
|
|
383
|
+
]);
|
|
384
|
+
// A→B→C→A forms a cycle; D is standalone
|
|
385
|
+
const groupDeps = new Map([
|
|
386
|
+
["GroupA", ["GroupB"]],
|
|
387
|
+
["GroupB", ["GroupC"]],
|
|
388
|
+
["GroupC", ["GroupA"]],
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
const result = mergeImportCycleGroups(groups, ownership, groupDeps);
|
|
392
|
+
|
|
393
|
+
// A, B, C merged into one group; D remains
|
|
394
|
+
expect(result.groups).toHaveLength(2);
|
|
395
|
+
expect(result.mergedCycles).toHaveLength(1);
|
|
396
|
+
expect(result.mergedCycles[0]).toHaveLength(3);
|
|
397
|
+
|
|
398
|
+
const survivor = result.groups.find((g) => g.files.includes("a.ts"))!;
|
|
399
|
+
expect(survivor.files).toContain("b.ts");
|
|
400
|
+
expect(survivor.files).toContain("c.ts");
|
|
401
|
+
expect(survivor.files).not.toContain("d.ts");
|
|
402
|
+
|
|
403
|
+
// All ownership for cycle members points to survivor
|
|
404
|
+
expect(result.ownership.get("a.ts")).toBe(survivor.name);
|
|
405
|
+
expect(result.ownership.get("b.ts")).toBe(survivor.name);
|
|
406
|
+
expect(result.ownership.get("c.ts")).toBe(survivor.name);
|
|
407
|
+
expect(result.ownership.get("d.ts")).toBe("GroupD");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("no merge when no cycles exist", () => {
|
|
411
|
+
const groups = [
|
|
412
|
+
{ name: "GroupA", files: ["a.ts"], description: "Group A" },
|
|
413
|
+
{ name: "GroupB", files: ["b.ts"], description: "Group B" },
|
|
414
|
+
{ name: "GroupC", files: ["c.ts"], description: "Group C" },
|
|
415
|
+
];
|
|
416
|
+
const ownership = new Map([
|
|
417
|
+
["a.ts", "GroupA"],
|
|
418
|
+
["b.ts", "GroupB"],
|
|
419
|
+
["c.ts", "GroupC"],
|
|
420
|
+
]);
|
|
421
|
+
// Linear chain: A→B→C (no cycles)
|
|
422
|
+
const groupDeps = new Map([
|
|
423
|
+
["GroupA", ["GroupB"]],
|
|
424
|
+
["GroupB", ["GroupC"]],
|
|
425
|
+
]);
|
|
426
|
+
|
|
427
|
+
const result = mergeImportCycleGroups(groups, ownership, groupDeps);
|
|
428
|
+
|
|
429
|
+
expect(result.groups).toHaveLength(3);
|
|
430
|
+
expect(result.mergedCycles).toHaveLength(0);
|
|
431
|
+
// Ownership unchanged
|
|
432
|
+
expect(result.ownership.get("a.ts")).toBe("GroupA");
|
|
433
|
+
expect(result.ownership.get("b.ts")).toBe("GroupB");
|
|
434
|
+
expect(result.ownership.get("c.ts")).toBe("GroupC");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("no merge when groups have no deps", () => {
|
|
438
|
+
const groups = [
|
|
439
|
+
{ name: "GroupA", files: ["a.ts"], description: "Group A" },
|
|
440
|
+
{ name: "GroupB", files: ["b.ts"], description: "Group B" },
|
|
441
|
+
];
|
|
442
|
+
const ownership = new Map([
|
|
443
|
+
["a.ts", "GroupA"],
|
|
444
|
+
["b.ts", "GroupB"],
|
|
445
|
+
]);
|
|
446
|
+
const groupDeps = new Map<string, string[]>();
|
|
447
|
+
|
|
448
|
+
const result = mergeImportCycleGroups(groups, ownership, groupDeps);
|
|
449
|
+
|
|
450
|
+
expect(result.groups).toHaveLength(2);
|
|
451
|
+
expect(result.mergedCycles).toHaveLength(0);
|
|
452
|
+
});
|
|
254
453
|
});
|
package/src/stack/import-deps.ts
CHANGED
|
@@ -123,6 +123,119 @@ async function readFileFromGit(repoPath: string, sha: string, filePath: string):
|
|
|
123
123
|
return result.stdout.toString();
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
export function rebuildGroupDeps(
|
|
127
|
+
fileDeps: Map<string, string[]>,
|
|
128
|
+
ownership: Map<string, string>,
|
|
129
|
+
): Map<string, string[]> {
|
|
130
|
+
const groupDeps = new Map<string, Set<string>>();
|
|
131
|
+
for (const [fromFile, toFiles] of fileDeps) {
|
|
132
|
+
const fromGroup = ownership.get(fromFile);
|
|
133
|
+
if (!fromGroup) continue;
|
|
134
|
+
|
|
135
|
+
for (const toFile of toFiles) {
|
|
136
|
+
const toGroup = ownership.get(toFile);
|
|
137
|
+
if (!toGroup || toGroup === fromGroup) continue;
|
|
138
|
+
if (!groupDeps.has(fromGroup)) groupDeps.set(fromGroup, new Set());
|
|
139
|
+
groupDeps.get(fromGroup)!.add(toGroup);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result = new Map<string, string[]>();
|
|
144
|
+
for (const [group, deps] of groupDeps) {
|
|
145
|
+
if (deps.size > 0) {
|
|
146
|
+
result.set(group, Array.from(deps));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function tarjanSCC(nodes: string[], adj: Map<string, string[]>): string[][] {
|
|
153
|
+
let idx = 0;
|
|
154
|
+
const indices = new Map<string, number>();
|
|
155
|
+
const lowlinks = new Map<string, number>();
|
|
156
|
+
const onStack = new Set<string>();
|
|
157
|
+
const stack: string[] = [];
|
|
158
|
+
const sccs: string[][] = [];
|
|
159
|
+
|
|
160
|
+
function visit(v: string) {
|
|
161
|
+
indices.set(v, idx);
|
|
162
|
+
lowlinks.set(v, idx);
|
|
163
|
+
idx++;
|
|
164
|
+
stack.push(v);
|
|
165
|
+
onStack.add(v);
|
|
166
|
+
|
|
167
|
+
for (const w of adj.get(v) ?? []) {
|
|
168
|
+
if (!indices.has(w)) {
|
|
169
|
+
visit(w);
|
|
170
|
+
lowlinks.set(v, Math.min(lowlinks.get(v)!, lowlinks.get(w)!));
|
|
171
|
+
} else if (onStack.has(w)) {
|
|
172
|
+
lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(w)!));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (lowlinks.get(v) === indices.get(v)) {
|
|
177
|
+
const scc: string[] = [];
|
|
178
|
+
let w: string;
|
|
179
|
+
do {
|
|
180
|
+
w = stack.pop()!;
|
|
181
|
+
onStack.delete(w);
|
|
182
|
+
scc.push(w);
|
|
183
|
+
} while (w !== v);
|
|
184
|
+
sccs.push(scc);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const v of nodes) {
|
|
189
|
+
if (!indices.has(v)) visit(v);
|
|
190
|
+
}
|
|
191
|
+
return sccs;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ImportCycleMergeResult<G extends { name: string; files: string[] }> {
|
|
195
|
+
groups: G[];
|
|
196
|
+
ownership: Map<string, string>;
|
|
197
|
+
mergedCycles: string[][];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function mergeImportCycleGroups<G extends { name: string; files: string[]; key_changes?: string[]; description: string }>(
|
|
201
|
+
groups: G[],
|
|
202
|
+
ownership: Map<string, string>,
|
|
203
|
+
groupDeps: Map<string, string[]>,
|
|
204
|
+
): ImportCycleMergeResult<G> {
|
|
205
|
+
const groupNames = groups.map((g) => g.name);
|
|
206
|
+
const sccs = tarjanSCC(groupNames, groupDeps);
|
|
207
|
+
const cycleSCCs = sccs.filter((scc) => scc.length > 1);
|
|
208
|
+
|
|
209
|
+
if (cycleSCCs.length === 0) {
|
|
210
|
+
return { groups: [...groups], ownership: new Map(ownership), mergedCycles: [] };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const newOwnership = new Map(ownership);
|
|
214
|
+
const groupMap = new Map(groups.map((g) => [g.name, g]));
|
|
215
|
+
const absorbed = new Set<string>();
|
|
216
|
+
|
|
217
|
+
for (const scc of cycleSCCs) {
|
|
218
|
+
const survivor = groupMap.get(scc[0]!)!;
|
|
219
|
+
for (let i = 1; i < scc.length; i++) {
|
|
220
|
+
const victim = groupMap.get(scc[i]!)!;
|
|
221
|
+
for (const file of victim.files) {
|
|
222
|
+
if (!survivor.files.includes(file)) survivor.files.push(file);
|
|
223
|
+
}
|
|
224
|
+
if (victim.key_changes) {
|
|
225
|
+
survivor.key_changes = [...(survivor.key_changes ?? []), ...victim.key_changes];
|
|
226
|
+
}
|
|
227
|
+
survivor.description = survivor.description || victim.description;
|
|
228
|
+
for (const [path, gid] of newOwnership) {
|
|
229
|
+
if (gid === victim.name) newOwnership.set(path, survivor.name);
|
|
230
|
+
}
|
|
231
|
+
absorbed.add(victim.name);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const remaining = groups.filter((g) => !absorbed.has(g.name));
|
|
236
|
+
return { groups: remaining, ownership: newOwnership, mergedCycles: cycleSCCs };
|
|
237
|
+
}
|
|
238
|
+
|
|
126
239
|
export interface ImportDepResult {
|
|
127
240
|
fileDeps: Map<string, string[]>;
|
|
128
241
|
groupDeps: Map<string, string[]>;
|
|
@@ -159,26 +272,5 @@ export async function analyzeImportDependencies(
|
|
|
159
272
|
}
|
|
160
273
|
}));
|
|
161
274
|
|
|
162
|
-
|
|
163
|
-
for (const [fromFile, toFiles] of fileDeps) {
|
|
164
|
-
const fromGroup = ownership.get(fromFile);
|
|
165
|
-
if (!fromGroup) continue;
|
|
166
|
-
|
|
167
|
-
if (!groupDeps.has(fromGroup)) groupDeps.set(fromGroup, new Set());
|
|
168
|
-
|
|
169
|
-
for (const toFile of toFiles) {
|
|
170
|
-
const toGroup = ownership.get(toFile);
|
|
171
|
-
if (!toGroup || toGroup === fromGroup) continue;
|
|
172
|
-
groupDeps.get(fromGroup)!.add(toGroup);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const groupDepsMap = new Map<string, string[]>();
|
|
177
|
-
for (const [group, deps] of groupDeps) {
|
|
178
|
-
if (deps.size > 0) {
|
|
179
|
-
groupDepsMap.set(group, Array.from(deps));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return { fileDeps, groupDeps: groupDepsMap };
|
|
275
|
+
return { fileDeps, groupDeps: rebuildGroupDeps(fileDeps, ownership) };
|
|
184
276
|
}
|