@aaronshaf/confluence-cli 0.1.15 → 1.0.0

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.
@@ -1,233 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { dirname, join, normalize, relative } from 'node:path';
3
- import type { PushCandidate } from './file-scanner.js';
4
-
5
- /**
6
- * Extract all local markdown links from content.
7
- *
8
- * Matches markdown links like [text](path.md) but excludes:
9
- * - http:// and https:// URLs
10
- * - Links to non-.md files
11
- *
12
- * @param content - Markdown content to extract links from
13
- * @returns Array of relative paths (as written in the markdown)
14
- */
15
- export function extractLocalLinks(content: string): string[] {
16
- // Match markdown links: [text](path.md) or [text](path.md#anchor)
17
- // The text part can contain nested brackets (e.g., [text [nested]](link.md))
18
- // Pattern explanation:
19
- // \[([^\[\]]+(?:\[[^\]]*\][^\[\]]*)*)\] - Match [text] allowing nested brackets
20
- // \(([^)#]+\.md)(?:#[^)]*)?\) - Match (path.md) or (path.md#anchor)
21
- const linkPattern = /\[([^[\]]+(?:\[[^\]]*\][^[\]]*)*)\]\(([^)#]+\.md)(?:#[^)]*)?\)/g;
22
-
23
- const links: string[] = [];
24
- let match: RegExpExecArray | null;
25
-
26
- while ((match = linkPattern.exec(content)) !== null) {
27
- const linkPath = match[2];
28
-
29
- // Skip external URLs
30
- if (linkPath.startsWith('http://') || linkPath.startsWith('https://')) {
31
- continue;
32
- }
33
-
34
- // Remove any anchor fragments (e.g., file.md#section -> file.md)
35
- const pathWithoutAnchor = linkPath.split('#')[0];
36
-
37
- links.push(pathWithoutAnchor);
38
- }
39
-
40
- return links;
41
- }
42
-
43
- /**
44
- * Resolve a link path relative to the source file's directory.
45
- *
46
- * @param linkPath - The link path as written in markdown
47
- * @param sourceFilePath - The path of the file containing the link (relative to directory)
48
- * @returns Normalized path relative to directory root
49
- */
50
- function resolveLinkPath(linkPath: string, sourceFilePath: string): string {
51
- // Get the directory containing the source file
52
- const sourceDir = dirname(sourceFilePath);
53
-
54
- // Join and normalize the path
55
- const resolvedPath = normalize(join(sourceDir, linkPath));
56
-
57
- return resolvedPath;
58
- }
59
-
60
- /**
61
- * Sort push candidates by dependencies so linked-to pages are pushed first.
62
- *
63
- * Uses Kahn's algorithm for topological sorting with cycle detection.
64
- * If cycles are detected, the files involved in cycles are still included
65
- * in the output (in their original relative order).
66
- *
67
- * @param candidates - Array of push candidates to sort
68
- * @param directory - Root directory for resolving file paths
69
- * @returns Object containing:
70
- * - sorted: Candidates sorted so dependencies come first
71
- * - cycles: Array of detected cycles (each cycle is array of paths)
72
- */
73
- export function sortByDependencies(
74
- candidates: PushCandidate[],
75
- directory: string,
76
- ): { sorted: PushCandidate[]; cycles: string[][] } {
77
- // Build a set of candidate paths for quick lookup
78
- const candidatePaths = new Set(candidates.map((c) => c.path));
79
-
80
- // Build dependency graph: file -> files it depends on (links to)
81
- // dependsOn[A] = [B, C] means A links to B and C
82
- const dependsOn = new Map<string, Set<string>>();
83
-
84
- // Build reverse graph: file -> files that depend on it (link to it)
85
- // dependedBy[B] = [A] means A links to B
86
- const dependedBy = new Map<string, Set<string>>();
87
-
88
- // Initialize maps for all candidates
89
- for (const candidate of candidates) {
90
- dependsOn.set(candidate.path, new Set());
91
- dependedBy.set(candidate.path, new Set());
92
- }
93
-
94
- // Read each candidate's content and extract dependencies
95
- for (const candidate of candidates) {
96
- const fullPath = join(directory, candidate.path);
97
-
98
- let content: string;
99
- try {
100
- content = readFileSync(fullPath, 'utf-8');
101
- } catch {
102
- // If we can't read the file, skip dependency analysis for it
103
- continue;
104
- }
105
-
106
- const links = extractLocalLinks(content);
107
-
108
- for (const link of links) {
109
- // Resolve the link relative to the candidate's location
110
- const resolvedLink = resolveLinkPath(link, candidate.path);
111
-
112
- // Only track dependencies that are also candidates
113
- if (candidatePaths.has(resolvedLink)) {
114
- // candidate.path depends on resolvedLink
115
- dependsOn.get(candidate.path)?.add(resolvedLink);
116
- dependedBy.get(resolvedLink)?.add(candidate.path);
117
- }
118
- }
119
- }
120
-
121
- // Kahn's algorithm for topological sort
122
- // Start with nodes that have no dependencies
123
- const sorted: PushCandidate[] = [];
124
- const candidateMap = new Map(candidates.map((c) => [c.path, c]));
125
-
126
- // Queue of nodes with no remaining dependencies
127
- const queue: string[] = [];
128
-
129
- // Count of unprocessed dependencies for each node
130
- const inDegree = new Map<string, number>();
131
-
132
- // Initialize in-degrees
133
- for (const candidate of candidates) {
134
- const deps = dependsOn.get(candidate.path);
135
- const depCount = deps?.size ?? 0;
136
- inDegree.set(candidate.path, depCount);
137
-
138
- if (depCount === 0) {
139
- queue.push(candidate.path);
140
- }
141
- }
142
-
143
- // Process queue
144
- while (queue.length > 0) {
145
- const current = queue.shift();
146
- if (!current) break;
147
-
148
- const candidate = candidateMap.get(current);
149
- if (candidate) {
150
- sorted.push(candidate);
151
- }
152
-
153
- // For each file that depends on current, decrement its in-degree
154
- const dependents = dependedBy.get(current);
155
- if (dependents) {
156
- for (const dependent of dependents) {
157
- const currentDegree = inDegree.get(dependent) ?? 0;
158
- const newDegree = currentDegree - 1;
159
- inDegree.set(dependent, newDegree);
160
-
161
- if (newDegree === 0) {
162
- queue.push(dependent);
163
- }
164
- }
165
- }
166
- }
167
-
168
- // Detect cycles: any nodes not in sorted output are part of cycles
169
- const cycles: string[][] = [];
170
- const sortedPaths = new Set(sorted.map((c) => c.path));
171
- const remainingPaths = candidates.filter((c) => !sortedPaths.has(c.path)).map((c) => c.path);
172
-
173
- if (remainingPaths.length > 0) {
174
- // Find cycles using DFS
175
- const visited = new Set<string>();
176
- const inStack = new Set<string>();
177
-
178
- function findCycles(node: string, path: string[]): void {
179
- if (inStack.has(node)) {
180
- // Found a cycle - extract it from path
181
- const cycleStart = path.indexOf(node);
182
- const cycle = path.slice(cycleStart);
183
- cycles.push(cycle);
184
- return;
185
- }
186
-
187
- if (visited.has(node)) {
188
- return;
189
- }
190
-
191
- visited.add(node);
192
- inStack.add(node);
193
- path.push(node);
194
-
195
- const deps = dependsOn.get(node);
196
- if (deps) {
197
- for (const dep of deps) {
198
- if (remainingPaths.includes(dep)) {
199
- findCycles(dep, [...path]);
200
- }
201
- }
202
- }
203
-
204
- inStack.delete(node);
205
- }
206
-
207
- for (const start of remainingPaths) {
208
- if (!visited.has(start)) {
209
- findCycles(start, []);
210
- }
211
- }
212
-
213
- // Add remaining candidates to sorted output, prioritizing new pages first
214
- // so they get created and receive page_ids before modified pages are pushed.
215
- // This allows modified pages to resolve links to the newly created pages.
216
- const remainingCandidates = remainingPaths
217
- .map((path) => candidateMap.get(path))
218
- .filter((c): c is PushCandidate => c !== undefined)
219
- .sort((a, b) => {
220
- // New pages first, then modified
221
- if (a.type === 'new' && b.type !== 'new') return -1;
222
- if (a.type !== 'new' && b.type === 'new') return 1;
223
- // Within same type, preserve original order (alphabetical)
224
- return remainingPaths.indexOf(a.path) - remainingPaths.indexOf(b.path);
225
- });
226
-
227
- for (const candidate of remainingCandidates) {
228
- sorted.push(candidate);
229
- }
230
- }
231
-
232
- return { sorted, cycles };
233
- }
@@ -1,384 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { extractLocalLinks, sortByDependencies } from '../lib/dependency-sorter.js';
6
- import type { PushCandidate } from '../lib/file-scanner.js';
7
-
8
- describe('dependency-sorter', () => {
9
- let testDir: string;
10
-
11
- beforeEach(() => {
12
- testDir = mkdtempSync(join(tmpdir(), 'cn-test-'));
13
- });
14
-
15
- afterEach(() => {
16
- rmSync(testDir, { recursive: true, force: true });
17
- });
18
-
19
- describe('extractLocalLinks', () => {
20
- test('extracts simple markdown links to .md files', () => {
21
- const content = 'Check out [my page](other-page.md) for more info.';
22
-
23
- const links = extractLocalLinks(content);
24
-
25
- expect(links).toEqual(['other-page.md']);
26
- });
27
-
28
- test('extracts multiple links', () => {
29
- const content = `
30
- # Documentation
31
-
32
- See [intro](intro.md) for getting started.
33
- Then read [guide](guide.md) for details.
34
- `;
35
-
36
- const links = extractLocalLinks(content);
37
-
38
- expect(links).toEqual(['intro.md', 'guide.md']);
39
- });
40
-
41
- test('extracts links with relative paths', () => {
42
- const content = 'See [parent](../parent.md) and [child](subdir/child.md).';
43
-
44
- const links = extractLocalLinks(content);
45
-
46
- expect(links).toEqual(['../parent.md', 'subdir/child.md']);
47
- });
48
-
49
- test('ignores http links', () => {
50
- const content = 'See [external](http://example.com/page.md) for more.';
51
-
52
- const links = extractLocalLinks(content);
53
-
54
- expect(links).toEqual([]);
55
- });
56
-
57
- test('ignores https links', () => {
58
- const content = 'See [secure](https://example.com/page.md) for more.';
59
-
60
- const links = extractLocalLinks(content);
61
-
62
- expect(links).toEqual([]);
63
- });
64
-
65
- test('removes anchor fragments from links', () => {
66
- const content = 'See [section](guide.md#installation) for setup.';
67
-
68
- const links = extractLocalLinks(content);
69
-
70
- expect(links).toEqual(['guide.md']);
71
- });
72
-
73
- test('handles nested brackets in link text', () => {
74
- const content = 'See [text [nested]](page.md) for info.';
75
-
76
- const links = extractLocalLinks(content);
77
-
78
- expect(links).toEqual(['page.md']);
79
- });
80
-
81
- test('returns empty array for content with no links', () => {
82
- const content = '# Just a heading\n\nSome plain text.';
83
-
84
- const links = extractLocalLinks(content);
85
-
86
- expect(links).toEqual([]);
87
- });
88
-
89
- test('returns empty array for links to non-md files', () => {
90
- const content = 'See [image](photo.png) and [doc](file.pdf).';
91
-
92
- const links = extractLocalLinks(content);
93
-
94
- expect(links).toEqual([]);
95
- });
96
- });
97
-
98
- describe('sortByDependencies', () => {
99
- function createCandidate(path: string, type: 'new' | 'modified' = 'new'): PushCandidate {
100
- return {
101
- path,
102
- type,
103
- title: path.replace('.md', ''),
104
- };
105
- }
106
-
107
- test('returns original order when no files have links', () => {
108
- writeFileSync(join(testDir, 'a.md'), '# File A');
109
- writeFileSync(join(testDir, 'b.md'), '# File B');
110
- writeFileSync(join(testDir, 'c.md'), '# File C');
111
-
112
- const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
113
-
114
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
115
-
116
- expect(cycles).toEqual([]);
117
- expect(sorted.map((c) => c.path)).toEqual(['a.md', 'b.md', 'c.md']);
118
- });
119
-
120
- test('sorts linear chain so dependencies come first', () => {
121
- // A links to B, B links to C
122
- // Expected order: C, B, A
123
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
124
- writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
125
- writeFileSync(join(testDir, 'c.md'), '# C');
126
-
127
- const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
128
-
129
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
130
-
131
- expect(cycles).toEqual([]);
132
- expect(sorted.map((c) => c.path)).toEqual(['c.md', 'b.md', 'a.md']);
133
- });
134
-
135
- test('handles diamond dependency pattern', () => {
136
- // A -> B, A -> C, B -> D, C -> D
137
- // Expected: D first, then B and C (in any order), then A
138
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md) and [C](c.md)');
139
- writeFileSync(join(testDir, 'b.md'), '# B\nSee [D](d.md)');
140
- writeFileSync(join(testDir, 'c.md'), '# C\nSee [D](d.md)');
141
- writeFileSync(join(testDir, 'd.md'), '# D');
142
-
143
- const candidates: PushCandidate[] = [
144
- createCandidate('a.md'),
145
- createCandidate('b.md'),
146
- createCandidate('c.md'),
147
- createCandidate('d.md'),
148
- ];
149
-
150
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
151
-
152
- expect(cycles).toEqual([]);
153
-
154
- const paths = sorted.map((c) => c.path);
155
- // D must come before B and C
156
- expect(paths.indexOf('d.md')).toBeLessThan(paths.indexOf('b.md'));
157
- expect(paths.indexOf('d.md')).toBeLessThan(paths.indexOf('c.md'));
158
- // B and C must come before A
159
- expect(paths.indexOf('b.md')).toBeLessThan(paths.indexOf('a.md'));
160
- expect(paths.indexOf('c.md')).toBeLessThan(paths.indexOf('a.md'));
161
- });
162
-
163
- test('detects and reports simple circular dependency', () => {
164
- // A -> B, B -> A
165
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
166
- writeFileSync(join(testDir, 'b.md'), '# B\nSee [A](a.md)');
167
-
168
- const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md')];
169
-
170
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
171
-
172
- // Should detect cycle
173
- expect(cycles.length).toBeGreaterThan(0);
174
- // Both files should still be in output
175
- expect(sorted).toHaveLength(2);
176
- });
177
-
178
- test('detects longer cycles', () => {
179
- // A -> B -> C -> A
180
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
181
- writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
182
- writeFileSync(join(testDir, 'c.md'), '# C\nSee [A](a.md)');
183
-
184
- const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
185
-
186
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
187
-
188
- expect(cycles.length).toBeGreaterThan(0);
189
- // All files should still be in output
190
- expect(sorted).toHaveLength(3);
191
- });
192
-
193
- test('ignores links to files not in candidates', () => {
194
- // A links to B (candidate) and X (not a candidate)
195
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md) and [X](x.md)');
196
- writeFileSync(join(testDir, 'b.md'), '# B');
197
- writeFileSync(join(testDir, 'x.md'), '# X (already synced)');
198
-
199
- const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md')];
200
-
201
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
202
-
203
- expect(cycles).toEqual([]);
204
- // B should come before A because A depends on B
205
- expect(sorted.map((c) => c.path)).toEqual(['b.md', 'a.md']);
206
- });
207
-
208
- test('handles links in subdirectories', () => {
209
- mkdirSync(join(testDir, 'docs'));
210
-
211
- // docs/a.md links to docs/b.md
212
- writeFileSync(join(testDir, 'docs', 'a.md'), '# A\nSee [B](b.md)');
213
- writeFileSync(join(testDir, 'docs', 'b.md'), '# B');
214
-
215
- const candidates: PushCandidate[] = [createCandidate('docs/a.md'), createCandidate('docs/b.md')];
216
-
217
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
218
-
219
- expect(cycles).toEqual([]);
220
- expect(sorted.map((c) => c.path)).toEqual(['docs/b.md', 'docs/a.md']);
221
- });
222
-
223
- test('handles cross-directory links', () => {
224
- mkdirSync(join(testDir, 'guides'));
225
- mkdirSync(join(testDir, 'reference'));
226
-
227
- // guides/a.md links to reference/b.md
228
- writeFileSync(join(testDir, 'guides', 'a.md'), '# A\nSee [B](../reference/b.md)');
229
- writeFileSync(join(testDir, 'reference', 'b.md'), '# B');
230
-
231
- const candidates: PushCandidate[] = [createCandidate('guides/a.md'), createCandidate('reference/b.md')];
232
-
233
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
234
-
235
- expect(cycles).toEqual([]);
236
- expect(sorted.map((c) => c.path)).toEqual(['reference/b.md', 'guides/a.md']);
237
- });
238
-
239
- test('handles empty candidate list', () => {
240
- const { sorted, cycles } = sortByDependencies([], testDir);
241
-
242
- expect(sorted).toEqual([]);
243
- expect(cycles).toEqual([]);
244
- });
245
-
246
- test('handles single candidate with no links', () => {
247
- writeFileSync(join(testDir, 'only.md'), '# Only file');
248
-
249
- const candidates: PushCandidate[] = [createCandidate('only.md')];
250
-
251
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
252
-
253
- expect(cycles).toEqual([]);
254
- expect(sorted).toEqual(candidates);
255
- });
256
-
257
- test('handles single candidate with self-link', () => {
258
- writeFileSync(join(testDir, 'self.md'), '# Self\nSee [Self](self.md)');
259
-
260
- const candidates: PushCandidate[] = [createCandidate('self.md')];
261
-
262
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
263
-
264
- // Self-link creates a cycle
265
- expect(cycles.length).toBeGreaterThan(0);
266
- expect(sorted).toHaveLength(1);
267
- });
268
-
269
- test('preserves candidate type and title', () => {
270
- writeFileSync(join(testDir, 'new.md'), '# New\nSee [modified](modified.md)');
271
- writeFileSync(join(testDir, 'modified.md'), '# Modified');
272
-
273
- const candidates: PushCandidate[] = [
274
- { path: 'new.md', type: 'new', title: 'New Page' },
275
- { path: 'modified.md', type: 'modified', title: 'Modified Page', pageId: '123' },
276
- ];
277
-
278
- const { sorted } = sortByDependencies(candidates, testDir);
279
-
280
- // modified.md should come first (dependency)
281
- expect(sorted[0]).toEqual({
282
- path: 'modified.md',
283
- type: 'modified',
284
- title: 'Modified Page',
285
- pageId: '123',
286
- });
287
- expect(sorted[1]).toEqual({
288
- path: 'new.md',
289
- type: 'new',
290
- title: 'New Page',
291
- });
292
- });
293
-
294
- test('handles mixed candidates with and without dependencies', () => {
295
- // A -> B, C has no links, D -> E
296
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
297
- writeFileSync(join(testDir, 'b.md'), '# B');
298
- writeFileSync(join(testDir, 'c.md'), '# C (independent)');
299
- writeFileSync(join(testDir, 'd.md'), '# D\nSee [E](e.md)');
300
- writeFileSync(join(testDir, 'e.md'), '# E');
301
-
302
- const candidates: PushCandidate[] = [
303
- createCandidate('a.md'),
304
- createCandidate('b.md'),
305
- createCandidate('c.md'),
306
- createCandidate('d.md'),
307
- createCandidate('e.md'),
308
- ];
309
-
310
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
311
-
312
- expect(cycles).toEqual([]);
313
-
314
- const paths = sorted.map((c) => c.path);
315
- // B before A
316
- expect(paths.indexOf('b.md')).toBeLessThan(paths.indexOf('a.md'));
317
- // E before D
318
- expect(paths.indexOf('e.md')).toBeLessThan(paths.indexOf('d.md'));
319
- // All files included
320
- expect(paths).toHaveLength(5);
321
- });
322
-
323
- test('gracefully handles unreadable files', () => {
324
- writeFileSync(join(testDir, 'readable.md'), '# Readable\nSee [unreadable](unreadable.md)');
325
- // unreadable.md doesn't exist as a file, but is in candidates
326
-
327
- const candidates: PushCandidate[] = [createCandidate('readable.md'), createCandidate('unreadable.md')];
328
-
329
- // Should not throw, even though unreadable.md can't be read
330
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
331
-
332
- expect(cycles).toEqual([]);
333
- expect(sorted).toHaveLength(2);
334
- });
335
-
336
- test('prioritizes new pages before modified pages in cycles', () => {
337
- // Create circular dependency: modified -> new -> modified
338
- // The new page should be pushed first so it gets a page_id,
339
- // allowing the modified page to resolve links to it
340
- mkdirSync(join(testDir, 'getting-started'));
341
- mkdirSync(join(testDir, 'tools'));
342
-
343
- writeFileSync(join(testDir, 'getting-started', 'onboarding.md'), '# Onboarding\nSee [CLI](../tools/cli.md)');
344
- writeFileSync(join(testDir, 'tools', 'cli.md'), '# CLI\nSee [Onboarding](../getting-started/onboarding.md)');
345
-
346
- const candidates: PushCandidate[] = [
347
- { path: 'getting-started/onboarding.md', type: 'modified', title: 'Onboarding', pageId: '123' },
348
- { path: 'tools/cli.md', type: 'new', title: 'CLI' },
349
- ];
350
-
351
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
352
-
353
- // Should detect the cycle
354
- expect(cycles.length).toBeGreaterThan(0);
355
- // Both files should be in output
356
- expect(sorted).toHaveLength(2);
357
- // New page should come FIRST (before modified) so it gets created first
358
- expect(sorted[0].type).toBe('new');
359
- expect(sorted[0].path).toBe('tools/cli.md');
360
- expect(sorted[1].type).toBe('modified');
361
- expect(sorted[1].path).toBe('getting-started/onboarding.md');
362
- });
363
-
364
- test('maintains alphabetical order within same type in cycles', () => {
365
- // All new pages in a cycle should maintain alphabetical order
366
- writeFileSync(join(testDir, 'c.md'), '# C\nSee [A](a.md)');
367
- writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
368
- writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
369
-
370
- const candidates: PushCandidate[] = [
371
- { path: 'c.md', type: 'new', title: 'C' },
372
- { path: 'a.md', type: 'new', title: 'A' },
373
- { path: 'b.md', type: 'new', title: 'B' },
374
- ];
375
-
376
- const { sorted, cycles } = sortByDependencies(candidates, testDir);
377
-
378
- expect(cycles.length).toBeGreaterThan(0);
379
- // All same type (new), should preserve original order from candidates array
380
- // Original order is c, a, b
381
- expect(sorted.map((c) => c.path)).toEqual(['c.md', 'a.md', 'b.md']);
382
- });
383
- });
384
- });