@dalzoubi/dev-agents-sync 1.0.26 → 2.0.1

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,910 @@
1
+ /**
2
+ * tests/claudeMd.test.mjs
3
+ *
4
+ * Tests for the `claudeMd` helper (packages/dev-agents-sync/src/claudeMd.mjs)
5
+ * and the CLAUDE.md injection behaviour wired into `init` and `update`.
6
+ *
7
+ * ALL tests in this file are intentionally RED until the production helper is
8
+ * implemented. The import of `../src/claudeMd.mjs` is the first thing that
9
+ * will fail; `init`/`update` tests will fail because the helper doesn't exist
10
+ * yet and the commands don't call it.
11
+ *
12
+ * Fenced block format (from the slice spec):
13
+ * <!-- dev-agents:auto-route:start managed-by: dev-agents-sync vX.Y.Z -->
14
+ * ...routing rules content...
15
+ * <!-- dev-agents:auto-route:end -->
16
+ *
17
+ * The content injected is the body of `.claude/skills/auto-route.md` from the
18
+ * release at the resolved version. The version in the start-marker matches
19
+ * the CLI package version from `packages/dev-agents-sync/package.json`.
20
+ *
21
+ * Behaviour contract:
22
+ * 1. inject into empty file — creates CLAUDE.md and writes fenced block
23
+ * 2. inject into file with prose — appends fenced block; existing prose untouched
24
+ * 3. replace stale block — updates block in-place; prose above/below untouched
25
+ * 4. skip when current — no write when block already matches
26
+ * 5. dry-run — returns planned diff, no file write
27
+ * 6. malformed block (start marker present, end marker missing) — warns to
28
+ * stderr and skips rather than corrupting the file
29
+ */
30
+
31
+ import { describe, it, beforeEach, afterEach } from 'node:test';
32
+ import assert from 'node:assert/strict';
33
+ import {
34
+ mkdtempSync,
35
+ mkdirSync,
36
+ writeFileSync,
37
+ readFileSync,
38
+ existsSync,
39
+ rmSync,
40
+ } from 'node:fs';
41
+ import { tmpdir } from 'node:os';
42
+ import path from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Production module under test — will fail with MODULE_NOT_FOUND until
47
+ // the implement agent creates src/claudeMd.mjs.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ import {
51
+ injectClaudeMd,
52
+ buildFencedBlock,
53
+ parseExistingBlock,
54
+ START_MARKER_PREFIX,
55
+ END_MARKER,
56
+ } from '../src/claudeMd.mjs';
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Command-level imports for integration tests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ import { runInit } from '../src/commands/init.mjs';
63
+ import { runUpdate } from '../src/commands/update.mjs';
64
+ import { writeLockfile } from '../src/lockfile.mjs';
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Constants
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
71
+
72
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
73
+
74
+ // The version recorded in the fenced start-marker comes from the CLI package.
75
+ const CLI_VERSION = JSON.parse(
76
+ readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
77
+ ).version;
78
+
79
+ // Minimal routing content for test fixtures (does not need to match the real
80
+ // file exactly — we just need deterministic content).
81
+ const SAMPLE_ROUTING_CONTENT = '## Routing\n\nRoute `fix` → `implement`.';
82
+
83
+ // A well-formed fenced block using the sample content and CLI_VERSION.
84
+ function makeFencedBlock(version = CLI_VERSION, content = SAMPLE_ROUTING_CONTENT) {
85
+ return [
86
+ `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v${version} -->`,
87
+ content,
88
+ `<!-- dev-agents:auto-route:end -->`,
89
+ ].join('\n');
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function makeTmpDir() {
97
+ return mkdtempSync(path.join(tmpdir(), 'das-claudemd-test-'));
98
+ }
99
+
100
+ /**
101
+ * Captures all output written to process.stderr.write during the execution of
102
+ * `fn`. Returns the concatenated string.
103
+ */
104
+ async function captureStderr(fn) {
105
+ const origWrite = process.stderr.write.bind(process.stderr);
106
+ let captured = '';
107
+ process.stderr.write = (chunk) => {
108
+ captured += typeof chunk === 'string' ? chunk : chunk.toString();
109
+ return true;
110
+ };
111
+ try {
112
+ await fn();
113
+ } finally {
114
+ process.stderr.write = origWrite;
115
+ }
116
+ return captured;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Fixture fetcher helpers (mirrors init.test.mjs / update.test.mjs patterns)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ import { readdirSync } from 'node:fs';
124
+
125
+ function collectFiles(baseDir, currentDir, map, prefix) {
126
+ const entries = readdirSync(currentDir, { withFileTypes: true });
127
+ for (const entry of entries) {
128
+ const full = path.join(currentDir, entry.name);
129
+ if (entry.isDirectory()) {
130
+ collectFiles(baseDir, full, map, prefix);
131
+ } else {
132
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
133
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
134
+ }
135
+ }
136
+ }
137
+
138
+ function buildFixtureFileMap(targets = ['claude', 'cursor']) {
139
+ const map = {};
140
+ for (const t of targets) {
141
+ const targetDir = path.join(FIXTURE_DIR, t);
142
+ if (!existsSync(targetDir)) continue;
143
+ collectFiles(targetDir, targetDir, map, t);
144
+ }
145
+ return map;
146
+ }
147
+
148
+ /**
149
+ * A fixture fetcher that also injects a synthetic `skills/auto-route.md` into
150
+ * the claude target so the CLAUDE.md injection logic has content to pull from.
151
+ */
152
+ function makeFixtureFetcherWithAutoRoute(routingContent = SAMPLE_ROUTING_CONTENT) {
153
+ return async (_repo, _tag, _token) => {
154
+ const base = buildFixtureFileMap(['claude', 'cursor']);
155
+ // Inject the auto-route skill so claudeMd.mjs can find it.
156
+ base['claude/skills/auto-route.md'] = routingContent;
157
+ return base;
158
+ };
159
+ }
160
+
161
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0'];
162
+
163
+ // ===========================================================================
164
+ // Unit tests — claudeMd helper
165
+ // ===========================================================================
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // buildFencedBlock
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe('buildFencedBlock', () => {
172
+ it('returns a string that starts with the start marker', () => {
173
+ const block = buildFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
174
+ const expectedStart = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v${CLI_VERSION} -->`;
175
+ assert.ok(
176
+ block.startsWith(expectedStart),
177
+ `expected block to start with start marker, got:\n${block}`,
178
+ );
179
+ });
180
+
181
+ it('returns a string that ends with the end marker', () => {
182
+ const block = buildFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
183
+ assert.ok(
184
+ block.trimEnd().endsWith('<!-- dev-agents:auto-route:end -->'),
185
+ `expected block to end with end marker, got:\n${block}`,
186
+ );
187
+ });
188
+
189
+ it('includes the routing content between the markers', () => {
190
+ const block = buildFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
191
+ assert.ok(
192
+ block.includes(SAMPLE_ROUTING_CONTENT),
193
+ `expected routing content to appear in block, got:\n${block}`,
194
+ );
195
+ });
196
+
197
+ it('embeds the version in the start marker', () => {
198
+ const block = buildFencedBlock('2.3.4', SAMPLE_ROUTING_CONTENT);
199
+ assert.ok(
200
+ block.includes('v2.3.4'),
201
+ `expected version v2.3.4 in block, got:\n${block}`,
202
+ );
203
+ });
204
+
205
+ it('exported START_MARKER_PREFIX matches the prefix used in buildFencedBlock output', () => {
206
+ const block = buildFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
207
+ const firstLine = block.split('\n')[0];
208
+ assert.ok(
209
+ firstLine.startsWith(START_MARKER_PREFIX),
210
+ `expected first line to start with START_MARKER_PREFIX "${START_MARKER_PREFIX}", got:\n${firstLine}`,
211
+ );
212
+ });
213
+
214
+ it('exported END_MARKER matches the trailing marker in buildFencedBlock output', () => {
215
+ const block = buildFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
216
+ const lines = block.split('\n');
217
+ const lastNonEmpty = [...lines].reverse().find((l) => l.trim().length > 0);
218
+ assert.equal(
219
+ lastNonEmpty,
220
+ END_MARKER,
221
+ `expected last non-empty line to equal END_MARKER`,
222
+ );
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // parseExistingBlock — extracts start-marker version from file content
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('parseExistingBlock', () => {
231
+ it('returns null when no block is present in the file', () => {
232
+ const content = '# My CLAUDE.md\n\nSome prose.';
233
+ assert.equal(parseExistingBlock(content), null);
234
+ });
235
+
236
+ it('returns an object with version and full block text when a well-formed block is present', () => {
237
+ const block = makeFencedBlock('1.2.3');
238
+ const content = `# CLAUDE.md\n\n${block}\n\nTrailing prose.`;
239
+ const result = parseExistingBlock(content);
240
+ assert.ok(result !== null, 'expected non-null result for a file with a block');
241
+ assert.equal(result.version, '1.2.3');
242
+ assert.ok(typeof result.block === 'string', 'expected result.block to be a string');
243
+ });
244
+
245
+ it('returns { malformed: true } when start marker is present but end marker is missing', () => {
246
+ const startMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v1.0.0 -->`;
247
+ const content = `# CLAUDE.md\n\n${startMarker}\nContent without end marker.`;
248
+ const result = parseExistingBlock(content);
249
+ assert.ok(result !== null, 'expected non-null result for malformed block');
250
+ assert.equal(result.malformed, true, 'expected result.malformed to be true');
251
+ });
252
+
253
+ it('returns the version from the start marker accurately', () => {
254
+ const block = makeFencedBlock('9.9.9');
255
+ const result = parseExistingBlock(block);
256
+ assert.equal(result?.version, '9.9.9');
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // injectClaudeMd — core logic
262
+ // ---------------------------------------------------------------------------
263
+
264
+ describe('injectClaudeMd — inject into empty/non-existent file', () => {
265
+ let tmpDir;
266
+
267
+ beforeEach(() => { tmpDir = makeTmpDir(); });
268
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
269
+
270
+ it('creates CLAUDE.md when the file does not exist', () => {
271
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
272
+ assert.ok(!existsSync(claudeMdPath), 'precondition: CLAUDE.md must not exist');
273
+
274
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
275
+
276
+ assert.ok(existsSync(claudeMdPath), 'CLAUDE.md must be created');
277
+ });
278
+
279
+ it('written CLAUDE.md contains the fenced block with the correct version', () => {
280
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
281
+
282
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
283
+
284
+ const written = readFileSync(claudeMdPath, 'utf8');
285
+ const expectedStartMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v${CLI_VERSION} -->`;
286
+ assert.ok(
287
+ written.includes(expectedStartMarker),
288
+ `expected start marker in written CLAUDE.md, got:\n${written}`,
289
+ );
290
+ assert.ok(
291
+ written.includes('<!-- dev-agents:auto-route:end -->'),
292
+ `expected end marker in written CLAUDE.md, got:\n${written}`,
293
+ );
294
+ });
295
+
296
+ it('written CLAUDE.md contains the routing content', () => {
297
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
298
+
299
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
300
+
301
+ const written = readFileSync(claudeMdPath, 'utf8');
302
+ assert.ok(
303
+ written.includes(SAMPLE_ROUTING_CONTENT),
304
+ `expected routing content in written CLAUDE.md, got:\n${written}`,
305
+ );
306
+ });
307
+
308
+ it('creates CLAUDE.md when the file exists but is empty', () => {
309
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
310
+ writeFileSync(claudeMdPath, '', 'utf8');
311
+
312
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
313
+
314
+ const written = readFileSync(claudeMdPath, 'utf8');
315
+ assert.ok(
316
+ written.includes(START_MARKER_PREFIX),
317
+ `expected start marker after injecting into empty file, got:\n${written}`,
318
+ );
319
+ });
320
+ });
321
+
322
+ describe('injectClaudeMd — inject into file with existing prose', () => {
323
+ let tmpDir;
324
+
325
+ beforeEach(() => { tmpDir = makeTmpDir(); });
326
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
327
+
328
+ it('preserves existing prose above the injected block', () => {
329
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
330
+ const originalProse = '# My Project\n\nThis is my CLAUDE.md.';
331
+ writeFileSync(claudeMdPath, originalProse, 'utf8');
332
+
333
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
334
+
335
+ const written = readFileSync(claudeMdPath, 'utf8');
336
+ assert.ok(
337
+ written.includes('# My Project'),
338
+ `existing heading must be preserved, got:\n${written}`,
339
+ );
340
+ assert.ok(
341
+ written.includes('This is my CLAUDE.md.'),
342
+ `existing prose must be preserved, got:\n${written}`,
343
+ );
344
+ });
345
+
346
+ it('appends the fenced block after the existing prose', () => {
347
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
348
+ const originalProse = '# My Project\n\nProse here.';
349
+ writeFileSync(claudeMdPath, originalProse, 'utf8');
350
+
351
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
352
+
353
+ const written = readFileSync(claudeMdPath, 'utf8');
354
+ const proseIndex = written.indexOf('Prose here.');
355
+ const blockIndex = written.indexOf(START_MARKER_PREFIX);
356
+ assert.ok(proseIndex !== -1, 'prose must be present');
357
+ assert.ok(blockIndex !== -1, 'fenced block must be present');
358
+ assert.ok(
359
+ proseIndex < blockIndex,
360
+ 'existing prose must appear before the injected fenced block',
361
+ );
362
+ });
363
+ });
364
+
365
+ describe('injectClaudeMd — replace stale block', () => {
366
+ let tmpDir;
367
+
368
+ beforeEach(() => { tmpDir = makeTmpDir(); });
369
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
370
+
371
+ it('replaces a block whose version is older than the current version', () => {
372
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
373
+ const staleBlock = makeFencedBlock('0.9.0', 'Old routing content.');
374
+ const originalContent = `# My Project\n\n${staleBlock}\n\nTrailing prose.`;
375
+ writeFileSync(claudeMdPath, originalContent, 'utf8');
376
+
377
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
378
+
379
+ const written = readFileSync(claudeMdPath, 'utf8');
380
+ // New version in start marker
381
+ const expectedStartMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v${CLI_VERSION} -->`;
382
+ assert.ok(
383
+ written.includes(expectedStartMarker),
384
+ `expected updated start marker, got:\n${written}`,
385
+ );
386
+ // Old version removed
387
+ assert.ok(
388
+ !written.includes('v0.9.0'),
389
+ `old version marker must be removed, got:\n${written}`,
390
+ );
391
+ // Old routing content replaced
392
+ assert.ok(
393
+ !written.includes('Old routing content.'),
394
+ `old routing content must be replaced, got:\n${written}`,
395
+ );
396
+ // New routing content present
397
+ assert.ok(
398
+ written.includes(SAMPLE_ROUTING_CONTENT),
399
+ `new routing content must be present, got:\n${written}`,
400
+ );
401
+ });
402
+
403
+ it('preserves prose above the block when replacing a stale block', () => {
404
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
405
+ const staleBlock = makeFencedBlock('0.9.0', 'Old routing content.');
406
+ const originalContent = `# Header prose\n\nSome text.\n\n${staleBlock}\n`;
407
+ writeFileSync(claudeMdPath, originalContent, 'utf8');
408
+
409
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
410
+
411
+ const written = readFileSync(claudeMdPath, 'utf8');
412
+ assert.ok(
413
+ written.includes('# Header prose'),
414
+ `header prose above the block must be preserved, got:\n${written}`,
415
+ );
416
+ assert.ok(
417
+ written.includes('Some text.'),
418
+ `prose above block must be preserved, got:\n${written}`,
419
+ );
420
+ });
421
+
422
+ it('preserves prose below the block when replacing a stale block', () => {
423
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
424
+ const staleBlock = makeFencedBlock('0.9.0', 'Old routing content.');
425
+ const originalContent = `${staleBlock}\n\nTrailing notes.`;
426
+ writeFileSync(claudeMdPath, originalContent, 'utf8');
427
+
428
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
429
+
430
+ const written = readFileSync(claudeMdPath, 'utf8');
431
+ assert.ok(
432
+ written.includes('Trailing notes.'),
433
+ `prose below the block must be preserved, got:\n${written}`,
434
+ );
435
+ });
436
+ });
437
+
438
+ describe('injectClaudeMd — skip when current', () => {
439
+ let tmpDir;
440
+
441
+ beforeEach(() => { tmpDir = makeTmpDir(); });
442
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
443
+
444
+ it('returns { skipped: true } when the block is already current', () => {
445
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
446
+ const currentBlock = makeFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
447
+ writeFileSync(claudeMdPath, currentBlock, 'utf8');
448
+
449
+ const result = injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
450
+
451
+ assert.ok(
452
+ result?.skipped === true,
453
+ `expected { skipped: true } when block is already current, got: ${JSON.stringify(result)}`,
454
+ );
455
+ });
456
+
457
+ it('does not modify the file when the block is already current', () => {
458
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
459
+ const currentBlock = makeFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
460
+ writeFileSync(claudeMdPath, currentBlock, 'utf8');
461
+
462
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
463
+
464
+ const after = readFileSync(claudeMdPath, 'utf8');
465
+ assert.equal(
466
+ after,
467
+ currentBlock,
468
+ 'file must be byte-identical when block is already current',
469
+ );
470
+ });
471
+ });
472
+
473
+ describe('injectClaudeMd — dry-run', () => {
474
+ let tmpDir;
475
+
476
+ beforeEach(() => { tmpDir = makeTmpDir(); });
477
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
478
+
479
+ it('does not write to disk when dryRun is true (file does not exist)', () => {
480
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
481
+
482
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT, { dryRun: true });
483
+
484
+ assert.ok(
485
+ !existsSync(claudeMdPath),
486
+ 'CLAUDE.md must not be created in dry-run mode',
487
+ );
488
+ });
489
+
490
+ it('does not modify disk when dryRun is true and a stale block is present', () => {
491
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
492
+ const originalContent = makeFencedBlock('0.1.0', 'Old content.');
493
+ writeFileSync(claudeMdPath, originalContent, 'utf8');
494
+
495
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT, { dryRun: true });
496
+
497
+ const after = readFileSync(claudeMdPath, 'utf8');
498
+ assert.equal(
499
+ after,
500
+ originalContent,
501
+ 'file must not be modified in dry-run mode even when block is stale',
502
+ );
503
+ });
504
+
505
+ it('returns a result object with action and planned content in dry-run mode', () => {
506
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
507
+
508
+ const result = injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT, {
509
+ dryRun: true,
510
+ });
511
+
512
+ assert.ok(result !== null && typeof result === 'object', 'expected a result object from dry-run');
513
+ // The result must communicate what would change — accept any reasonable shape.
514
+ const hasActionableField =
515
+ result.dryRun === true ||
516
+ typeof result.action === 'string' ||
517
+ typeof result.plannedContent === 'string';
518
+ assert.ok(
519
+ hasActionableField,
520
+ `dry-run result must include dryRun:true, action, or plannedContent. Got: ${JSON.stringify(result)}`,
521
+ );
522
+ });
523
+ });
524
+
525
+ describe('injectClaudeMd — malformed block (start marker present, end marker missing)', () => {
526
+ let tmpDir;
527
+
528
+ beforeEach(() => { tmpDir = makeTmpDir(); });
529
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
530
+
531
+ it('warns to stderr when the start marker is present but the end marker is missing', async () => {
532
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
533
+ const startMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v1.0.0 -->`;
534
+ const malformedContent = `# My CLAUDE.md\n\n${startMarker}\nContent with no end marker.`;
535
+ writeFileSync(claudeMdPath, malformedContent, 'utf8');
536
+
537
+ const stderr = await captureStderr(() => {
538
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
539
+ });
540
+
541
+ assert.ok(
542
+ stderr.length > 0,
543
+ 'expected a warning to stderr for malformed block (start without end)',
544
+ );
545
+ assert.ok(
546
+ /warn|malformed|end marker|missing/i.test(stderr),
547
+ `expected warning text to describe the malformed block, got:\n${stderr}`,
548
+ );
549
+ });
550
+
551
+ it('does not modify the file when the block is malformed', () => {
552
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
553
+ const startMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v1.0.0 -->`;
554
+ const malformedContent = `# My CLAUDE.md\n\n${startMarker}\nContent with no end marker.`;
555
+ writeFileSync(claudeMdPath, malformedContent, 'utf8');
556
+
557
+ injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
558
+
559
+ const after = readFileSync(claudeMdPath, 'utf8');
560
+ assert.equal(
561
+ after,
562
+ malformedContent,
563
+ 'file must not be modified when block is malformed — skip to avoid corruption',
564
+ );
565
+ });
566
+
567
+ it('returns { skipped: true, malformed: true } for a malformed block', () => {
568
+ const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
569
+ const startMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v1.0.0 -->`;
570
+ writeFileSync(
571
+ claudeMdPath,
572
+ `${startMarker}\nOrphaned content with no closing marker.`,
573
+ 'utf8',
574
+ );
575
+
576
+ const result = injectClaudeMd(claudeMdPath, CLI_VERSION, SAMPLE_ROUTING_CONTENT);
577
+
578
+ assert.ok(
579
+ result?.malformed === true,
580
+ `expected result.malformed === true, got: ${JSON.stringify(result)}`,
581
+ );
582
+ assert.ok(
583
+ result?.skipped === true,
584
+ `expected result.skipped === true, got: ${JSON.stringify(result)}`,
585
+ );
586
+ });
587
+ });
588
+
589
+ // ===========================================================================
590
+ // Integration tests — init wires up CLAUDE.md injection
591
+ // ===========================================================================
592
+
593
+ describe('init — CLAUDE.md injection', () => {
594
+ let consumerDir;
595
+
596
+ beforeEach(() => { consumerDir = makeTmpDir(); });
597
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
598
+
599
+ it('creates CLAUDE.md in the consumer root after init when file does not exist', async () => {
600
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
601
+ assert.ok(!existsSync(claudeMdPath), 'precondition: CLAUDE.md must not exist');
602
+
603
+ await runInit(consumerDir, {
604
+ targets: ['claude'],
605
+ fetcher: makeFixtureFetcherWithAutoRoute(),
606
+ availableTags: AVAILABLE_TAGS,
607
+ });
608
+
609
+ assert.ok(
610
+ existsSync(claudeMdPath),
611
+ 'CLAUDE.md must be created at the consumer root after init',
612
+ );
613
+ });
614
+
615
+ it('written CLAUDE.md contains the fenced start marker after init', async () => {
616
+ await runInit(consumerDir, {
617
+ targets: ['claude'],
618
+ fetcher: makeFixtureFetcherWithAutoRoute(),
619
+ availableTags: AVAILABLE_TAGS,
620
+ });
621
+
622
+ const written = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
623
+ assert.ok(
624
+ written.includes(START_MARKER_PREFIX),
625
+ `expected fenced start marker prefix in CLAUDE.md, got:\n${written}`,
626
+ );
627
+ assert.ok(
628
+ written.includes(END_MARKER),
629
+ `expected fenced end marker in CLAUDE.md, got:\n${written}`,
630
+ );
631
+ });
632
+
633
+ it('written CLAUDE.md contains the routing content from skills/auto-route.md', async () => {
634
+ const routingContent = '## Routing table\n\nRoute `fix` → `implement`.';
635
+ await runInit(consumerDir, {
636
+ targets: ['claude'],
637
+ fetcher: makeFixtureFetcherWithAutoRoute(routingContent),
638
+ availableTags: AVAILABLE_TAGS,
639
+ });
640
+
641
+ const written = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
642
+ assert.ok(
643
+ written.includes('Route `fix` → `implement`.'),
644
+ `expected routing content in CLAUDE.md, got:\n${written}`,
645
+ );
646
+ });
647
+
648
+ it('preserves existing prose in CLAUDE.md above the injected block', async () => {
649
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
650
+ writeFileSync(claudeMdPath, '# My project\n\nExisting prose.', 'utf8');
651
+
652
+ await runInit(consumerDir, {
653
+ targets: ['claude'],
654
+ fetcher: makeFixtureFetcherWithAutoRoute(),
655
+ availableTags: AVAILABLE_TAGS,
656
+ });
657
+
658
+ const written = readFileSync(claudeMdPath, 'utf8');
659
+ assert.ok(
660
+ written.includes('# My project'),
661
+ `existing heading must be preserved, got:\n${written}`,
662
+ );
663
+ assert.ok(
664
+ written.includes('Existing prose.'),
665
+ `existing prose must be preserved, got:\n${written}`,
666
+ );
667
+ });
668
+
669
+ it('--dry-run does not create CLAUDE.md', async () => {
670
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
671
+
672
+ await runInit(consumerDir, {
673
+ targets: ['claude'],
674
+ dryRun: true,
675
+ fetcher: makeFixtureFetcherWithAutoRoute(),
676
+ availableTags: AVAILABLE_TAGS,
677
+ });
678
+
679
+ assert.ok(
680
+ !existsSync(claudeMdPath),
681
+ 'CLAUDE.md must not be created in dry-run mode',
682
+ );
683
+ });
684
+
685
+ it('--dry-run result includes claudeMd in the planned changes', async () => {
686
+ const result = await runInit(consumerDir, {
687
+ targets: ['claude'],
688
+ dryRun: true,
689
+ fetcher: makeFixtureFetcherWithAutoRoute(),
690
+ availableTags: AVAILABLE_TAGS,
691
+ });
692
+
693
+ // The result must communicate the planned CLAUDE.md write in some form.
694
+ const resultStr = JSON.stringify(result);
695
+ assert.ok(
696
+ resultStr.includes('CLAUDE.md') || result.claudeMd !== undefined,
697
+ `dry-run result must reference CLAUDE.md planned change, got: ${resultStr}`,
698
+ );
699
+ });
700
+ });
701
+
702
+ // ===========================================================================
703
+ // Integration tests — update wires up CLAUDE.md injection
704
+ // ===========================================================================
705
+
706
+ describe('update — CLAUDE.md injection replaces stale block', () => {
707
+ let consumerDir;
708
+
709
+ beforeEach(() => { consumerDir = makeTmpDir(); });
710
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
711
+
712
+ /**
713
+ * Sets up a consumer that has already run init at v1.0.0 with a CLAUDE.md
714
+ * containing a stale block from a previous (lower) version.
715
+ */
716
+ async function setupConsumerWithStaleClaude(opts = {}) {
717
+ const { staleVersion = '0.1.0', staleProse = '' } = opts;
718
+
719
+ writeLockfile(consumerDir, {
720
+ source: 'github:dalzoubi/dev-agents',
721
+ range: '^1',
722
+ resolvedVersion: '1.0.0',
723
+ targets: ['claude'],
724
+ lastUpdated: '2026-04-01T00:00:00Z',
725
+ });
726
+
727
+ // Write managed claude agent file
728
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
729
+ mkdirSync(agentsDir, { recursive: true });
730
+ writeFileSync(
731
+ path.join(agentsDir, 'define.md'),
732
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
733
+ 'utf8',
734
+ );
735
+
736
+ // Write CLAUDE.md with a stale fenced block
737
+ const staleBlock = makeFencedBlock(staleVersion, 'Old routing content.');
738
+ const claudeContent = staleProse
739
+ ? `${staleProse}\n\n${staleBlock}`
740
+ : staleBlock;
741
+ writeFileSync(path.join(consumerDir, 'CLAUDE.md'), claudeContent, 'utf8');
742
+ }
743
+
744
+ it('replaces a stale fenced block in CLAUDE.md after update', async () => {
745
+ await setupConsumerWithStaleClaude({ staleVersion: '0.1.0' });
746
+
747
+ await runUpdate(consumerDir, {
748
+ fetcher: makeFixtureFetcherWithAutoRoute(),
749
+ availableTags: ['v1.0.0', 'v1.2.0'],
750
+ });
751
+
752
+ const written = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
753
+ assert.ok(
754
+ !written.includes('v0.1.0'),
755
+ `stale version must be replaced in CLAUDE.md, got:\n${written}`,
756
+ );
757
+ assert.ok(
758
+ written.includes(START_MARKER_PREFIX),
759
+ `updated block must contain start marker prefix, got:\n${written}`,
760
+ );
761
+ });
762
+
763
+ it('preserves existing prose above the block in CLAUDE.md after update', async () => {
764
+ await setupConsumerWithStaleClaude({
765
+ staleVersion: '0.1.0',
766
+ staleProse: '# My Project\n\nExisting prose above the block.',
767
+ });
768
+
769
+ await runUpdate(consumerDir, {
770
+ fetcher: makeFixtureFetcherWithAutoRoute(),
771
+ availableTags: ['v1.0.0', 'v1.2.0'],
772
+ });
773
+
774
+ const written = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
775
+ assert.ok(
776
+ written.includes('# My Project'),
777
+ `prose above the block must be preserved after update, got:\n${written}`,
778
+ );
779
+ assert.ok(
780
+ written.includes('Existing prose above the block.'),
781
+ `existing prose must be preserved after update, got:\n${written}`,
782
+ );
783
+ });
784
+
785
+ it('skips silently when block is already current during update', async () => {
786
+ writeLockfile(consumerDir, {
787
+ source: 'github:dalzoubi/dev-agents',
788
+ range: '^1',
789
+ resolvedVersion: '1.0.0',
790
+ targets: ['claude'],
791
+ lastUpdated: '2026-04-01T00:00:00Z',
792
+ });
793
+
794
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
795
+ mkdirSync(agentsDir, { recursive: true });
796
+ writeFileSync(
797
+ path.join(agentsDir, 'define.md'),
798
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
799
+ 'utf8',
800
+ );
801
+
802
+ // Write CLAUDE.md already at the current version — update should skip it.
803
+ const currentBlock = makeFencedBlock(CLI_VERSION, SAMPLE_ROUTING_CONTENT);
804
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
805
+ writeFileSync(claudeMdPath, currentBlock, 'utf8');
806
+ const contentBefore = readFileSync(claudeMdPath, 'utf8');
807
+
808
+ await runUpdate(consumerDir, {
809
+ fetcher: makeFixtureFetcherWithAutoRoute(),
810
+ availableTags: ['v1.0.0', 'v1.0.0'], // same version → upToDate
811
+ });
812
+
813
+ // File must be byte-identical — skip means no write.
814
+ const contentAfter = readFileSync(claudeMdPath, 'utf8');
815
+ assert.equal(
816
+ contentAfter,
817
+ contentBefore,
818
+ 'CLAUDE.md must not be modified when block is already current',
819
+ );
820
+ });
821
+
822
+ it('warns to stderr and skips when CLAUDE.md has a malformed block during update', async () => {
823
+ writeLockfile(consumerDir, {
824
+ source: 'github:dalzoubi/dev-agents',
825
+ range: '^1',
826
+ resolvedVersion: '1.0.0',
827
+ targets: ['claude'],
828
+ lastUpdated: '2026-04-01T00:00:00Z',
829
+ });
830
+
831
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
832
+ mkdirSync(agentsDir, { recursive: true });
833
+ writeFileSync(
834
+ path.join(agentsDir, 'define.md'),
835
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
836
+ 'utf8',
837
+ );
838
+
839
+ // Write CLAUDE.md with a malformed block (start marker, no end marker).
840
+ const startMarker = `<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v1.0.0 -->`;
841
+ const malformedContent = `# CLAUDE.md\n\n${startMarker}\nOrphaned content.`;
842
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
843
+ writeFileSync(claudeMdPath, malformedContent, 'utf8');
844
+
845
+ const stderr = await captureStderr(async () => {
846
+ await runUpdate(consumerDir, {
847
+ fetcher: makeFixtureFetcherWithAutoRoute(),
848
+ availableTags: ['v1.0.0', 'v1.2.0'],
849
+ });
850
+ });
851
+
852
+ // Must warn
853
+ assert.ok(
854
+ /warn|malformed|end marker|missing/i.test(stderr),
855
+ `expected warning about malformed block during update, got:\n${stderr}`,
856
+ );
857
+
858
+ // Must NOT have corrupted the file
859
+ const contentAfter = readFileSync(claudeMdPath, 'utf8');
860
+ assert.equal(
861
+ contentAfter,
862
+ malformedContent,
863
+ 'CLAUDE.md must not be modified when block is malformed — skip to preserve user content',
864
+ );
865
+ });
866
+
867
+ it('--dry-run does not modify CLAUDE.md during update', async () => {
868
+ await setupConsumerWithStaleClaude({ staleVersion: '0.1.0' });
869
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
870
+ const contentBefore = readFileSync(claudeMdPath, 'utf8');
871
+
872
+ await runUpdate(consumerDir, {
873
+ dryRun: true,
874
+ fetcher: makeFixtureFetcherWithAutoRoute(),
875
+ availableTags: ['v1.0.0', 'v1.2.0'],
876
+ });
877
+
878
+ const contentAfter = readFileSync(claudeMdPath, 'utf8');
879
+ assert.equal(
880
+ contentAfter,
881
+ contentBefore,
882
+ 'CLAUDE.md must not be modified during a dry-run update',
883
+ );
884
+ });
885
+
886
+ it('--dry-run result includes claudeMd field when routing content is available', async () => {
887
+ await setupConsumerWithStaleClaude({ staleVersion: '0.1.0' });
888
+
889
+ const result = await runUpdate(consumerDir, {
890
+ dryRun: true,
891
+ fetcher: makeFixtureFetcherWithAutoRoute(),
892
+ availableTags: ['v1.0.0', 'v1.2.0'],
893
+ });
894
+
895
+ assert.ok(
896
+ result !== null && typeof result === 'object',
897
+ 'expected a result object from update dry-run',
898
+ );
899
+ assert.ok(
900
+ 'claudeMd' in result,
901
+ `update --dry-run result must include a claudeMd field when routing content is present, got: ${JSON.stringify(result)}`,
902
+ );
903
+ // The claudeMd value must be non-null (routing content was available via the fetcher).
904
+ assert.notEqual(
905
+ result.claudeMd,
906
+ null,
907
+ 'claudeMd field must be non-null when routing content is available',
908
+ );
909
+ });
910
+ });