newpr 1.0.20 → 1.0.21

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.20",
3
+ "version": "1.0.21",
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
+ }
@@ -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
 
@@ -234,8 +289,8 @@ function tieBreaker(
234
289
  function findMinimalCycle(
235
290
  allGroups: string[],
236
291
  adjacency: Map<string, string[]>,
237
- sorted: string[],
238
- edgeMap: Map<string, ConstraintEdge>,
292
+ sorted: string[] = [],
293
+ edgeMap: Map<string, ConstraintEdge> = new Map(),
239
294
  ): CycleReport {
240
295
  const inCycle = new Set(allGroups.filter((g) => !sorted.includes(g)));
241
296
 
@@ -0,0 +1,131 @@
1
+ const IMPORT_PATTERNS: RegExp[] = [
2
+ /^\s*import\s+.*?\s+from\s+['"]([^'"]+)['"]/gm,
3
+ /^\s*export\s+.*?\s+from\s+['"]([^'"]+)['"]/gm,
4
+ /^\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)/gm,
5
+ /^\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm,
6
+ ];
7
+
8
+ const ANALYZABLE_EXTENSIONS = new Set([
9
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
10
+ ]);
11
+
12
+ function isRelativeImport(specifier: string): boolean {
13
+ return specifier.startsWith("./") || specifier.startsWith("../");
14
+ }
15
+
16
+ function extractImports(source: string): string[] {
17
+ const imports = new Set<string>();
18
+ for (const re of IMPORT_PATTERNS) {
19
+ re.lastIndex = 0;
20
+ let match: RegExpExecArray | null;
21
+ while ((match = re.exec(source)) !== null) {
22
+ const specifier = match[1];
23
+ if (specifier && isRelativeImport(specifier)) {
24
+ imports.add(specifier);
25
+ }
26
+ }
27
+ }
28
+ return Array.from(imports);
29
+ }
30
+
31
+ function resolveRelative(fromFile: string, specifier: string): string {
32
+ const parts = fromFile.split("/");
33
+ parts.pop();
34
+ const segments = specifier.split("/");
35
+ for (const seg of segments) {
36
+ if (seg === "..") {
37
+ parts.pop();
38
+ } else if (seg !== ".") {
39
+ parts.push(seg);
40
+ }
41
+ }
42
+ return parts.join("/");
43
+ }
44
+
45
+ function resolveToExistingFile(
46
+ candidate: string,
47
+ fileSet: Set<string>,
48
+ ): string | null {
49
+ if (fileSet.has(candidate)) return candidate;
50
+
51
+ for (const ext of ANALYZABLE_EXTENSIONS) {
52
+ const withExt = `${candidate}${ext}`;
53
+ if (fileSet.has(withExt)) return withExt;
54
+ }
55
+
56
+ for (const indexFile of ["index.ts", "index.tsx", "index.js"]) {
57
+ const asDir = `${candidate}/${indexFile}`;
58
+ if (fileSet.has(asDir)) return asDir;
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ async function readFileFromGit(repoPath: string, sha: string, filePath: string): Promise<string | null> {
65
+ const result = await Bun.$`git -C ${repoPath} show ${sha}:${filePath}`.quiet().nothrow();
66
+ if (result.exitCode !== 0) return null;
67
+ return result.stdout.toString();
68
+ }
69
+
70
+ export interface ImportDepResult {
71
+ fileDeps: Map<string, string[]>;
72
+ groupDeps: Map<string, string[]>;
73
+ }
74
+
75
+ export async function analyzeImportDependencies(
76
+ repoPath: string,
77
+ headSha: string,
78
+ changedFiles: string[],
79
+ ownership: Map<string, string>,
80
+ ): Promise<ImportDepResult> {
81
+ const fileSet = new Set(changedFiles);
82
+ const fileDeps = new Map<string, string[]>();
83
+
84
+ const ext = (f: string) => {
85
+ const dot = f.lastIndexOf(".");
86
+ return dot >= 0 ? f.slice(dot) : "";
87
+ };
88
+
89
+ const analyzable = changedFiles.filter((f) => ANALYZABLE_EXTENSIONS.has(ext(f)));
90
+
91
+ await Promise.all(analyzable.map(async (filePath) => {
92
+ const source = await readFileFromGit(repoPath, headSha, filePath);
93
+ if (!source) return;
94
+
95
+ const rawImports = extractImports(source);
96
+ const resolved: string[] = [];
97
+
98
+ for (const specifier of rawImports) {
99
+ const candidate = resolveRelative(filePath, specifier);
100
+ const existing = resolveToExistingFile(candidate, fileSet);
101
+ if (existing && existing !== filePath) {
102
+ resolved.push(existing);
103
+ }
104
+ }
105
+
106
+ if (resolved.length > 0) {
107
+ fileDeps.set(filePath, resolved);
108
+ }
109
+ }));
110
+
111
+ const groupDeps = new Map<string, Set<string>>();
112
+ for (const [fromFile, toFiles] of fileDeps) {
113
+ const fromGroup = ownership.get(fromFile);
114
+ if (!fromGroup) continue;
115
+
116
+ if (!groupDeps.has(fromGroup)) groupDeps.set(fromGroup, new Set());
117
+
118
+ for (const toFile of toFiles) {
119
+ const toGroup = ownership.get(toFile);
120
+ if (!toGroup || toGroup === fromGroup) continue;
121
+ groupDeps.get(fromGroup)!.add(toGroup);
122
+ }
123
+ }
124
+
125
+ const groupDepsMap = new Map<string, string[]>();
126
+ for (const [group, deps] of groupDeps) {
127
+ groupDepsMap.set(group, Array.from(deps));
128
+ }
129
+
130
+ return { fileDeps, groupDeps: groupDepsMap };
131
+ }
@@ -260,7 +260,7 @@ describe("integration: full stacking pipeline", () => {
260
260
  ]),
261
261
  });
262
262
 
263
- expect(feasibility.feasible).toBe(false);
264
- expect(feasibility.cycle).toBeDefined();
263
+ expect(feasibility.feasible).toBe(true);
264
+ expect(feasibility.ordered_group_ids).toHaveLength(2);
265
265
  });
266
266
  });