newpr 1.0.19 → 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.
Files changed (32) hide show
  1. package/package.json +2 -1
  2. package/src/cli/args.ts +1 -1
  3. package/src/config/index.ts +1 -6
  4. package/src/stack/co-change.ts +64 -0
  5. package/src/stack/confidence-score.ts +242 -0
  6. package/src/stack/feasibility.test.ts +6 -7
  7. package/src/stack/feasibility.ts +86 -30
  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/symbol-flow.ts +229 -0
  12. package/src/web/client/components/AnalyticsConsent.tsx +13 -13
  13. package/src/web/client/components/ChatSection.tsx +1 -1
  14. package/src/web/client/components/DetailPane.tsx +9 -9
  15. package/src/web/client/components/DiffViewer.tsx +17 -17
  16. package/src/web/client/components/ErrorScreen.tsx +1 -1
  17. package/src/web/client/components/InputScreen.tsx +2 -2
  18. package/src/web/client/components/LoadingTimeline.tsx +12 -12
  19. package/src/web/client/components/Markdown.tsx +10 -10
  20. package/src/web/client/components/ResultsScreen.tsx +4 -4
  21. package/src/web/client/components/ReviewModal.tsx +2 -2
  22. package/src/web/client/components/SettingsPanel.tsx +1 -1
  23. package/src/web/client/panels/DiscussionPanel.tsx +24 -24
  24. package/src/web/client/panels/FilesPanel.tsx +26 -26
  25. package/src/web/client/panels/GroupsPanel.tsx +25 -25
  26. package/src/web/client/panels/StoryPanel.tsx +25 -25
  27. package/src/web/index.html +2 -0
  28. package/src/web/server/routes.ts +6 -4
  29. package/src/web/server/stack-manager.ts +103 -7
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +16 -11
  32. 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.19",
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",
package/src/cli/args.ts CHANGED
@@ -57,7 +57,7 @@ Options (review mode):
57
57
  -v, --version Show version
58
58
 
59
59
  Environment Variables:
60
- OPENROUTER_API_KEY Required. Your OpenRouter API key.
60
+ OPENROUTER_API_KEY Optional. Used when configured; otherwise local agent fallback is used.
61
61
  GITHUB_TOKEN Optional. Falls back to gh CLI token.
62
62
  NEWPR_MODEL Default model override.
63
63
  NEWPR_MAX_FILES Max files to analyze (default: 100).
@@ -61,12 +61,7 @@ export async function loadConfig(
61
61
  ): Promise<NewprConfig> {
62
62
  const stored = await (_readStore ?? readStoredConfig)();
63
63
 
64
- const apiKey = process.env.OPENROUTER_API_KEY || stored.openrouter_api_key;
65
- if (!apiKey) {
66
- throw new Error(
67
- "OPENROUTER_API_KEY is not set. Run `newpr auth` to configure, or set the environment variable.",
68
- );
69
- }
64
+ const apiKey = process.env.OPENROUTER_API_KEY || stored.openrouter_api_key || "";
70
65
 
71
66
  const agentVal = stored.agent as NewprConfig["agent"];
72
67
  const rawLang = process.env.NEWPR_LANGUAGE || stored.language || DEFAULT_CONFIG.language;
@@ -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);
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
 
@@ -137,6 +192,7 @@ function topologicalSort(
137
192
  groups: string[],
138
193
  edges: ConstraintEdge[],
139
194
  deltas: DeltaEntry[],
195
+ ownership?: Map<string, string>,
140
196
  ): FeasibilityResult {
141
197
  const inDegree = new Map<string, number>();
142
198
  const adjacency = new Map<string, string[]>();
@@ -156,7 +212,7 @@ function topologicalSort(
156
212
  edgeMap.set(`${edge.from}→${edge.to}`, edge);
157
213
  }
158
214
 
159
- const firstCommitDate = buildFirstCommitDateMap(groups, deltas);
215
+ const firstCommitDate = buildFirstCommitDateMap(groups, deltas, ownership);
160
216
 
161
217
  const queue: string[] = [];
162
218
  for (const [g, deg] of inDegree) {
@@ -200,6 +256,7 @@ function topologicalSort(
200
256
  function buildFirstCommitDateMap(
201
257
  groups: string[],
202
258
  deltas: DeltaEntry[],
259
+ ownership?: Map<string, string>,
203
260
  ): Map<string, string> {
204
261
  const result = new Map<string, string>();
205
262
  for (const g of groups) {
@@ -207,12 +264,11 @@ function buildFirstCommitDateMap(
207
264
  }
208
265
  for (const delta of deltas) {
209
266
  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
- }
267
+ const groupId = ownership?.get(change.path);
268
+ if (!groupId) continue;
269
+ const current = result.get(groupId);
270
+ if (current && delta.date < current) {
271
+ result.set(groupId, delta.date);
216
272
  }
217
273
  }
218
274
  }
@@ -233,8 +289,8 @@ function tieBreaker(
233
289
  function findMinimalCycle(
234
290
  allGroups: string[],
235
291
  adjacency: Map<string, string[]>,
236
- sorted: string[],
237
- edgeMap: Map<string, ConstraintEdge>,
292
+ sorted: string[] = [],
293
+ edgeMap: Map<string, ConstraintEdge> = new Map(),
238
294
  ): CycleReport {
239
295
  const inCycle = new Set(allGroups.filter((g) => !sorted.includes(g)));
240
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
  });