newpr 1.0.20 → 1.0.22

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 (41) hide show
  1. package/package.json +2 -1
  2. package/src/stack/co-change.ts +64 -0
  3. package/src/stack/confidence-score.ts +242 -0
  4. package/src/stack/delta.ts +38 -3
  5. package/src/stack/execute.ts +20 -6
  6. package/src/stack/feasibility.test.ts +6 -7
  7. package/src/stack/feasibility.ts +84 -24
  8. package/src/stack/import-deps.ts +131 -0
  9. package/src/stack/integration.test.ts +2 -2
  10. package/src/stack/partition.ts +41 -21
  11. package/src/stack/plan.ts +63 -5
  12. package/src/stack/publish.ts +111 -21
  13. package/src/stack/symbol-flow.ts +229 -0
  14. package/src/stack/types.ts +2 -0
  15. package/src/web/client/App.tsx +2 -4
  16. package/src/web/client/components/AnalyticsConsent.tsx +1 -98
  17. package/src/web/client/components/AppShell.tsx +5 -5
  18. package/src/web/client/components/ChatSection.tsx +1 -1
  19. package/src/web/client/components/DetailPane.tsx +9 -9
  20. package/src/web/client/components/DiffViewer.tsx +17 -17
  21. package/src/web/client/components/ErrorScreen.tsx +1 -1
  22. package/src/web/client/components/InputScreen.tsx +2 -2
  23. package/src/web/client/components/LoadingTimeline.tsx +12 -12
  24. package/src/web/client/components/Markdown.tsx +10 -10
  25. package/src/web/client/components/ResultsScreen.tsx +4 -4
  26. package/src/web/client/components/ReviewModal.tsx +2 -2
  27. package/src/web/client/components/SettingsPanel.tsx +1 -1
  28. package/src/web/client/components/StackDagView.tsx +317 -0
  29. package/src/web/client/components/StackGroupCard.tsx +15 -1
  30. package/src/web/client/lib/analytics.ts +6 -4
  31. package/src/web/client/panels/DiscussionPanel.tsx +24 -24
  32. package/src/web/client/panels/FilesPanel.tsx +26 -26
  33. package/src/web/client/panels/GroupsPanel.tsx +25 -25
  34. package/src/web/client/panels/StackPanel.tsx +6 -15
  35. package/src/web/client/panels/StoryPanel.tsx +25 -25
  36. package/src/web/index.html +2 -0
  37. package/src/web/server/routes.ts +20 -2
  38. package/src/web/server/stack-manager.ts +93 -1
  39. package/src/web/styles/built.css +1 -1
  40. package/src/web/styles/globals.css +16 -11
  41. package/src/workspace/agent.ts +13 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
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",
@@ -74,6 +74,7 @@
74
74
  "ink-text-input": "6.0.0",
75
75
  "katex": "^0.16.28",
76
76
  "lucide-react": "^0.567.0",
77
+ "meriyah": "^7.1.0",
77
78
  "react": "19.1.0",
78
79
  "react-dom": "19.1.0",
79
80
  "react-markdown": "^10.1.0",
