newpr 0.6.5 → 1.0.0
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/history/store.ts +25 -0
- package/src/stack/balance.ts +128 -0
- package/src/stack/coupling.test.ts +158 -0
- package/src/stack/coupling.ts +135 -0
- package/src/stack/delta.test.ts +223 -0
- package/src/stack/delta.ts +264 -0
- package/src/stack/execute.test.ts +176 -0
- package/src/stack/execute.ts +194 -0
- package/src/stack/feasibility.test.ts +185 -0
- package/src/stack/feasibility.ts +286 -0
- package/src/stack/integration.test.ts +266 -0
- package/src/stack/merge-groups.test.ts +97 -0
- package/src/stack/merge-groups.ts +87 -0
- package/src/stack/partition.test.ts +233 -0
- package/src/stack/partition.ts +273 -0
- package/src/stack/plan.test.ts +154 -0
- package/src/stack/plan.ts +139 -0
- package/src/stack/pr-title.ts +64 -0
- package/src/stack/publish.ts +96 -0
- package/src/stack/split.ts +173 -0
- package/src/stack/types.ts +202 -0
- package/src/stack/verify.test.ts +137 -0
- package/src/stack/verify.ts +201 -0
- package/src/web/client/components/FeasibilityAlert.tsx +64 -0
- package/src/web/client/components/InputScreen.tsx +100 -89
- package/src/web/client/components/ResultsScreen.tsx +10 -2
- package/src/web/client/components/StackGroupCard.tsx +171 -0
- package/src/web/client/components/StackWarnings.tsx +135 -0
- package/src/web/client/hooks/useStack.ts +301 -0
- package/src/web/client/panels/StackPanel.tsx +289 -0
- package/src/web/server/routes.ts +114 -0
- package/src/web/server/stack-manager.ts +580 -0
- package/src/web/server.ts +15 -0
- package/src/web/styles/built.css +1 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { checkFeasibility } from "./feasibility.ts";
|
|
3
|
+
import type { DeltaEntry } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
function makeDelta(sha: string, parentSha: string, files: Array<{ status: "A" | "M" | "D"; path: string }>): DeltaEntry {
|
|
6
|
+
return {
|
|
7
|
+
sha,
|
|
8
|
+
parent_sha: parentSha,
|
|
9
|
+
author: "Test",
|
|
10
|
+
date: "2024-01-01",
|
|
11
|
+
message: `Commit ${sha}`,
|
|
12
|
+
changes: files.map((f) => ({
|
|
13
|
+
status: f.status,
|
|
14
|
+
path: f.path,
|
|
15
|
+
old_blob: "0".repeat(40),
|
|
16
|
+
new_blob: "1".repeat(40),
|
|
17
|
+
old_mode: f.status === "A" ? "000000" : "100644",
|
|
18
|
+
new_mode: f.status === "D" ? "000000" : "100644",
|
|
19
|
+
})),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("checkFeasibility", () => {
|
|
24
|
+
test("monotonic sequence (A→B→C) is feasible with correct order", () => {
|
|
25
|
+
const deltas: DeltaEntry[] = [
|
|
26
|
+
makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
|
|
27
|
+
makeDelta("c2", "c1", [{ status: "A", path: "b.ts" }]),
|
|
28
|
+
makeDelta("c3", "c2", [{ status: "A", path: "c.ts" }]),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const ownership = new Map([
|
|
32
|
+
["a.ts", "group-a"],
|
|
33
|
+
["b.ts", "group-b"],
|
|
34
|
+
["c.ts", "group-c"],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const result = checkFeasibility({ deltas, ownership });
|
|
38
|
+
|
|
39
|
+
expect(result.feasible).toBe(true);
|
|
40
|
+
expect(result.ordered_group_ids).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("non-monotonic (A→B→A) is infeasible with cycle report", () => {
|
|
44
|
+
const deltas: DeltaEntry[] = [
|
|
45
|
+
makeDelta("c1", "base", [{ status: "M", path: "shared.ts" }]),
|
|
46
|
+
makeDelta("c2", "c1", [{ status: "M", path: "shared.ts" }]),
|
|
47
|
+
makeDelta("c3", "c2", [{ status: "M", path: "shared.ts" }]),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const ownership = new Map([
|
|
51
|
+
["shared.ts", "group-a"],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const result = checkFeasibility({ deltas, ownership });
|
|
55
|
+
expect(result.feasible).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("same-group edits across commits are monotonic → feasible", () => {
|
|
59
|
+
const deltas: DeltaEntry[] = [
|
|
60
|
+
makeDelta("c1", "base", [
|
|
61
|
+
{ status: "M", path: "file-a.ts" },
|
|
62
|
+
{ status: "M", path: "file-b.ts" },
|
|
63
|
+
]),
|
|
64
|
+
makeDelta("c2", "c1", [
|
|
65
|
+
{ status: "M", path: "file-b.ts" },
|
|
66
|
+
{ status: "M", path: "file-a.ts" },
|
|
67
|
+
]),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const ownership = new Map([
|
|
71
|
+
["file-a.ts", "group-a"],
|
|
72
|
+
["file-b.ts", "group-b"],
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const result = checkFeasibility({ deltas, ownership });
|
|
76
|
+
expect(result.feasible).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("declared deps creating cycle → infeasible", () => {
|
|
80
|
+
const deltas: DeltaEntry[] = [
|
|
81
|
+
makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
|
|
82
|
+
makeDelta("c2", "c1", [{ status: "A", path: "b.ts" }]),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const ownership = new Map([
|
|
86
|
+
["a.ts", "group-a"],
|
|
87
|
+
["b.ts", "group-b"],
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const declaredDeps = new Map([
|
|
91
|
+
["group-a", ["group-b"]],
|
|
92
|
+
["group-b", ["group-a"]],
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
|
|
96
|
+
|
|
97
|
+
expect(result.feasible).toBe(false);
|
|
98
|
+
expect(result.cycle).toBeDefined();
|
|
99
|
+
expect(result.cycle?.group_cycle.length).toBeGreaterThan(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("single file in one group → no edges, feasible", () => {
|
|
103
|
+
const deltas: DeltaEntry[] = [
|
|
104
|
+
makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const ownership = new Map([["a.ts", "group-a"]]);
|
|
108
|
+
|
|
109
|
+
const result = checkFeasibility({ deltas, ownership });
|
|
110
|
+
|
|
111
|
+
expect(result.feasible).toBe(true);
|
|
112
|
+
expect(result.ordered_group_ids).toEqual(["group-a"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("empty input → feasible", () => {
|
|
116
|
+
const result = checkFeasibility({
|
|
117
|
+
deltas: [],
|
|
118
|
+
ownership: new Map(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.feasible).toBe(true);
|
|
122
|
+
expect(result.ordered_group_ids).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("per-file monotonic edits across commits → feasible", () => {
|
|
126
|
+
const deltas: DeltaEntry[] = [
|
|
127
|
+
makeDelta("c1", "base", [{ status: "M", path: "file-x.ts" }]),
|
|
128
|
+
makeDelta("c2", "c1", [{ status: "M", path: "file-y.ts" }]),
|
|
129
|
+
makeDelta("c3", "c2", [{ status: "M", path: "file-x.ts" }]),
|
|
130
|
+
makeDelta("c4", "c3", [{ status: "M", path: "file-y.ts" }]),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const ownership = new Map([
|
|
134
|
+
["file-x.ts", "group-a"],
|
|
135
|
+
["file-y.ts", "group-b"],
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
const result = checkFeasibility({ deltas, ownership });
|
|
139
|
+
expect(result.feasible).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("mutual declared deps create cycle → infeasible", () => {
|
|
143
|
+
const deltas: DeltaEntry[] = [
|
|
144
|
+
makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
|
|
145
|
+
makeDelta("c2", "c1", [{ status: "A", path: "b.ts" }]),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const ownership = new Map([
|
|
149
|
+
["a.ts", "group-a"],
|
|
150
|
+
["b.ts", "group-b"],
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const declaredDeps = new Map([
|
|
154
|
+
["group-a", ["group-b"]],
|
|
155
|
+
["group-b", ["group-a"]],
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
|
|
159
|
+
expect(result.feasible).toBe(false);
|
|
160
|
+
expect(result.cycle).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("declared deps without cycle → feasible with correct order", () => {
|
|
164
|
+
const deltas: DeltaEntry[] = [
|
|
165
|
+
makeDelta("c1", "base", [{ status: "A", path: "a.ts" }]),
|
|
166
|
+
makeDelta("c2", "c1", [{ status: "A", path: "b.ts" }]),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const ownership = new Map([
|
|
170
|
+
["a.ts", "group-a"],
|
|
171
|
+
["b.ts", "group-b"],
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const declaredDeps = new Map([
|
|
175
|
+
["group-b", ["group-a"]],
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const result = checkFeasibility({ deltas, ownership, declared_deps: declaredDeps });
|
|
179
|
+
|
|
180
|
+
expect(result.feasible).toBe(true);
|
|
181
|
+
expect(result.ordered_group_ids).toBeDefined();
|
|
182
|
+
const order = result.ordered_group_ids!;
|
|
183
|
+
expect(order.indexOf("group-a")).toBeLessThan(order.indexOf("group-b"));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeltaEntry,
|
|
3
|
+
ConstraintEdge,
|
|
4
|
+
CycleReport,
|
|
5
|
+
FeasibilityResult,
|
|
6
|
+
} from "./types.ts";
|
|
7
|
+
|
|
8
|
+
interface FeasibilityInput {
|
|
9
|
+
deltas: DeltaEntry[];
|
|
10
|
+
ownership: Map<string, string>;
|
|
11
|
+
declared_deps?: Map<string, string[]>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function checkFeasibility(input: FeasibilityInput): FeasibilityResult {
|
|
15
|
+
const { deltas, ownership, declared_deps } = input;
|
|
16
|
+
|
|
17
|
+
const allGroups = new Set<string>(ownership.values());
|
|
18
|
+
if (allGroups.size <= 1) {
|
|
19
|
+
return {
|
|
20
|
+
feasible: true,
|
|
21
|
+
ordered_group_ids: Array.from(allGroups),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const edges: ConstraintEdge[] = [];
|
|
26
|
+
|
|
27
|
+
addPathOrderEdges(deltas, ownership, edges);
|
|
28
|
+
if (declared_deps) {
|
|
29
|
+
addDeclaredDepEdges(declared_deps, allGroups, edges);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const deduped = deduplicateEdges(edges);
|
|
33
|
+
const result = topologicalSort(Array.from(allGroups), deduped, deltas);
|
|
34
|
+
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addPathOrderEdges(
|
|
39
|
+
deltas: DeltaEntry[],
|
|
40
|
+
ownership: Map<string, string>,
|
|
41
|
+
edges: ConstraintEdge[],
|
|
42
|
+
): void {
|
|
43
|
+
const pathEditSequences = new Map<string, Array<{ commit_index: number; group_id: string; sha: string }>>();
|
|
44
|
+
|
|
45
|
+
for (let commitIdx = 0; commitIdx < deltas.length; commitIdx++) {
|
|
46
|
+
const delta = deltas[commitIdx];
|
|
47
|
+
if (!delta) continue;
|
|
48
|
+
|
|
49
|
+
for (const change of delta.changes) {
|
|
50
|
+
const path = change.path;
|
|
51
|
+
const groupId = ownership.get(path);
|
|
52
|
+
if (!groupId) continue;
|
|
53
|
+
|
|
54
|
+
let seq = pathEditSequences.get(path);
|
|
55
|
+
if (!seq) {
|
|
56
|
+
seq = [];
|
|
57
|
+
pathEditSequences.set(path, seq);
|
|
58
|
+
}
|
|
59
|
+
seq.push({ commit_index: commitIdx, group_id: groupId, sha: delta.sha });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [path, seq] of pathEditSequences) {
|
|
64
|
+
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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collapseConsecutiveDuplicates<T extends { group_id: string }>(
|
|
89
|
+
seq: T[],
|
|
90
|
+
): T[] {
|
|
91
|
+
const result: T[] = [];
|
|
92
|
+
for (const item of seq) {
|
|
93
|
+
const last = result[result.length - 1];
|
|
94
|
+
if (!last || last.group_id !== item.group_id) {
|
|
95
|
+
result.push(item);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function addDeclaredDepEdges(
|
|
102
|
+
declaredDeps: Map<string, string[]>,
|
|
103
|
+
allGroups: Set<string>,
|
|
104
|
+
edges: ConstraintEdge[],
|
|
105
|
+
): void {
|
|
106
|
+
for (const [groupId, deps] of declaredDeps) {
|
|
107
|
+
if (!allGroups.has(groupId)) continue;
|
|
108
|
+
|
|
109
|
+
for (const depGroupId of deps) {
|
|
110
|
+
if (!allGroups.has(depGroupId)) continue;
|
|
111
|
+
if (groupId === depGroupId) continue;
|
|
112
|
+
|
|
113
|
+
edges.push({
|
|
114
|
+
from: depGroupId,
|
|
115
|
+
to: groupId,
|
|
116
|
+
kind: "dependency",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
|
|
123
|
+
const seen = new Set<string>();
|
|
124
|
+
const result: ConstraintEdge[] = [];
|
|
125
|
+
|
|
126
|
+
for (const edge of edges) {
|
|
127
|
+
const key = `${edge.from}→${edge.to}`;
|
|
128
|
+
if (seen.has(key)) continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
result.push(edge);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function topologicalSort(
|
|
137
|
+
groups: string[],
|
|
138
|
+
edges: ConstraintEdge[],
|
|
139
|
+
deltas: DeltaEntry[],
|
|
140
|
+
): FeasibilityResult {
|
|
141
|
+
const inDegree = new Map<string, number>();
|
|
142
|
+
const adjacency = new Map<string, string[]>();
|
|
143
|
+
const edgeMap = new Map<string, ConstraintEdge>();
|
|
144
|
+
|
|
145
|
+
for (const g of groups) {
|
|
146
|
+
inDegree.set(g, 0);
|
|
147
|
+
adjacency.set(g, []);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const edge of edges) {
|
|
151
|
+
const neighbors = adjacency.get(edge.from);
|
|
152
|
+
if (neighbors) {
|
|
153
|
+
neighbors.push(edge.to);
|
|
154
|
+
}
|
|
155
|
+
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
|
156
|
+
edgeMap.set(`${edge.from}→${edge.to}`, edge);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const firstCommitDate = buildFirstCommitDateMap(groups, deltas);
|
|
160
|
+
|
|
161
|
+
const queue: string[] = [];
|
|
162
|
+
for (const [g, deg] of inDegree) {
|
|
163
|
+
if (deg === 0) queue.push(g);
|
|
164
|
+
}
|
|
165
|
+
queue.sort((a, b) => tieBreaker(a, b, firstCommitDate));
|
|
166
|
+
|
|
167
|
+
const sorted: string[] = [];
|
|
168
|
+
|
|
169
|
+
while (queue.length > 0) {
|
|
170
|
+
queue.sort((a, b) => tieBreaker(a, b, firstCommitDate));
|
|
171
|
+
const node = queue.shift()!;
|
|
172
|
+
sorted.push(node);
|
|
173
|
+
|
|
174
|
+
const neighbors = adjacency.get(node) ?? [];
|
|
175
|
+
for (const neighbor of neighbors) {
|
|
176
|
+
const deg = (inDegree.get(neighbor) ?? 1) - 1;
|
|
177
|
+
inDegree.set(neighbor, deg);
|
|
178
|
+
if (deg === 0) {
|
|
179
|
+
queue.push(neighbor);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (sorted.length === groups.length) {
|
|
185
|
+
return {
|
|
186
|
+
feasible: true,
|
|
187
|
+
ordered_group_ids: sorted,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const cycle = findMinimalCycle(groups, adjacency, sorted, edgeMap);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
feasible: false,
|
|
195
|
+
ordered_group_ids: undefined,
|
|
196
|
+
cycle,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildFirstCommitDateMap(
|
|
201
|
+
groups: string[],
|
|
202
|
+
deltas: DeltaEntry[],
|
|
203
|
+
): Map<string, string> {
|
|
204
|
+
const result = new Map<string, string>();
|
|
205
|
+
for (const g of groups) {
|
|
206
|
+
result.set(g, "9999");
|
|
207
|
+
}
|
|
208
|
+
for (const delta of deltas) {
|
|
209
|
+
for (const change of delta.changes) {
|
|
210
|
+
const g = change.path;
|
|
211
|
+
if (result.has(g)) {
|
|
212
|
+
const current = result.get(g);
|
|
213
|
+
if (!current || delta.date < current) {
|
|
214
|
+
result.set(g, delta.date);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function tieBreaker(
|
|
223
|
+
a: string,
|
|
224
|
+
b: string,
|
|
225
|
+
firstCommitDate: Map<string, string>,
|
|
226
|
+
): number {
|
|
227
|
+
const dateA = firstCommitDate.get(a) ?? "9999";
|
|
228
|
+
const dateB = firstCommitDate.get(b) ?? "9999";
|
|
229
|
+
if (dateA !== dateB) return dateA < dateB ? -1 : 1;
|
|
230
|
+
return a.localeCompare(b);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function findMinimalCycle(
|
|
234
|
+
allGroups: string[],
|
|
235
|
+
adjacency: Map<string, string[]>,
|
|
236
|
+
sorted: string[],
|
|
237
|
+
edgeMap: Map<string, ConstraintEdge>,
|
|
238
|
+
): CycleReport {
|
|
239
|
+
const inCycle = new Set(allGroups.filter((g) => !sorted.includes(g)));
|
|
240
|
+
|
|
241
|
+
if (inCycle.size === 0) {
|
|
242
|
+
return { group_cycle: [], edge_cycle: [] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const start = Array.from(inCycle)[0]!;
|
|
246
|
+
const visited = new Map<string, string>();
|
|
247
|
+
const bfsQueue: string[] = [start];
|
|
248
|
+
visited.set(start, "");
|
|
249
|
+
|
|
250
|
+
while (bfsQueue.length > 0) {
|
|
251
|
+
const current = bfsQueue.shift()!;
|
|
252
|
+
const neighbors = adjacency.get(current) ?? [];
|
|
253
|
+
|
|
254
|
+
for (const neighbor of neighbors) {
|
|
255
|
+
if (!inCycle.has(neighbor)) continue;
|
|
256
|
+
|
|
257
|
+
if (neighbor === start && visited.size > 1) {
|
|
258
|
+
const cycle: string[] = [start];
|
|
259
|
+
let backtrack = current;
|
|
260
|
+
while (backtrack !== start && backtrack !== "") {
|
|
261
|
+
cycle.unshift(backtrack);
|
|
262
|
+
backtrack = visited.get(backtrack) ?? "";
|
|
263
|
+
}
|
|
264
|
+
cycle.push(start);
|
|
265
|
+
|
|
266
|
+
const edgeCycle: ConstraintEdge[] = [];
|
|
267
|
+
for (let i = 0; i < cycle.length - 1; i++) {
|
|
268
|
+
const edge = edgeMap.get(`${cycle[i]}→${cycle[i + 1]}`);
|
|
269
|
+
if (edge) edgeCycle.push(edge);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { group_cycle: cycle, edge_cycle: edgeCycle };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!visited.has(neighbor)) {
|
|
276
|
+
visited.set(neighbor, current);
|
|
277
|
+
bfsQueue.push(neighbor);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
group_cycle: Array.from(inCycle),
|
|
284
|
+
edge_cycle: [],
|
|
285
|
+
};
|
|
286
|
+
}
|