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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -119,7 +119,7 @@ describe("extractDeltas", () => {
119
119
  });
120
120
 
121
121
  describe("computeGroupStats", () => {
122
- test("uses merged dependency baseline for multi-parent DAG groups", async () => {
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(1);
184
- expect(stats.get("C")?.deletions).toBe(1);
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
  }
@@ -196,48 +196,7 @@ async function resolveParentTree(
196
196
  return resolveTree(repoPath, baseSha);
197
197
  }
198
198
 
199
- if (parentIds.length === 1) {
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
  });
@@ -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 edgeSet = new Set(edges.map((e) => `${e.from}→${e.to}`));
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
- if (edgeSet.has(reverseKey) && edge.kind === "dependency") continue;
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 === "path-order" ? 0 : e.kind === "dependency" ? 1 : 2;
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
  });
@@ -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
- const groupDeps = new Map<string, Set<string>>();
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
  }