@@ -0,0 +1,64 @@
1
+ import type { DeltaEntry } from "./types.ts";
2
+
3
+ export interface CoChangeResult {
4
+ pairs: Map<string, number>;
5
+ totalCommits: number;
6
+ }
7
+
8
+ export function buildCoChangePairs(deltas: DeltaEntry[]): CoChangeResult {
9
+ const pairs = new Map<string, number>();
10
+
11
+ for (const delta of deltas) {
12
+ const files = delta.changes.map((c) => c.path);
13
+ for (let i = 0; i < files.length; i++) {
14
+ for (let j = i + 1; j < files.length; j++) {
15
+ const key = [files[i]!, files[j]!].sort().join("|||");
16
+ pairs.set(key, (pairs.get(key) ?? 0) + 1);
17
+ }
18
+ }
19
+ }
20
+
21
+ return { pairs, totalCommits: deltas.length };
22
+ }
23
+
24
+ export async function buildHistoricalCoChangePairs(
25
+ repoPath: string,
26
+ filePaths: string[],
27
+ maxCommits = 200,
28
+ ): Promise<CoChangeResult> {
29
+ if (filePaths.length === 0) return { pairs: new Map(), totalCommits: 0 };
30
+
31
+ const result = await Bun.$`git -C ${repoPath} log --name-only --pretty=format:"%H" -n ${maxCommits} -- ${filePaths}`.quiet().nothrow();
32
+ if (result.exitCode !== 0) return { pairs: new Map(), totalCommits: 0 };
33
+
34
+ const fileSet = new Set(filePaths);
35
+ const lines = result.stdout.toString().split("\n");
36
+ const pairs = new Map<string, number>();
37
+ let totalCommits = 0;
38
+ let currentFiles: string[] = [];
39
+
40
+ const flushCommit = () => {
41
+ if (currentFiles.length < 2) return;
42
+ for (let i = 0; i < currentFiles.length; i++) {
43
+ for (let j = i + 1; j < currentFiles.length; j++) {
44
+ const key = [currentFiles[i]!, currentFiles[j]!].sort().join("|||");
45
+ pairs.set(key, (pairs.get(key) ?? 0) + 1);
46
+ }
47
+ }
48
+ totalCommits++;
49
+ currentFiles = [];
50
+ };
51
+
52
+ for (const line of lines) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed) continue;
55
+ if (/^[0-9a-f]{40}$/i.test(trimmed)) {
56
+ flushCommit();
57
+ } else if (fileSet.has(trimmed)) {
58
+ currentFiles.push(trimmed);
59
+ }
60
+ }
61
+ flushCommit();
62
+
63
+ return { pairs, totalCommits };
64
+ }
@@ -0,0 +1,242 @@
1
+ import type { FileGroup } from "../types/output.ts";
2
+ import type { FileSymbols } from "./symbol-flow.ts";
3
+
4
+ export type LayerType = "schema" | "refactor" | "codegen" | "core" | "integration" | "ui" | "test" | "unknown";
5
+
6
+ const LAYER_ORDER: Record<LayerType, number> = {
7
+ schema: 0,
8
+ codegen: 1,
9
+ refactor: 2,
10
+ core: 3,
11
+ integration: 4,
12
+ ui: 5,
13
+ test: 6,
14
+ unknown: 3,
15
+ };
16
+
17
+ const SCHEMA_PATH_RE = /\/(schema|types?|const|constants|config|model|interface)s?(?:\/|\.)/i;
18
+ const SCHEMA_SUFFIX_RE = /\.(schema|types?|const|d)\.[tj]sx?$/i;
19
+ const CODEGEN_PATH_RE = /\/(generated?|__generated?|\.generated?)\//i;
20
+ const TEST_PATH_RE = /\.(test|spec)\.[tj]sx?$|^\/?(__tests?__|tests?)\//i;
21
+ const UI_PATH_RE = /\.(tsx|css|scss|sass|less|html|vue|svelte)$|\/ui\/|\/components?\//i;
22
+ const INTEGRATION_PATH_RE = /\/(?:hooks?|providers?|context|store|api|adapters?|container|wiring)\//i;
23
+
24
+ function classifyLayer(filePath: string, symbols: FileSymbols | undefined): LayerType {
25
+ if (TEST_PATH_RE.test(filePath)) return "test";
26
+ if (CODEGEN_PATH_RE.test(filePath)) return "codegen";
27
+ if (SCHEMA_SUFFIX_RE.test(filePath) || SCHEMA_PATH_RE.test(filePath)) return "schema";
28
+ if (UI_PATH_RE.test(filePath)) return "ui";
29
+ if (INTEGRATION_PATH_RE.test(filePath)) return "integration";
30
+
31
+ if (symbols) {
32
+ const importCount = symbols.imports.reduce((sum, imp) => sum + imp.names.length, 0);
33
+ const exportCount = symbols.exports.length;
34
+ if (importCount > 6 && exportCount < 3) return "integration";
35
+ if (exportCount > 5 && importCount < 3) return "schema";
36
+ }
37
+
38
+ return "core";
39
+ }
40
+
41
+ export function classifyGroupLayer(group: FileGroup, symbolMap: Map<string, FileSymbols>): LayerType {
42
+ const counts: Partial<Record<LayerType, number>> = {};
43
+ for (const file of group.files) {
44
+ const layer = classifyLayer(file, symbolMap.get(file));
45
+ counts[layer] = (counts[layer] ?? 0) + 1;
46
+ }
47
+ if (Object.keys(counts).length === 0) return "unknown";
48
+ let best: LayerType = "unknown";
49
+ let bestCount = 0;
50
+ for (const [layer, count] of Object.entries(counts) as [LayerType, number][]) {
51
+ if (count > bestCount) { best = layer; bestCount = count; }
52
+ }
53
+ return best;
54
+ }
55
+
56
+ export function getLayerOrder(layer: LayerType): number {
57
+ return LAYER_ORDER[layer];
58
+ }
59
+
60
+ export interface ConfidenceBreakdown {
61
+ import: number;
62
+ directory: number;
63
+ symbol: number;
64
+ coChange: number;
65
+ layerBonus: number;
66
+ }
67
+
68
+ export interface GroupScore {
69
+ groupName: string;
70
+ total: number;
71
+ breakdown: ConfidenceBreakdown;
72
+ }
73
+
74
+ function directoryScore(file: string, group: FileGroup): number {
75
+ const fileParts = file.split("/");
76
+ let maxOverlap = 0;
77
+ for (const gf of group.files) {
78
+ const gParts = gf.split("/");
79
+ let overlap = 0;
80
+ const limit = Math.min(fileParts.length - 1, gParts.length - 1);
81
+ for (let i = 0; i < limit; i++) {
82
+ if (fileParts[i] === gParts[i]) overlap++;
83
+ else break;
84
+ }
85
+ if (overlap > maxOverlap) maxOverlap = overlap;
86
+ }
87
+ return Math.min(1, maxOverlap / 4);
88
+ }
89
+
90
+ function importScore(
91
+ file: string,
92
+ group: FileGroup,
93
+ symbolMap: Map<string, FileSymbols>,
94
+ ): number {
95
+ const fileSyms = symbolMap.get(file);
96
+ if (!fileSyms) return 0;
97
+
98
+ const groupFileSet = new Set(group.files);
99
+ let score = 0;
100
+
101
+ for (const imp of fileSyms.imports) {
102
+ if (groupFileSet.has(imp.from)) {
103
+ score += Math.min(1, imp.names.length / 3);
104
+ }
105
+ }
106
+
107
+ for (const gf of group.files) {
108
+ const gSyms = symbolMap.get(gf);
109
+ if (!gSyms) continue;
110
+ for (const imp of gSyms.imports) {
111
+ if (imp.from === file) {
112
+ score += Math.min(1, imp.names.length / 3);
113
+ }
114
+ }
115
+ }
116
+
117
+ return Math.min(1, score / 3);
118
+ }
119
+
120
+ function symbolScore(
121
+ file: string,
122
+ group: FileGroup,
123
+ symbolMap: Map<string, FileSymbols>,
124
+ ): number {
125
+ const fileSyms = symbolMap.get(file);
126
+ if (!fileSyms || fileSyms.exports.length === 0) return 0;
127
+
128
+ const fileExportSet = new Set(fileSyms.exports);
129
+ let sharedCount = 0;
130
+
131
+ for (const gf of group.files) {
132
+ const gSyms = symbolMap.get(gf);
133
+ if (!gSyms) continue;
134
+ for (const imp of gSyms.imports) {
135
+ for (const name of imp.names) {
136
+ if (fileExportSet.has(name)) sharedCount++;
137
+ }
138
+ }
139
+ for (const imp of fileSyms.imports) {
140
+ for (const name of gSyms.exports) {
141
+ if (imp.names.includes(name)) sharedCount++;
142
+ }
143
+ }
144
+ }
145
+
146
+ return Math.min(1, sharedCount / 5);
147
+ }
148
+
149
+ function coChangeScore(
150
+ file: string,
151
+ group: FileGroup,
152
+ coChangePairs: Map<string, number>,
153
+ totalCommits: number,
154
+ ): number {
155
+ if (totalCommits === 0) return 0;
156
+ let total = 0;
157
+ for (const gf of group.files) {
158
+ const key = [file, gf].sort().join("|||");
159
+ total += coChangePairs.get(key) ?? 0;
160
+ }
161
+ return Math.min(1, total / (totalCommits * 0.5));
162
+ }
163
+
164
+ function layerBonus(
165
+ file: string,
166
+ group: FileGroup,
167
+ symbolMap: Map<string, FileSymbols>,
168
+ ): number {
169
+ const fileLayer = classifyLayer(file, symbolMap.get(file));
170
+ const groupLayer = classifyGroupLayer(group, symbolMap);
171
+ if (fileLayer === groupLayer) return 0.3;
172
+ if (Math.abs(LAYER_ORDER[fileLayer] - LAYER_ORDER[groupLayer]) === 1) return 0.1;
173
+ return 0;
174
+ }
175
+
176
+ export function scoreFileAgainstGroups(
177
+ file: string,
178
+ groups: FileGroup[],
179
+ symbolMap: Map<string, FileSymbols>,
180
+ coChangePairs: Map<string, number>,
181
+ totalCommits: number,
182
+ ): GroupScore[] {
183
+ return groups.map((group) => {
184
+ const breakdown: ConfidenceBreakdown = {
185
+ import: importScore(file, group, symbolMap) * 0.4,
186
+ directory: directoryScore(file, group) * 0.3,
187
+ symbol: symbolScore(file, group, symbolMap) * 0.2,
188
+ coChange: coChangeScore(file, group, coChangePairs, totalCommits) * 0.1,
189
+ layerBonus: layerBonus(file, group, symbolMap),
190
+ };
191
+ const total = breakdown.import + breakdown.directory + breakdown.symbol + breakdown.coChange + breakdown.layerBonus;
192
+ return { groupName: group.name, total, breakdown };
193
+ }).sort((a, b) => b.total - a.total);
194
+ }
195
+
196
+ export interface ReassignmentResult {
197
+ reassigned: Map<string, string>;
198
+ warnings: Array<{ file: string; from: string; to: string; confidence: number }>;
199
+ }
200
+
201
+ const REASSIGN_THRESHOLD = 0.25;
202
+ const MIN_ADVANTAGE = 0.15;
203
+
204
+ export function computeConfidenceReassignments(
205
+ ownership: Map<string, string>,
206
+ groups: FileGroup[],
207
+ symbolMap: Map<string, FileSymbols>,
208
+ coChangePairs: Map<string, number>,
209
+ totalCommits: number,
210
+ ): ReassignmentResult {
211
+ const reassigned = new Map<string, string>();
212
+ const warnings: ReassignmentResult["warnings"] = [];
213
+
214
+ const groupByName = new Map(groups.map((g) => [g.name, g]));
215
+
216
+ for (const [file, currentGroup] of ownership) {
217
+ const scores = scoreFileAgainstGroups(file, groups, symbolMap, coChangePairs, totalCommits);
218
+ if (scores.length === 0) continue;
219
+
220
+ const best = scores[0]!;
221
+ if (best.groupName === currentGroup) continue;
222
+ if (best.total < REASSIGN_THRESHOLD) continue;
223
+
224
+ const currentScore = scores.find((s) => s.groupName === currentGroup);
225
+ const currentTotal = currentScore?.total ?? 0;
226
+
227
+ if (best.total - currentTotal < MIN_ADVANTAGE) continue;
228
+
229
+ const targetGroup = groupByName.get(best.groupName);
230
+ if (!targetGroup) continue;
231
+
232
+ reassigned.set(file, best.groupName);
233
+ warnings.push({
234
+ file,
235
+ from: currentGroup,
236
+ to: best.groupName,
237
+ confidence: best.total,
238
+ });
239
+ }
240
+
241
+ return { reassigned, warnings };
242
+ }
@@ -183,22 +183,57 @@ function checkForUnsupportedModes(
183
183
  }
