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.
- package/package.json +2 -1
- package/src/stack/co-change.ts +64 -0
- package/src/stack/confidence-score.ts +242 -0
- package/src/stack/delta.ts +38 -3
- package/src/stack/execute.ts +20 -6
- package/src/stack/feasibility.test.ts +6 -7
- package/src/stack/feasibility.ts +84 -24
- package/src/stack/import-deps.ts +131 -0
- package/src/stack/integration.test.ts +2 -2
- package/src/stack/partition.ts +41 -21
- package/src/stack/plan.ts +63 -5
- package/src/stack/publish.ts +111 -21
- package/src/stack/symbol-flow.ts +229 -0
- package/src/stack/types.ts +2 -0
- package/src/web/client/App.tsx +2 -4
- package/src/web/client/components/AnalyticsConsent.tsx +1 -98
- package/src/web/client/components/AppShell.tsx +5 -5
- package/src/web/client/components/ChatSection.tsx +1 -1
- package/src/web/client/components/DetailPane.tsx +9 -9
- package/src/web/client/components/DiffViewer.tsx +17 -17
- package/src/web/client/components/ErrorScreen.tsx +1 -1
- package/src/web/client/components/InputScreen.tsx +2 -2
- package/src/web/client/components/LoadingTimeline.tsx +12 -12
- package/src/web/client/components/Markdown.tsx +10 -10
- package/src/web/client/components/ResultsScreen.tsx +4 -4
- package/src/web/client/components/ReviewModal.tsx +2 -2
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/components/StackDagView.tsx +317 -0
- package/src/web/client/components/StackGroupCard.tsx +15 -1
- package/src/web/client/lib/analytics.ts +6 -4
- package/src/web/client/panels/DiscussionPanel.tsx +24 -24
- package/src/web/client/panels/FilesPanel.tsx +26 -26
- package/src/web/client/panels/GroupsPanel.tsx +25 -25
- package/src/web/client/panels/StackPanel.tsx +6 -15
- package/src/web/client/panels/StoryPanel.tsx +25 -25
- package/src/web/index.html +2 -0
- package/src/web/server/routes.ts +20 -2
- package/src/web/server/stack-manager.ts +93 -1
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +16 -11
- 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.
|
|
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
|
+
}
|
package/src/stack/delta.ts
CHANGED
|
@@ -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 =
|
|
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();
|
package/src/stack/execute.ts
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
98
|
-
expect(result.
|
|
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 →
|
|
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(
|
|
160
|
-
expect(result.
|
|
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", () => {
|
package/src/stack/feasibility.ts
CHANGED
|
@@ -30,7 +30,64 @@ export function checkFeasibility(input: FeasibilityInput): FeasibilityResult {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const deduped = deduplicateEdges(edges);
|
|
33
|
-
const
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|