newpr 1.0.23 → 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 +453 -0
- package/src/stack/import-deps.ts +181 -36
- package/src/stack/quality-gate.ts +10 -18
- package/src/web/client/components/Markdown.tsx +9 -8
- 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[] = [];
|