184
184
  }
185
185
 
186
+ async function resolveParentTree(
187
+ repoPath: string,
188
+ baseSha: string,
189
+ gid: string,
190
+ expectedTrees: Map<string, string>,
191
+ dagParents: Map<string, string[]>,
192
+ ): Promise<string | null> {
193
+ const parentIds = dagParents.get(gid) ?? [];
194
+
195
+ if (parentIds.length === 0) {
196
+ return resolveTree(repoPath, baseSha);
197
+ }
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
+ let mergedTree = parentTrees[0]!;
208
+ for (let i = 1; i < parentTrees.length; i++) {
209
+ const mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedTree} ${parentTrees[i]!}`.quiet().nothrow();
210
+ if (mergeResult.exitCode === 0) {
211
+ mergedTree = mergeResult.stdout.toString().trim().split("\n")[0]!.trim();
212
+ }
213
+ }
214
+
215
+ return mergedTree;
216
+ }
217
+
186
218
  export async function computeGroupStats(
187
219
  repoPath: string,
188
220
  baseSha: string,
189
221
  orderedGroupIds: string[],
190
222
  expectedTrees: Map<string, string>,
223
+ dagParents?: Map<string, string[]>,
191
224
  ): Promise<Map<string, StackGroupStats>> {
192
225
  const stats = new Map<string, StackGroupStats>();
226
+ const linearParents = new Map<string, string[]>(
227
+ orderedGroupIds.map((gid, i) => [gid, i === 0 ? [] : [orderedGroupIds[i - 1]!]]),
228
+ );
229
+ const effectiveDagParents = dagParents ?? linearParents;
193
230
 
194
231
  for (let i = 0; i < orderedGroupIds.length; i++) {
195
232
  const gid = orderedGroupIds[i]!;
196
233
  const tree = expectedTrees.get(gid);
197
234
  if (!tree) continue;
198
235
 
199
- const prevTree = i === 0
200
- ? await resolveTree(repoPath, baseSha)
201
- : expectedTrees.get(orderedGroupIds[i - 1]!);
236
+ const prevTree = await resolveParentTree(repoPath, baseSha, gid, expectedTrees, effectiveDagParents);
202
237
  if (!prevTree) continue;
203
238
 
204
239
  const numstatResult = await Bun.$`git -C ${repoPath} diff-tree --numstat -r ${prevTree} ${tree}`.quiet().nothrow();
@@ -4,6 +4,7 @@ import type {
4
4
  StackExecResult,
5
5
  GroupCommitInfo,
6
6
  } from "./types.ts";
7
+ import { buildAncestorSets } from "./plan.ts";
7
8
 
8
9
  export class StackExecutionError extends Error {
9
10
  constructor(message: string) {
@@ -78,6 +79,10 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
78
79
  const groupRank = new Map<string, number>();
79
80
  groupOrder.forEach((gid, idx) => groupRank.set(gid, idx));
80
81
 
82
+ const dagParents = new Map<string, string[]>();
83
+ for (const g of plan.groups) dagParents.set(g.id, g.deps ?? []);
84
+ const ancestorSets = buildAncestorSets(groupOrder, dagParents);
85
+
81
86
  try {
82
87
  for (let i = 0; i < groupOrder.length; i++) {
83
88
  const idxFile = `/tmp/newpr-exec-idx-${runId}-${i}`;
@@ -101,8 +106,12 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
101
106
  const fileRank = groupRank.get(fileGroupId);
102
107
  if (fileRank === undefined) continue;
103
108
 
104
- // Suffix propagation: update index[fileRank] through index[N-1]
105
- for (let idxNum = fileRank; idxNum < groupOrder.length; idxNum++) {
109
+ for (let idxNum = 0; idxNum < groupOrder.length; idxNum++) {
110
+ const targetGroupId = groupOrder[idxNum]!;
111
+ const isOwner = targetGroupId === fileGroupId;
112
+ const isAncestorOfOwner = ancestorSets.get(targetGroupId)?.has(fileGroupId) ?? false;
113
+ if (!isOwner && !isAncestorOfOwner) continue;
114
+
106
115
  let batch = batchPerIndex.get(idxNum);
107
116
  if (!batch) {
108
117
  batch = [];
@@ -137,7 +146,7 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
137
146
  }
138
147
 
139
148
  const groupCommits: GroupCommitInfo[] = [];
140
- let prevCommitSha = plan.base_sha;
149
+ const commitBySha = new Map<string, string>();
141
150
 
142
151
  for (let i = 0; i < groupOrder.length; i++) {
143
152
  const idxFile = tmpIndexFiles[i];
@@ -162,7 +171,13 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
162
171
 
163
172
  const commitMessage = group.pr_title ?? `${group.type}(${group.name}): ${group.description}`;
164
173
 
165
- const commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} -p ${prevCommitSha} -m ${commitMessage}`.env({
174
+ const directParents = (group.deps ?? []).length > 0
175
+ ? group.deps.map((dep) => commitBySha.get(dep) ?? plan.base_sha)
176
+ : [groupCommits[i - 1]?.commit_sha ?? plan.base_sha];
177
+
178
+ const parentArgs = directParents.flatMap((p) => ["-p", p]);
179
+
180
+ const commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} ${parentArgs} -m ${commitMessage}`.env({
166
181
  GIT_AUTHOR_NAME: pr_author.name,
167
182
  GIT_AUTHOR_EMAIL: pr_author.email,
168
183
  GIT_COMMITTER_NAME: pr_author.name,
@@ -175,6 +190,7 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
175
190
  );
176
191
  }
177
192
  const commitSha = commitTree.stdout.toString().trim();
193
+ commitBySha.set(gid, commitSha);
178
194
 
179
195
  const branchName = buildStackBranchName(pr_number, head_branch, group);
180
196
 
@@ -193,8 +209,6 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
193
209
  branch_name: branchName,
194
210
  pr_title: group.pr_title,
195
211
  });
196
-
197
- prevCommitSha = commitSha;
198
212
  }
199
213
 
200
214
  const lastCommit = groupCommits[groupCommits.length - 1];
@@ -76,7 +76,7 @@ describe("checkFeasibility", () => {
76
76
  expect(result.feasible).toBe(true);
77
77
  });
78
78
 
79
- test("declared deps creating cycle → infeasible", () => {
79
+ test("declared deps with mutual cycle → still feasible (cycle broken automatically)", () => {
80
80
  const deltas: DeltaEntry[] = [
81
81
  makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
82
82
  makeDelta("c2", "c1", [{ status: "A", path: "b.ts" }]),
@@ -94,9 +94,8 @@ describe("checkFeasibility", () => {
94
94
 
95
95
  const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
96
96
 
97
- expect(result.feasible).toBe(false);
98
- expect(result.cycle).toBeDefined();
99
- expect(result.cycle?.group_cycle.length).toBeGreaterThan(0);
97
+ expect(result.feasible).toBe(true);
98
+ expect(result.ordered_group_ids).toHaveLength(2);
100
99
  });
101
100
 
102
101
  test("single file in one group → no edges, feasible", () => {
@@ -139,7 +138,7 @@ describe("checkFeasibility", () => {
139
138
  expect(result.feasible).toBe(true);
140
139
  });
141
140
 
142
- test("mutual declared deps create cycle → infeasible", () => {
141
+ test("mutual declared deps create cycle → cycle broken, still feasible", () => {
143
142
  const deltas: DeltaEntry[] = [
144
143
  makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
145
144
  makeDelta("c2", "c1", [{ status: "A", path: "b.ts" }]),
@@ -156,8 +155,8 @@ describe("checkFeasibility", () => {
156
155
  ]);
157
156
 
158
157
  const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
159
- expect(result.feasible).toBe(false);
160
- expect(result.cycle).toBeDefined();
158
+ expect(result.feasible).toBe(true);
159
+ expect(result.ordered_group_ids).toHaveLength(2);
161
160
  });
162
161
 
163
162
  test("declared deps without cycle → feasible with correct order", () => {
@@ -30,7 +30,64 @@ export function checkFeasibility(input: FeasibilityInput): FeasibilityResult {
30
30
  }
31
31
 
32
32
  const deduped = deduplicateEdges(edges);
33
- const result = topologicalSort(Array.from(allGroups), deduped, deltas, ownership);
33
+ const acyclic = breakAllCycles(deduped);
34
+ const result = topologicalSort(Array.from(allGroups), acyclic, deltas, ownership);
35
+
36
+ return result;
37
+ }
38
+
39
+ function breakAllCycles(edges: ConstraintEdge[]): ConstraintEdge[] {
40
+ const edgeSet = new Set(edges.map((e) => `${e.from}→${e.to}`));
41
+ const withoutMutual: ConstraintEdge[] = [];
42
+ for (const edge of edges) {
43
+ const reverseKey = `${edge.to}→${edge.from}`;
44
+ if (edgeSet.has(reverseKey) && edge.kind === "dependency") continue;
45
+ withoutMutual.push(edge);
46
+ }
47
+ return breakRemainingCycles(withoutMutual);
48
+ }
49
+
50
+ function hasCycle(groups: string[], edges: ConstraintEdge[]): boolean {
51
+ const adjacency = new Map<string, string[]>();
52
+ for (const g of groups) adjacency.set(g, []);
53
+ for (const e of edges) adjacency.get(e.from)?.push(e.to);
54
+
55
+ const WHITE = 0, GRAY = 1, BLACK = 2;
56
+ const color = new Map<string, number>(groups.map((g) => [g, WHITE]));
57
+
58
+ const dfs = (node: string): boolean => {
59
+ color.set(node, GRAY);
60
+ for (const neighbor of adjacency.get(node) ?? []) {
61
+ if (color.get(neighbor) === GRAY) return true;
62
+ if (color.get(neighbor) === WHITE && dfs(neighbor)) return true;
63
+ }
64
+ color.set(node, BLACK);
65
+ return false;
66
+ };
67
+
68
+ for (const g of groups) {
69
+ if (color.get(g) === WHITE && dfs(g)) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function breakRemainingCycles(edges: ConstraintEdge[]): ConstraintEdge[] {
75
+ const groups = [...new Set(edges.flatMap((e) => [e.from, e.to]))];
76
+ if (!hasCycle(groups, edges)) return edges;
77
+
78
+ const prioritized = [...edges].sort((a, b) => {
79
+ const kindScore = (e: ConstraintEdge) =>
80
+ e.kind === "path-order" ? 0 : e.kind === "dependency" ? 1 : 2;
81
+ return kindScore(a) - kindScore(b);
82
+ });
83
+
84
+ const result: ConstraintEdge[] = [];
85
+ for (const edge of prioritized) {
86
+ result.push(edge);
87
+ if (hasCycle(groups, result)) {
88
+ result.pop();
89
+ }
90
+ }
34
91
 
35
92
  return result;
36
93
  }
@@ -62,26 +119,24 @@ function addPathOrderEdges(
62
119
 
63
120
  for (const [path, seq] of pathEditSequences) {
64
121
  const collapsed = collapseConsecutiveDuplicates(seq);
65
-
66
- for (let i = 0; i < collapsed.length - 1; i++) {
67
- const prev = collapsed[i];
68
- const next = collapsed[i + 1];
69
- if (!prev || !next) continue;
70
- if (prev.group_id === next.group_id) continue;
71
-
72
- edges.push({
73
- from: prev.group_id,
74
- to: next.group_id,
75
- kind: "path-order",
76
- evidence: {
77
- path,
78
- from_commit: prev.sha,
79
- to_commit: next.sha,
80
- from_commit_index: prev.commit_index,
81
- to_commit_index: next.commit_index,
82
- },
83
- });
84
- }
122
+ if (collapsed.length < 2) continue;
123
+
124
+ const first = collapsed[0]!;
125
+ const last = collapsed[collapsed.length - 1]!;
126
+ if (first.group_id === last.group_id) continue;
127
+
128
+ edges.push({
129
+ from: first.group_id,
130
+ to: last.group_id,
131
+ kind: "path-order",
132
+ evidence: {
133
+ path,
134
+ from_commit: first.sha,
135
+ to_commit: last.sha,
136
+ from_commit_index: first.commit_index,
137
+ to_commit_index: last.commit_index,
138
+ },
139
+ });
85
140
  }
86
141
  }
87
142
 
@@ -135,10 +190,11 @@ function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
135
190
 
136
191
  function topologicalSort(
137
192
  groups: string[],
138
- edges: ConstraintEdge[],
193
+ acyclicEdges: ConstraintEdge[],
139
194
  deltas: DeltaEntry[],
140
195
  ownership?: Map<string, string>,
141
196
  ): FeasibilityResult {
197
+ const edges = acyclicEdges;
142
198
  const inDegree = new Map<string, number>();
143
199
  const adjacency = new Map<string, string[]>();
144
200
  const edgeMap = new Map<string, ConstraintEdge>();
@@ -183,9 +239,13 @@ function topologicalSort(
183
239
  }
184
240
 
185
241
  if (sorted.length === groups.length) {
242
+ const dependencyEdges = acyclicEdges
243
+ .filter((e) => e.kind === "dependency" || e.kind === "path-order")
244
+ .map((e) => ({ from: e.from, to: e.to }));
186
245
  return {
187
246
  feasible: true,
188
247
  ordered_group_ids: sorted,
248
+ dependency_edges: dependencyEdges,
189
249
  };
190
250
  }
191
251
 
@@ -234,8 +294,8 @@ function tieBreaker(
234
294
  function findMinimalCycle(
235
295
  allGroups: string[],
236
296
  adjacency: Map<string, string[]>,
237
- sorted: string[],
238
- edgeMap: Map<string, ConstraintEdge>,
297
+ sorted: string[] = [],
298
+ edgeMap: Map<string, ConstraintEdge> = new Map(),
239
299
  ): CycleReport {
240
300
  const inCycle = new Set(allGroups.filter((g) => !sorted.includes(g)));
241
301