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.
- package/package.json +2 -1
- package/src/cli/args.ts +1 -1
- package/src/config/index.ts +1 -6
- package/src/stack/co-change.ts +64 -0
- package/src/stack/confidence-score.ts +242 -0
- package/src/stack/feasibility.test.ts +6 -7
- package/src/stack/feasibility.ts +86 -30
- 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/symbol-flow.ts +229 -0
- package/src/web/client/components/AnalyticsConsent.tsx +13 -13
- 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/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/StoryPanel.tsx +25 -25
- package/src/web/index.html +2 -0
- package/src/web/server/routes.ts +6 -4
- package/src/web/server/stack-manager.ts +103 -7
- 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.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
|
|
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).
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -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
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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(
|
|
264
|
-
expect(feasibility.
|
|
263
|
+
expect(feasibility.feasible).toBe(true);
|
|
264
|
+
expect(feasibility.ordered_group_ids).toHaveLength(2);
|
|
265
265
|
});
|
|
266
266
|
});
|