edsger 0.39.2 → 0.39.3
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/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
- package/dist/phases/pr-splitting/import-dep-validator.d.ts +67 -0
- package/dist/phases/pr-splitting/import-dep-validator.js +236 -0
- package/dist/phases/pr-splitting/index.js +12 -7
- package/dist/skills/phase/pr-execution/SKILL.md +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { autoFixPROrdering, getTransitiveDependencies, parseImportSpecifiers, resolveImportToChangedFile, } from '../import-dep-validator.js';
|
|
4
|
+
// ============================================================
|
|
5
|
+
// parseImportSpecifiers
|
|
6
|
+
// ============================================================
|
|
7
|
+
void describe('parseImportSpecifiers', () => {
|
|
8
|
+
void it('parses named imports', () => {
|
|
9
|
+
const result = parseImportSpecifiers(`import { foo } from './utils'`);
|
|
10
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
11
|
+
});
|
|
12
|
+
void it('parses default imports', () => {
|
|
13
|
+
const result = parseImportSpecifiers(`import foo from './utils'`);
|
|
14
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
15
|
+
});
|
|
16
|
+
void it('parses namespace imports', () => {
|
|
17
|
+
const result = parseImportSpecifiers(`import * as foo from './utils'`);
|
|
18
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
19
|
+
});
|
|
20
|
+
void it('parses side-effect imports', () => {
|
|
21
|
+
const result = parseImportSpecifiers(`import './polyfill'`);
|
|
22
|
+
assert.deepStrictEqual(result, ['./polyfill']);
|
|
23
|
+
});
|
|
24
|
+
void it('parses type-only imports', () => {
|
|
25
|
+
const result = parseImportSpecifiers(`import type { Foo } from './types'`);
|
|
26
|
+
assert.deepStrictEqual(result, ['./types']);
|
|
27
|
+
});
|
|
28
|
+
void it('parses re-exports', () => {
|
|
29
|
+
const result = parseImportSpecifiers(`export { foo } from './utils'`);
|
|
30
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
31
|
+
});
|
|
32
|
+
void it('parses star re-exports', () => {
|
|
33
|
+
const result = parseImportSpecifiers(`export * from './utils'`);
|
|
34
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
35
|
+
});
|
|
36
|
+
void it('parses dynamic imports', () => {
|
|
37
|
+
const result = parseImportSpecifiers(`const m = import('./lazy')`);
|
|
38
|
+
assert.deepStrictEqual(result, ['./lazy']);
|
|
39
|
+
});
|
|
40
|
+
void it('parses multiple imports', () => {
|
|
41
|
+
const source = `
|
|
42
|
+
import { foo } from './utils'
|
|
43
|
+
import Bar from '../components/Bar'
|
|
44
|
+
import type { Baz } from './types'
|
|
45
|
+
`;
|
|
46
|
+
const result = parseImportSpecifiers(source);
|
|
47
|
+
assert.deepStrictEqual(result, ['./utils', '../components/Bar', './types']);
|
|
48
|
+
});
|
|
49
|
+
void it('filters out non-relative imports', () => {
|
|
50
|
+
const source = `
|
|
51
|
+
import { X } from 'lodash'
|
|
52
|
+
import React from 'react'
|
|
53
|
+
import { Y } from './local'
|
|
54
|
+
import { Z } from '@scope/package'
|
|
55
|
+
`;
|
|
56
|
+
const result = parseImportSpecifiers(source);
|
|
57
|
+
assert.deepStrictEqual(result, ['./local']);
|
|
58
|
+
});
|
|
59
|
+
void it('parses multiline imports', () => {
|
|
60
|
+
const source = `import {
|
|
61
|
+
foo,
|
|
62
|
+
bar,
|
|
63
|
+
baz
|
|
64
|
+
} from './utils'`;
|
|
65
|
+
const result = parseImportSpecifiers(source);
|
|
66
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
67
|
+
});
|
|
68
|
+
void it('deduplicates specifiers', () => {
|
|
69
|
+
const source = `
|
|
70
|
+
import { foo } from './utils'
|
|
71
|
+
import { bar } from './utils'
|
|
72
|
+
`;
|
|
73
|
+
const result = parseImportSpecifiers(source);
|
|
74
|
+
assert.deepStrictEqual(result, ['./utils']);
|
|
75
|
+
});
|
|
76
|
+
void it('handles .js extension in specifier', () => {
|
|
77
|
+
const result = parseImportSpecifiers(`import { X } from './utils.js'`);
|
|
78
|
+
assert.deepStrictEqual(result, ['./utils.js']);
|
|
79
|
+
});
|
|
80
|
+
void it('returns empty for empty source', () => {
|
|
81
|
+
assert.deepStrictEqual(parseImportSpecifiers(''), []);
|
|
82
|
+
});
|
|
83
|
+
void it('returns empty for non-relative only', () => {
|
|
84
|
+
const source = `import express from 'express'`;
|
|
85
|
+
assert.deepStrictEqual(parseImportSpecifiers(source), []);
|
|
86
|
+
});
|
|
87
|
+
void it('parses export default from', () => {
|
|
88
|
+
const result = parseImportSpecifiers(`export { default as X } from './module'`);
|
|
89
|
+
assert.deepStrictEqual(result, ['./module']);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ============================================================
|
|
93
|
+
// resolveImportToChangedFile
|
|
94
|
+
// ============================================================
|
|
95
|
+
void describe('resolveImportToChangedFile', () => {
|
|
96
|
+
void it('resolves exact match with extension', () => {
|
|
97
|
+
const changed = new Set(['src/utils.ts']);
|
|
98
|
+
const result = resolveImportToChangedFile('./utils.ts', 'src/app.ts', changed);
|
|
99
|
+
assert.strictEqual(result, 'src/utils.ts');
|
|
100
|
+
});
|
|
101
|
+
void it('infers .ts extension', () => {
|
|
102
|
+
const changed = new Set(['src/utils.ts']);
|
|
103
|
+
const result = resolveImportToChangedFile('./utils', 'src/app.ts', changed);
|
|
104
|
+
assert.strictEqual(result, 'src/utils.ts');
|
|
105
|
+
});
|
|
106
|
+
void it('infers .tsx extension', () => {
|
|
107
|
+
const changed = new Set(['src/Component.tsx']);
|
|
108
|
+
const result = resolveImportToChangedFile('./Component', 'src/app.ts', changed);
|
|
109
|
+
assert.strictEqual(result, 'src/Component.tsx');
|
|
110
|
+
});
|
|
111
|
+
void it('resolves index file', () => {
|
|
112
|
+
const changed = new Set(['src/types/index.ts']);
|
|
113
|
+
const result = resolveImportToChangedFile('./types', 'src/app.ts', changed);
|
|
114
|
+
assert.strictEqual(result, 'src/types/index.ts');
|
|
115
|
+
});
|
|
116
|
+
void it('resolves .js to .ts (ESM convention)', () => {
|
|
117
|
+
const changed = new Set(['src/utils.ts']);
|
|
118
|
+
const result = resolveImportToChangedFile('./utils.js', 'src/app.ts', changed);
|
|
119
|
+
assert.strictEqual(result, 'src/utils.ts');
|
|
120
|
+
});
|
|
121
|
+
void it('resolves parent directory traversal', () => {
|
|
122
|
+
const changed = new Set(['src/shared/helpers.ts']);
|
|
123
|
+
const result = resolveImportToChangedFile('../shared/helpers', 'src/components/Button.ts', changed);
|
|
124
|
+
assert.strictEqual(result, 'src/shared/helpers.ts');
|
|
125
|
+
});
|
|
126
|
+
void it('resolves double parent traversal', () => {
|
|
127
|
+
const changed = new Set(['src/utils.ts']);
|
|
128
|
+
const result = resolveImportToChangedFile('../../utils', 'src/features/explore/App.ts', changed);
|
|
129
|
+
assert.strictEqual(result, 'src/utils.ts');
|
|
130
|
+
});
|
|
131
|
+
void it('returns null for non-changed file', () => {
|
|
132
|
+
const changed = new Set(['src/other.ts']);
|
|
133
|
+
const result = resolveImportToChangedFile('./utils', 'src/app.ts', changed);
|
|
134
|
+
assert.strictEqual(result, null);
|
|
135
|
+
});
|
|
136
|
+
void it('returns null for external package import path', () => {
|
|
137
|
+
// This shouldn't happen since parseImportSpecifiers filters these,
|
|
138
|
+
// but test defensive behavior
|
|
139
|
+
const changed = new Set(['src/utils.ts']);
|
|
140
|
+
const result = resolveImportToChangedFile('./nonexistent', 'src/app.ts', changed);
|
|
141
|
+
assert.strictEqual(result, null);
|
|
142
|
+
});
|
|
143
|
+
void it('prefers .ts over .tsx when both exist', () => {
|
|
144
|
+
const changed = new Set(['src/utils.ts', 'src/utils.tsx']);
|
|
145
|
+
const result = resolveImportToChangedFile('./utils', 'src/app.ts', changed);
|
|
146
|
+
assert.strictEqual(result, 'src/utils.ts');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ============================================================
|
|
150
|
+
// getTransitiveDependencies
|
|
151
|
+
// ============================================================
|
|
152
|
+
void describe('getTransitiveDependencies', () => {
|
|
153
|
+
void it('returns direct dependency', () => {
|
|
154
|
+
const graph = new Map([
|
|
155
|
+
['A', new Set(['B'])],
|
|
156
|
+
['B', new Set()],
|
|
157
|
+
]);
|
|
158
|
+
const result = getTransitiveDependencies('A', graph);
|
|
159
|
+
assert.deepStrictEqual(result, new Set(['B']));
|
|
160
|
+
});
|
|
161
|
+
void it('returns transitive dependencies', () => {
|
|
162
|
+
const graph = new Map([
|
|
163
|
+
['A', new Set(['B'])],
|
|
164
|
+
['B', new Set(['C'])],
|
|
165
|
+
['C', new Set()],
|
|
166
|
+
]);
|
|
167
|
+
const result = getTransitiveDependencies('A', graph);
|
|
168
|
+
assert.deepStrictEqual(result, new Set(['B', 'C']));
|
|
169
|
+
});
|
|
170
|
+
void it('handles diamond dependency', () => {
|
|
171
|
+
const graph = new Map([
|
|
172
|
+
['A', new Set(['B', 'C'])],
|
|
173
|
+
['B', new Set(['D'])],
|
|
174
|
+
['C', new Set(['D'])],
|
|
175
|
+
['D', new Set()],
|
|
176
|
+
]);
|
|
177
|
+
const result = getTransitiveDependencies('A', graph);
|
|
178
|
+
assert.deepStrictEqual(result, new Set(['B', 'C', 'D']));
|
|
179
|
+
});
|
|
180
|
+
void it('handles cycles without infinite loop', () => {
|
|
181
|
+
const graph = new Map([
|
|
182
|
+
['A', new Set(['B'])],
|
|
183
|
+
['B', new Set(['A'])],
|
|
184
|
+
]);
|
|
185
|
+
const result = getTransitiveDependencies('A', graph);
|
|
186
|
+
assert.deepStrictEqual(result, new Set(['B', 'A']));
|
|
187
|
+
});
|
|
188
|
+
void it('returns empty set for no dependencies', () => {
|
|
189
|
+
const graph = new Map([['A', new Set()]]);
|
|
190
|
+
const result = getTransitiveDependencies('A', graph);
|
|
191
|
+
assert.deepStrictEqual(result, new Set());
|
|
192
|
+
});
|
|
193
|
+
void it('returns empty set for unknown file', () => {
|
|
194
|
+
const graph = new Map();
|
|
195
|
+
const result = getTransitiveDependencies('unknown', graph);
|
|
196
|
+
assert.deepStrictEqual(result, new Set());
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
// ============================================================
|
|
200
|
+
// autoFixPROrdering
|
|
201
|
+
// ============================================================
|
|
202
|
+
function makePR(sequence, name, files, dependsOn) {
|
|
203
|
+
return {
|
|
204
|
+
sequence,
|
|
205
|
+
name,
|
|
206
|
+
description: name,
|
|
207
|
+
branch_name: `pr/feat/${sequence}-${name.toLowerCase().replace(/\s/g, '-')}`,
|
|
208
|
+
depends_on_branch_name: dependsOn ?? null,
|
|
209
|
+
files: files.map((f) => ({ path: f, change_type: 'modified' })),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
void describe('autoFixPROrdering', () => {
|
|
213
|
+
void it('returns unchanged when no violations', () => {
|
|
214
|
+
const prs = [
|
|
215
|
+
makePR(1, 'Foundation', ['src/utils.ts']),
|
|
216
|
+
makePR(2, 'Components', ['src/app.ts'], 'pr/feat/1-foundation'),
|
|
217
|
+
];
|
|
218
|
+
// app.ts depends on utils.ts, and utils.ts is in PR 1 (earlier) — OK
|
|
219
|
+
const graph = new Map([
|
|
220
|
+
['src/app.ts', new Set(['src/utils.ts'])],
|
|
221
|
+
['src/utils.ts', new Set()],
|
|
222
|
+
]);
|
|
223
|
+
const result = autoFixPROrdering(prs, graph);
|
|
224
|
+
assert.strictEqual(result.movedFiles.length, 0);
|
|
225
|
+
assert.strictEqual(result.pullRequests[0].files?.length, 1);
|
|
226
|
+
assert.strictEqual(result.pullRequests[1].files?.length, 1);
|
|
227
|
+
});
|
|
228
|
+
void it('moves dependency from later PR to earlier PR', () => {
|
|
229
|
+
const prs = [
|
|
230
|
+
makePR(1, 'Components', ['src/app.ts']),
|
|
231
|
+
makePR(2, 'Utils', ['src/utils.ts'], 'pr/feat/1-components'),
|
|
232
|
+
];
|
|
233
|
+
// app.ts (PR 1) depends on utils.ts (PR 2) — violation!
|
|
234
|
+
const graph = new Map([
|
|
235
|
+
['src/app.ts', new Set(['src/utils.ts'])],
|
|
236
|
+
['src/utils.ts', new Set()],
|
|
237
|
+
]);
|
|
238
|
+
const result = autoFixPROrdering(prs, graph);
|
|
239
|
+
assert.strictEqual(result.movedFiles.length, 1);
|
|
240
|
+
assert.strictEqual(result.movedFiles[0].file, 'src/utils.ts');
|
|
241
|
+
// utils.ts should now be in PR 1
|
|
242
|
+
const pr1Files = result.pullRequests[0].files?.map((f) => f.path) ?? [];
|
|
243
|
+
assert.ok(pr1Files.includes('src/utils.ts'));
|
|
244
|
+
assert.ok(pr1Files.includes('src/app.ts'));
|
|
245
|
+
// PR 2 should be empty
|
|
246
|
+
assert.strictEqual(result.pullRequests[1].files?.length, 0);
|
|
247
|
+
});
|
|
248
|
+
void it('handles transitive dependency moves', () => {
|
|
249
|
+
const prs = [
|
|
250
|
+
makePR(1, 'App', ['src/app.ts']),
|
|
251
|
+
makePR(2, 'Service', ['src/service.ts'], 'pr/feat/1-app'),
|
|
252
|
+
makePR(3, 'Utils', ['src/utils.ts'], 'pr/feat/2-service'),
|
|
253
|
+
];
|
|
254
|
+
// app.ts → service.ts → utils.ts (chain across 3 PRs)
|
|
255
|
+
const graph = new Map([
|
|
256
|
+
['src/app.ts', new Set(['src/service.ts'])],
|
|
257
|
+
['src/service.ts', new Set(['src/utils.ts'])],
|
|
258
|
+
['src/utils.ts', new Set()],
|
|
259
|
+
]);
|
|
260
|
+
const result = autoFixPROrdering(prs, graph);
|
|
261
|
+
// Both service.ts and utils.ts should move to PR 1
|
|
262
|
+
const pr1Files = result.pullRequests[0].files?.map((f) => f.path) ?? [];
|
|
263
|
+
assert.ok(pr1Files.includes('src/app.ts'));
|
|
264
|
+
assert.ok(pr1Files.includes('src/service.ts'));
|
|
265
|
+
assert.ok(pr1Files.includes('src/utils.ts'));
|
|
266
|
+
});
|
|
267
|
+
void it('moves dep to earliest PR that needs it', () => {
|
|
268
|
+
const prs = [
|
|
269
|
+
makePR(1, 'PR1', ['src/a.ts']),
|
|
270
|
+
makePR(2, 'PR2', ['src/b.ts'], 'pr/feat/1-pr1'),
|
|
271
|
+
makePR(3, 'PR3', ['src/utils.ts'], 'pr/feat/2-pr2'),
|
|
272
|
+
];
|
|
273
|
+
// Both a.ts (PR 1) and b.ts (PR 2) import utils.ts (PR 3)
|
|
274
|
+
const graph = new Map([
|
|
275
|
+
['src/a.ts', new Set(['src/utils.ts'])],
|
|
276
|
+
['src/b.ts', new Set(['src/utils.ts'])],
|
|
277
|
+
['src/utils.ts', new Set()],
|
|
278
|
+
]);
|
|
279
|
+
const result = autoFixPROrdering(prs, graph);
|
|
280
|
+
// utils.ts should move to PR 1 (earliest needer)
|
|
281
|
+
const pr1Files = result.pullRequests[0].files?.map((f) => f.path) ?? [];
|
|
282
|
+
assert.ok(pr1Files.includes('src/utils.ts'));
|
|
283
|
+
});
|
|
284
|
+
void it('handles no dependencies at all', () => {
|
|
285
|
+
const prs = [
|
|
286
|
+
makePR(1, 'PR1', ['src/a.ts']),
|
|
287
|
+
makePR(2, 'PR2', ['src/b.ts'], 'pr/feat/1-pr1'),
|
|
288
|
+
];
|
|
289
|
+
const graph = new Map([
|
|
290
|
+
['src/a.ts', new Set()],
|
|
291
|
+
['src/b.ts', new Set()],
|
|
292
|
+
]);
|
|
293
|
+
const result = autoFixPROrdering(prs, graph);
|
|
294
|
+
assert.strictEqual(result.movedFiles.length, 0);
|
|
295
|
+
});
|
|
296
|
+
void it('handles single PR with all files', () => {
|
|
297
|
+
const prs = [makePR(1, 'Everything', ['src/a.ts', 'src/b.ts', 'src/c.ts'])];
|
|
298
|
+
const graph = new Map([
|
|
299
|
+
['src/a.ts', new Set(['src/b.ts'])],
|
|
300
|
+
['src/b.ts', new Set(['src/c.ts'])],
|
|
301
|
+
['src/c.ts', new Set()],
|
|
302
|
+
]);
|
|
303
|
+
const result = autoFixPROrdering(prs, graph);
|
|
304
|
+
assert.strictEqual(result.movedFiles.length, 0);
|
|
305
|
+
});
|
|
306
|
+
void it('does not mutate original PR files arrays', () => {
|
|
307
|
+
const originalFiles = [{ path: 'src/app.ts', change_type: 'modified' }];
|
|
308
|
+
const prs = [
|
|
309
|
+
{
|
|
310
|
+
sequence: 1,
|
|
311
|
+
name: 'PR1',
|
|
312
|
+
description: 'PR1',
|
|
313
|
+
files: originalFiles,
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
sequence: 2,
|
|
317
|
+
name: 'PR2',
|
|
318
|
+
description: 'PR2',
|
|
319
|
+
files: [{ path: 'src/utils.ts', change_type: 'modified' }],
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
const graph = new Map([
|
|
323
|
+
['src/app.ts', new Set(['src/utils.ts'])],
|
|
324
|
+
['src/utils.ts', new Set()],
|
|
325
|
+
]);
|
|
326
|
+
autoFixPROrdering(prs, graph);
|
|
327
|
+
// Original files array should not be modified
|
|
328
|
+
assert.strictEqual(originalFiles.length, 1);
|
|
329
|
+
assert.strictEqual(originalFiles[0].path, 'src/app.ts');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import dependency validator for PR splitting
|
|
3
|
+
*
|
|
4
|
+
* After the AI produces a PR split plan, this module analyzes import
|
|
5
|
+
* dependencies between changed files and auto-fixes ordering violations
|
|
6
|
+
* by moving dependency files to earlier PRs in the stack.
|
|
7
|
+
*/
|
|
8
|
+
import { type PlannedPullRequest } from './index.js';
|
|
9
|
+
/** A record of a file moved between PRs during auto-fix */
|
|
10
|
+
export interface MovedFile {
|
|
11
|
+
file: string;
|
|
12
|
+
fromPR: string;
|
|
13
|
+
toPR: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
/** Result of the validation + auto-fix pass */
|
|
17
|
+
export interface ImportValidationResult {
|
|
18
|
+
/** Whether any files were moved between PRs */
|
|
19
|
+
modified: boolean;
|
|
20
|
+
/** The (potentially reordered) PR list */
|
|
21
|
+
pullRequests: PlannedPullRequest[];
|
|
22
|
+
/** Human-readable log of what was moved */
|
|
23
|
+
movedFiles: MovedFile[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Main entry point: validate and auto-fix import dependency ordering
|
|
27
|
+
* across a planned PR stack.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateAndFixImportDependencies(pullRequests: PlannedPullRequest[], changedFiles: string[], devBranchRef: string, verbose?: boolean): ImportValidationResult;
|
|
30
|
+
/**
|
|
31
|
+
* Read a file's contents from a git ref without checkout.
|
|
32
|
+
* Returns empty string if file doesn't exist at that ref.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readFileAtRef(ref: string, filePath: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Parse all relative import/export specifiers from TypeScript/JavaScript source.
|
|
37
|
+
* Returns deduplicated relative specifier strings (e.g., './utils', '../types').
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseImportSpecifiers(sourceCode: string): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a relative import specifier to one of the changed files.
|
|
42
|
+
* Handles extension resolution (.ts, .tsx, .js, .jsx) and /index variants.
|
|
43
|
+
* Also handles .js→.ts mapping (ESM convention where imports use .js extension
|
|
44
|
+
* but actual source files are .ts).
|
|
45
|
+
* Returns the matched changed-file path or null.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveImportToChangedFile(specifier: string, importingFilePath: string, changedFilesSet: Set<string>): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Build a dependency graph: for each changed file, which other changed files it imports.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildDependencyGraph(changedFiles: string[], devBranchRef: string): Map<string, Set<string>>;
|
|
52
|
+
/**
|
|
53
|
+
* Compute transitive closure: given a file, return all files it transitively depends on.
|
|
54
|
+
* Handles cycles without infinite loops.
|
|
55
|
+
*/
|
|
56
|
+
export declare function getTransitiveDependencies(file: string, graph: Map<string, Set<string>>): Set<string>;
|
|
57
|
+
/**
|
|
58
|
+
* Given PRs sorted by sequence and a dependency graph, detect violations
|
|
59
|
+
* and auto-fix by moving files to earlier PRs.
|
|
60
|
+
*
|
|
61
|
+
* A violation occurs when file A (in PR N) imports from file B (in PR M, M > N).
|
|
62
|
+
* Fix: move B to PR N so it's available when PR N's branch is created.
|
|
63
|
+
*/
|
|
64
|
+
export declare function autoFixPROrdering(pullRequests: PlannedPullRequest[], dependencyGraph: Map<string, Set<string>>): {
|
|
65
|
+
pullRequests: PlannedPullRequest[];
|
|
66
|
+
movedFiles: MovedFile[];
|
|
67
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import dependency validator for PR splitting
|
|
3
|
+
*
|
|
4
|
+
* After the AI produces a PR split plan, this module analyzes import
|
|
5
|
+
* dependencies between changed files and auto-fixes ordering violations
|
|
6
|
+
* by moving dependency files to earlier PRs in the stack.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
9
|
+
import { dirname, join, normalize } from 'path';
|
|
10
|
+
import { logInfo, logWarning } from '../../utils/logger.js';
|
|
11
|
+
/**
|
|
12
|
+
* Main entry point: validate and auto-fix import dependency ordering
|
|
13
|
+
* across a planned PR stack.
|
|
14
|
+
*/
|
|
15
|
+
export function validateAndFixImportDependencies(pullRequests, changedFiles, devBranchRef, verbose) {
|
|
16
|
+
const graph = buildDependencyGraph(changedFiles, devBranchRef);
|
|
17
|
+
const { pullRequests: fixed, movedFiles } = autoFixPROrdering(pullRequests, graph);
|
|
18
|
+
if (verbose && movedFiles.length > 0) {
|
|
19
|
+
logInfo(`\n🔧 Import dependency auto-fix: moved ${movedFiles.length} file(s)`);
|
|
20
|
+
for (const move of movedFiles) {
|
|
21
|
+
logInfo(` ${move.file}: "${move.fromPR}" → "${move.toPR}"`);
|
|
22
|
+
logInfo(` Reason: ${move.reason}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
modified: movedFiles.length > 0,
|
|
27
|
+
pullRequests: fixed,
|
|
28
|
+
movedFiles,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read a file's contents from a git ref without checkout.
|
|
33
|
+
* Returns empty string if file doesn't exist at that ref.
|
|
34
|
+
*/
|
|
35
|
+
export function readFileAtRef(ref, filePath) {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync('git', ['show', `${ref}:${filePath}`], {
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const PARSABLE_EXTENSIONS = /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/;
|
|
47
|
+
/**
|
|
48
|
+
* Parse all relative import/export specifiers from TypeScript/JavaScript source.
|
|
49
|
+
* Returns deduplicated relative specifier strings (e.g., './utils', '../types').
|
|
50
|
+
*/
|
|
51
|
+
export function parseImportSpecifiers(sourceCode) {
|
|
52
|
+
const specifiers = [];
|
|
53
|
+
// 1. Static imports/exports with `from` clause:
|
|
54
|
+
// import { X } from './path'
|
|
55
|
+
// import X from './path'
|
|
56
|
+
// import * as X from './path'
|
|
57
|
+
// import type { X } from './path'
|
|
58
|
+
// export { X } from './path'
|
|
59
|
+
// export * from './path'
|
|
60
|
+
const fromPattern = /(?:import|export)\s[\s\S]*?\bfrom\s*['"]([^'"]+)['"]/g;
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = fromPattern.exec(sourceCode)) !== null) {
|
|
63
|
+
specifiers.push(match[1]);
|
|
64
|
+
}
|
|
65
|
+
// 2. Side-effect imports: import './polyfill'
|
|
66
|
+
// Must use line-start anchor to avoid matching "import X from '...'"
|
|
67
|
+
const sideEffectPattern = /^\s*import\s+['"]([^'"]+)['"]/gm;
|
|
68
|
+
while ((match = sideEffectPattern.exec(sourceCode)) !== null) {
|
|
69
|
+
specifiers.push(match[1]);
|
|
70
|
+
}
|
|
71
|
+
// 3. Dynamic imports: import('./path')
|
|
72
|
+
const dynamicPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
73
|
+
while ((match = dynamicPattern.exec(sourceCode)) !== null) {
|
|
74
|
+
specifiers.push(match[1]);
|
|
75
|
+
}
|
|
76
|
+
// Deduplicate and filter to relative imports only
|
|
77
|
+
return [...new Set(specifiers)].filter((s) => s.startsWith('./') || s.startsWith('../'));
|
|
78
|
+
}
|
|
79
|
+
/** Extensions to try when resolving imports (order matters) */
|
|
80
|
+
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
81
|
+
const INDEX_SUFFIXES = EXTENSIONS.map((ext) => `/index${ext}`);
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a relative import specifier to one of the changed files.
|
|
84
|
+
* Handles extension resolution (.ts, .tsx, .js, .jsx) and /index variants.
|
|
85
|
+
* Also handles .js→.ts mapping (ESM convention where imports use .js extension
|
|
86
|
+
* but actual source files are .ts).
|
|
87
|
+
* Returns the matched changed-file path or null.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveImportToChangedFile(specifier, importingFilePath, changedFilesSet) {
|
|
90
|
+
const importingDir = dirname(importingFilePath);
|
|
91
|
+
const resolved = normalize(join(importingDir, specifier));
|
|
92
|
+
// 1. Exact match (specifier already has extension)
|
|
93
|
+
if (changedFilesSet.has(resolved)) {
|
|
94
|
+
return resolved;
|
|
95
|
+
}
|
|
96
|
+
// 2. Try appending extensions: ./utils → ./utils.ts, ./utils.tsx, etc.
|
|
97
|
+
for (const ext of EXTENSIONS) {
|
|
98
|
+
const candidate = resolved + ext;
|
|
99
|
+
if (changedFilesSet.has(candidate)) {
|
|
100
|
+
return candidate;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 3. Try as directory with index file: ./types → ./types/index.ts, etc.
|
|
104
|
+
for (const suffix of INDEX_SUFFIXES) {
|
|
105
|
+
const candidate = resolved + suffix;
|
|
106
|
+
if (changedFilesSet.has(candidate)) {
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 4. Handle .js/.jsx extension in specifier (ESM convention:
|
|
111
|
+
// `import from './utils.js'` actually resolves to utils.ts at compile time)
|
|
112
|
+
if (/\.jsx?$/.test(resolved)) {
|
|
113
|
+
const withoutExt = resolved.replace(/\.jsx?$/, '');
|
|
114
|
+
for (const ext of EXTENSIONS) {
|
|
115
|
+
const candidate = withoutExt + ext;
|
|
116
|
+
if (changedFilesSet.has(candidate)) {
|
|
117
|
+
return candidate;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build a dependency graph: for each changed file, which other changed files it imports.
|
|
125
|
+
*/
|
|
126
|
+
export function buildDependencyGraph(changedFiles, devBranchRef) {
|
|
127
|
+
const changedFilesSet = new Set(changedFiles);
|
|
128
|
+
const graph = new Map();
|
|
129
|
+
for (const filePath of changedFiles) {
|
|
130
|
+
const deps = new Set();
|
|
131
|
+
graph.set(filePath, deps);
|
|
132
|
+
if (!PARSABLE_EXTENSIONS.test(filePath)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const content = readFileAtRef(devBranchRef, filePath);
|
|
136
|
+
if (!content) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const specifiers = parseImportSpecifiers(content);
|
|
140
|
+
for (const specifier of specifiers) {
|
|
141
|
+
const resolved = resolveImportToChangedFile(specifier, filePath, changedFilesSet);
|
|
142
|
+
if (resolved && resolved !== filePath) {
|
|
143
|
+
deps.add(resolved);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return graph;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Compute transitive closure: given a file, return all files it transitively depends on.
|
|
151
|
+
* Handles cycles without infinite loops.
|
|
152
|
+
*/
|
|
153
|
+
export function getTransitiveDependencies(file, graph) {
|
|
154
|
+
const result = new Set();
|
|
155
|
+
const stack = [file];
|
|
156
|
+
while (stack.length > 0) {
|
|
157
|
+
const current = stack.pop();
|
|
158
|
+
const deps = graph.get(current);
|
|
159
|
+
if (!deps)
|
|
160
|
+
continue;
|
|
161
|
+
for (const dep of deps) {
|
|
162
|
+
if (!result.has(dep)) {
|
|
163
|
+
result.add(dep);
|
|
164
|
+
stack.push(dep);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
const MAX_FIX_ITERATIONS = 100;
|
|
171
|
+
/**
|
|
172
|
+
* Given PRs sorted by sequence and a dependency graph, detect violations
|
|
173
|
+
* and auto-fix by moving files to earlier PRs.
|
|
174
|
+
*
|
|
175
|
+
* A violation occurs when file A (in PR N) imports from file B (in PR M, M > N).
|
|
176
|
+
* Fix: move B to PR N so it's available when PR N's branch is created.
|
|
177
|
+
*/
|
|
178
|
+
export function autoFixPROrdering(pullRequests, dependencyGraph) {
|
|
179
|
+
const sorted = [...pullRequests].sort((a, b) => a.sequence - b.sequence);
|
|
180
|
+
const movedFiles = [];
|
|
181
|
+
// Deep-clone files arrays so we don't mutate the original
|
|
182
|
+
for (const pr of sorted) {
|
|
183
|
+
pr.files = pr.files ? [...pr.files] : [];
|
|
184
|
+
}
|
|
185
|
+
// Build mutable file → PR index mapping (0-based, lower = earlier)
|
|
186
|
+
const fileToPRIndex = new Map();
|
|
187
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
188
|
+
for (const file of sorted[i].files ?? []) {
|
|
189
|
+
fileToPRIndex.set(file.path, i);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Fixed-point loop: keep moving files until no more violations
|
|
193
|
+
let changed = true;
|
|
194
|
+
let iterations = 0;
|
|
195
|
+
while (changed && iterations < MAX_FIX_ITERATIONS) {
|
|
196
|
+
changed = false;
|
|
197
|
+
iterations++;
|
|
198
|
+
for (let prIdx = 0; prIdx < sorted.length; prIdx++) {
|
|
199
|
+
const pr = sorted[prIdx];
|
|
200
|
+
for (const file of pr.files ?? []) {
|
|
201
|
+
const transitiveDeps = getTransitiveDependencies(file.path, dependencyGraph);
|
|
202
|
+
for (const dep of transitiveDeps) {
|
|
203
|
+
const depPRIdx = fileToPRIndex.get(dep);
|
|
204
|
+
if (depPRIdx === undefined || depPRIdx <= prIdx)
|
|
205
|
+
continue;
|
|
206
|
+
// Violation: dep is in a later PR but needed here
|
|
207
|
+
const sourcePR = sorted[depPRIdx];
|
|
208
|
+
const targetPR = sorted[prIdx];
|
|
209
|
+
const depFileEntry = sourcePR.files?.find((f) => f.path === dep);
|
|
210
|
+
if (!depFileEntry)
|
|
211
|
+
continue;
|
|
212
|
+
// Move from source to target
|
|
213
|
+
sourcePR.files = sourcePR.files?.filter((f) => f.path !== dep) ?? [];
|
|
214
|
+
if (!targetPR.files)
|
|
215
|
+
targetPR.files = [];
|
|
216
|
+
targetPR.files.push(depFileEntry);
|
|
217
|
+
fileToPRIndex.set(dep, prIdx);
|
|
218
|
+
movedFiles.push({
|
|
219
|
+
file: dep,
|
|
220
|
+
fromPR: `PR #${sourcePR.sequence} (${sourcePR.name})`,
|
|
221
|
+
toPR: `PR #${targetPR.sequence} (${targetPR.name})`,
|
|
222
|
+
reason: `imported by ${file.path}`,
|
|
223
|
+
});
|
|
224
|
+
changed = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Warn about empty PRs
|
|
230
|
+
for (const pr of sorted) {
|
|
231
|
+
if (!pr.files || pr.files.length === 0) {
|
|
232
|
+
logWarning(`PR "${pr.name}" (seq ${pr.sequence}) has no files after import dependency auto-fix`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { pullRequests: sorted, movedFiles };
|
|
236
|
+
}
|
|
@@ -5,6 +5,7 @@ import { formatFeedbacksForContext, getFeedbacksForPhase, } from '../../services
|
|
|
5
5
|
import { clearPullRequests, createPullRequests, } from '../../services/pull-requests.js';
|
|
6
6
|
import { logDebug, logError, logInfo } from '../../utils/logger.js';
|
|
7
7
|
import { fetchPRSplittingContext } from './context.js';
|
|
8
|
+
import { validateAndFixImportDependencies } from './import-dep-validator.js';
|
|
8
9
|
import { buildErrorResult, buildNoChangeResult, buildSuccessResult, sortPRsByDependency, validatePlannedPRs, } from './outcome.js';
|
|
9
10
|
import { createImprovementPrompt, createPRSplittingPromptWithContext, createPRSplittingSystemPrompt, formatContextForPrompt, formatExistingPRsForPrompt, } from './prompts.js';
|
|
10
11
|
function userMessage(content) {
|
|
@@ -130,9 +131,12 @@ export const splitFeatureIntoPRs = async (options, config
|
|
|
130
131
|
return buildErrorResult(featureId, `Invalid PR plan: ${validation.errors.join(', ')}`);
|
|
131
132
|
}
|
|
132
133
|
const sortedPRs = sortPRsByDependency(result.pullRequests);
|
|
134
|
+
// Validate and auto-fix import dependency ordering
|
|
135
|
+
const importFixResult = validateAndFixImportDependencies(sortedPRs, context.changedFiles, context.devBranchName, verbose);
|
|
136
|
+
const finalPRs = importFixResult.pullRequests;
|
|
133
137
|
if (verbose) {
|
|
134
|
-
logInfo(`\n✅ ${
|
|
135
|
-
|
|
138
|
+
logInfo(`\n✅ ${finalPRs.length} PRs planned:`);
|
|
139
|
+
finalPRs.forEach((pr) => {
|
|
136
140
|
logInfo(` ${pr.sequence}. ${pr.name} (${pr.branch_name})`);
|
|
137
141
|
if (pr.files) {
|
|
138
142
|
logInfo(` Files: ${pr.files.map((f) => f.path).join(', ')}`);
|
|
@@ -149,7 +153,7 @@ export const splitFeatureIntoPRs = async (options, config
|
|
|
149
153
|
// Save PR plan to database
|
|
150
154
|
const prInputs = [];
|
|
151
155
|
const prBranchNameToId = new Map();
|
|
152
|
-
for (const pr of
|
|
156
|
+
for (const pr of finalPRs) {
|
|
153
157
|
let basePrId;
|
|
154
158
|
if (pr.depends_on_branch_name) {
|
|
155
159
|
basePrId = prBranchNameToId.get(pr.depends_on_branch_name);
|
|
@@ -179,15 +183,16 @@ export const splitFeatureIntoPRs = async (options, config
|
|
|
179
183
|
phase: 'pr_splitting',
|
|
180
184
|
result: 'success',
|
|
181
185
|
metadata: {
|
|
182
|
-
prs_planned:
|
|
183
|
-
pr_names:
|
|
186
|
+
prs_planned: finalPRs.length,
|
|
187
|
+
pr_names: finalPRs.map((pr) => pr.name),
|
|
188
|
+
import_deps_fixed: importFixResult.movedFiles.length,
|
|
184
189
|
mode: isIncrementalUpdate ? 'incremental_update' : 'new_planning',
|
|
185
190
|
feedbacks_addressed: feedbacksContext?.feedbacks.length || 0,
|
|
186
191
|
timestamp: new Date().toISOString(),
|
|
187
192
|
},
|
|
188
193
|
}, verbose);
|
|
189
|
-
return buildSuccessResult(featureId,
|
|
190
|
-
`Split feature into ${
|
|
194
|
+
return buildSuccessResult(featureId, finalPRs, result.summary ||
|
|
195
|
+
`Split feature into ${finalPRs.length} pull requests for review`, result.rationale);
|
|
191
196
|
}
|
|
192
197
|
catch (error) {
|
|
193
198
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -21,6 +21,7 @@ You will receive a PR plan with file assignments. Each PR specifies a **base bra
|
|
|
21
21
|
## Stacked Branches
|
|
22
22
|
|
|
23
23
|
PRs form a dependency chain (stacked PRs):
|
|
24
|
+
|
|
24
25
|
- **First PR** (no dependency): branches from `main`
|
|
25
26
|
- **Dependent PRs**: branch from the PR they depend on
|
|
26
27
|
|