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.
@@ -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✅ ${sortedPRs.length} PRs planned:`);
135
- sortedPRs.forEach((pr) => {
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 sortedPRs) {
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: sortedPRs.length,
183
- pr_names: sortedPRs.map((pr) => pr.name),
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, sortedPRs, result.summary ||
190
- `Split feature into ${sortedPRs.length} pull requests for review`, result.rationale);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.39.2",
3
+ "version": "0.39.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"