@dalzoubi/dev-agents-sync 1.0.25 → 2.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.
@@ -0,0 +1,846 @@
1
+ /**
2
+ * tests/e2e/claudeMd-injection.test.mjs
3
+ *
4
+ * E2E tests for CLAUDE.md fenced-routing-block injection.
5
+ *
6
+ * These tests exercise the CLI programmatically via the runInit / runUpdate /
7
+ * runCheck command modules — the same interface the unit tests use — but with
8
+ * fixture fetchers that return a realistic file map including
9
+ * `claude/skills/auto-route.md`. This lets the tests cover the full
10
+ * init → update → check lifecycle without real GitHub calls.
11
+ *
12
+ * Success criteria (all RED until production wiring is in place):
13
+ *
14
+ * init:
15
+ * 1. Running init against a clean consumer repo creates CLAUDE.md with the
16
+ * fenced routing block.
17
+ * 2. The fenced block contains the start and end markers.
18
+ * 3. An existing CLAUDE.md with prose is preserved; the block is appended.
19
+ * 4. --dry-run does NOT create CLAUDE.md.
20
+ *
21
+ * update:
22
+ * 5. Running update against a consumer whose CLAUDE.md has a stale block
23
+ * replaces the block in-place.
24
+ * 6. Prose above/below the stale block is preserved after update.
25
+ *
26
+ * check:
27
+ * 7. check exits 1 and names CLAUDE.md when the fenced block is stale.
28
+ * 8. check exits 1 and names CLAUDE.md when the file is absent.
29
+ * 9. check exits 0 when the fenced block is current.
30
+ *
31
+ * These tests are RED because:
32
+ * - init.mjs / update.mjs do not yet call injectClaudeMd when the fetcher
33
+ * returns a claude/skills/auto-route.md entry.
34
+ * - check.mjs does not yet evaluate the CLAUDE.md fenced block.
35
+ *
36
+ * Implement agent must wire those calls before these tests go green.
37
+ */
38
+
39
+ import { describe, it, beforeEach, afterEach } from 'node:test';
40
+ import assert from 'node:assert/strict';
41
+ import {
42
+ mkdtempSync,
43
+ mkdirSync,
44
+ writeFileSync,
45
+ readFileSync,
46
+ existsSync,
47
+ rmSync,
48
+ readdirSync,
49
+ } from 'node:fs';
50
+ import { tmpdir } from 'node:os';
51
+ import path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { createRequire } from 'node:module';
54
+
55
+ import { runInit } from '../../src/commands/init.mjs';
56
+ import { runUpdate } from '../../src/commands/update.mjs';
57
+ import { runCheck } from '../../src/commands/check.mjs';
58
+ import { writeLockfile } from '../../src/lockfile.mjs';
59
+ import {
60
+ buildFencedBlock,
61
+ START_MARKER_PREFIX,
62
+ END_MARKER,
63
+ } from '../../src/claudeMd.mjs';
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Constants
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
70
+
71
+ const _require = createRequire(import.meta.url);
72
+ const CLI_VERSION = _require('../../package.json').version;
73
+
74
+ const FIXTURE_DIR = path.join(__dirname, '..', 'fixtures', 'release-v1.0.0');
75
+
76
+ // Deterministic routing content injected by the fixture fetcher.
77
+ // Tests that assert on the block content anchor to this exact string.
78
+ const FIXTURE_ROUTING_CONTENT = [
79
+ '## Routing table',
80
+ '',
81
+ '| User intent | Route to |',
82
+ '|---|---|',
83
+ '| New feature | `define` |',
84
+ '| Small fix | `implement` |',
85
+ '',
86
+ '## Signal words',
87
+ '',
88
+ '`build`, `fix`, `test`',
89
+ ].join('\n');
90
+
91
+ const FIXTURE_ROUTING_CONTENT_V2 = FIXTURE_ROUTING_CONTENT +
92
+ '\n\n## Updated section\n\nThis content was added in v2.';
93
+
94
+ // Available tags used across tests.
95
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0'];
96
+ const AVAILABLE_TAGS_V2 = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Fixture helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function collectFiles(baseDir, currentDir, map, prefix) {
103
+ const entries = readdirSync(currentDir, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ const full = path.join(currentDir, entry.name);
106
+ if (entry.isDirectory()) {
107
+ collectFiles(baseDir, full, map, prefix);
108
+ } else {
109
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
110
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
111
+ }
112
+ }
113
+ }
114
+
115
+ function buildBaseFileMap(targets = ['claude']) {
116
+ const map = {};
117
+ for (const t of targets) {
118
+ const targetDir = path.join(FIXTURE_DIR, t);
119
+ if (!existsSync(targetDir)) continue;
120
+ collectFiles(targetDir, targetDir, map, t);
121
+ }
122
+ return map;
123
+ }
124
+
125
+ /**
126
+ * Builds a fixture fetcher that includes `claude/skills/auto-route.md` with
127
+ * the given routing content. This is the critical ingredient: init/update must
128
+ * detect this key and call injectClaudeMd with its content.
129
+ */
130
+ function makeFixtureFetcher(routingContent = FIXTURE_ROUTING_CONTENT) {
131
+ return async (_repo, _tag, _token) => {
132
+ const base = buildBaseFileMap(['claude']);
133
+ base['claude/skills/auto-route.md'] = routingContent;
134
+ return base;
135
+ };
136
+ }
137
+
138
+ function makeTmpDir() {
139
+ return mkdtempSync(path.join(tmpdir(), 'das-e2e-claudemd-'));
140
+ }
141
+
142
+ /**
143
+ * Convenience: build the fenced block that init/update should write.
144
+ */
145
+ function currentBlock(routingContent = FIXTURE_ROUTING_CONTENT) {
146
+ return buildFencedBlock(CLI_VERSION, routingContent);
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // init — creates CLAUDE.md with fenced block in clean consumer repo
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe('e2e: init — CLAUDE.md injection into clean consumer repo', () => {
154
+ let consumerDir;
155
+
156
+ beforeEach(() => {
157
+ consumerDir = makeTmpDir();
158
+ });
159
+
160
+ afterEach(() => {
161
+ rmSync(consumerDir, { recursive: true, force: true });
162
+ });
163
+
164
+ it('creates CLAUDE.md at the consumer root', async () => {
165
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
166
+ assert.ok(!existsSync(claudeMdPath), 'precondition: CLAUDE.md must not exist before init');
167
+
168
+ await runInit(consumerDir, {
169
+ targets: ['claude'],
170
+ fetcher: makeFixtureFetcher(),
171
+ availableTags: AVAILABLE_TAGS,
172
+ });
173
+
174
+ assert.ok(
175
+ existsSync(claudeMdPath),
176
+ 'CLAUDE.md must be created at the consumer root after init when ' +
177
+ 'claude/skills/auto-route.md is present in the fetched file map',
178
+ );
179
+ });
180
+
181
+ it('CLAUDE.md contains the fenced start marker', async () => {
182
+ await runInit(consumerDir, {
183
+ targets: ['claude'],
184
+ fetcher: makeFixtureFetcher(),
185
+ availableTags: AVAILABLE_TAGS,
186
+ });
187
+
188
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
189
+ assert.ok(
190
+ content.includes(START_MARKER_PREFIX),
191
+ `CLAUDE.md must contain the fenced start marker prefix "${START_MARKER_PREFIX}".\n` +
192
+ `Got:\n${content}`,
193
+ );
194
+ });
195
+
196
+ it('CLAUDE.md contains the fenced end marker', async () => {
197
+ await runInit(consumerDir, {
198
+ targets: ['claude'],
199
+ fetcher: makeFixtureFetcher(),
200
+ availableTags: AVAILABLE_TAGS,
201
+ });
202
+
203
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
204
+ assert.ok(
205
+ content.includes(END_MARKER),
206
+ `CLAUDE.md must contain the fenced end marker "${END_MARKER}".\n` +
207
+ `Got:\n${content}`,
208
+ );
209
+ });
210
+
211
+ it('CLAUDE.md contains the routing content from claude/skills/auto-route.md', async () => {
212
+ await runInit(consumerDir, {
213
+ targets: ['claude'],
214
+ fetcher: makeFixtureFetcher(),
215
+ availableTags: AVAILABLE_TAGS,
216
+ });
217
+
218
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
219
+ assert.ok(
220
+ content.includes('## Routing table'),
221
+ `CLAUDE.md must include the routing table from the injected auto-route.md content.\n` +
222
+ `Got:\n${content}`,
223
+ );
224
+ });
225
+
226
+ it('CLAUDE.md fenced block includes the CLI version in the start marker', async () => {
227
+ await runInit(consumerDir, {
228
+ targets: ['claude'],
229
+ fetcher: makeFixtureFetcher(),
230
+ availableTags: AVAILABLE_TAGS,
231
+ });
232
+
233
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
234
+ const expectedMarker = `${START_MARKER_PREFIX}${CLI_VERSION} -->`;
235
+ assert.ok(
236
+ content.includes(expectedMarker),
237
+ `CLAUDE.md start marker must include the CLI version v${CLI_VERSION}.\n` +
238
+ `Expected marker: ${expectedMarker}\n` +
239
+ `Got:\n${content}`,
240
+ );
241
+ });
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // init — preserves existing CLAUDE.md prose
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe('e2e: init — existing CLAUDE.md prose is preserved', () => {
249
+ let consumerDir;
250
+
251
+ beforeEach(() => {
252
+ consumerDir = makeTmpDir();
253
+ });
254
+
255
+ afterEach(() => {
256
+ rmSync(consumerDir, { recursive: true, force: true });
257
+ });
258
+
259
+ it('prose already in CLAUDE.md is present after init', async () => {
260
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
261
+ writeFileSync(claudeMdPath, '# My Project\n\nThis is my CLAUDE.md.', 'utf8');
262
+
263
+ await runInit(consumerDir, {
264
+ targets: ['claude'],
265
+ fetcher: makeFixtureFetcher(),
266
+ availableTags: AVAILABLE_TAGS,
267
+ });
268
+
269
+ const content = readFileSync(claudeMdPath, 'utf8');
270
+ assert.ok(
271
+ content.includes('# My Project'),
272
+ `Heading "# My Project" must be preserved after init.\n` +
273
+ `Got:\n${content}`,
274
+ );
275
+ assert.ok(
276
+ content.includes('This is my CLAUDE.md.'),
277
+ `Prose "This is my CLAUDE.md." must be preserved after init.\n` +
278
+ `Got:\n${content}`,
279
+ );
280
+ });
281
+
282
+ it('existing prose appears before the fenced block after init', async () => {
283
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
284
+ writeFileSync(claudeMdPath, '# My Project\n\nExisting prose.', 'utf8');
285
+
286
+ await runInit(consumerDir, {
287
+ targets: ['claude'],
288
+ fetcher: makeFixtureFetcher(),
289
+ availableTags: AVAILABLE_TAGS,
290
+ });
291
+
292
+ const content = readFileSync(claudeMdPath, 'utf8');
293
+ const proseIndex = content.indexOf('Existing prose.');
294
+ const blockIndex = content.indexOf(START_MARKER_PREFIX);
295
+
296
+ assert.ok(proseIndex !== -1, 'Prose must be present in CLAUDE.md after init');
297
+ assert.ok(blockIndex !== -1, 'Fenced block must be present in CLAUDE.md after init');
298
+ assert.ok(
299
+ proseIndex < blockIndex,
300
+ 'Existing prose must appear before the injected fenced block',
301
+ );
302
+ });
303
+ });
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // init — --dry-run does not create CLAUDE.md
307
+ // ---------------------------------------------------------------------------
308
+
309
+ describe('e2e: init — --dry-run suppresses CLAUDE.md creation', () => {
310
+ let consumerDir;
311
+
312
+ beforeEach(() => {
313
+ consumerDir = makeTmpDir();
314
+ });
315
+
316
+ afterEach(() => {
317
+ rmSync(consumerDir, { recursive: true, force: true });
318
+ });
319
+
320
+ it('CLAUDE.md is not created in dry-run mode', async () => {
321
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
322
+
323
+ await runInit(consumerDir, {
324
+ targets: ['claude'],
325
+ dryRun: true,
326
+ fetcher: makeFixtureFetcher(),
327
+ availableTags: AVAILABLE_TAGS,
328
+ });
329
+
330
+ assert.ok(
331
+ !existsSync(claudeMdPath),
332
+ 'CLAUDE.md must NOT be created when init is run with --dry-run',
333
+ );
334
+ });
335
+ });
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // update — replaces stale fenced block
339
+ // ---------------------------------------------------------------------------
340
+
341
+ describe('e2e: update — replaces stale CLAUDE.md fenced block', () => {
342
+ let consumerDir;
343
+
344
+ beforeEach(() => {
345
+ consumerDir = makeTmpDir();
346
+ });
347
+
348
+ afterEach(() => {
349
+ rmSync(consumerDir, { recursive: true, force: true });
350
+ });
351
+
352
+ /**
353
+ * Sets up a consumer repo that has already run init at v1.0.0 with a CLAUDE.md
354
+ * containing a stale block written by an older CLI version.
355
+ */
356
+ function setupConsumerWithStaleBlock(opts = {}) {
357
+ const { staleVersion = '0.1.0', proseAbove = '' } = opts;
358
+
359
+ writeLockfile(consumerDir, {
360
+ source: 'github:dalzoubi/dev-agents',
361
+ range: '^1',
362
+ resolvedVersion: '1.0.0',
363
+ targets: ['claude'],
364
+ lastUpdated: '2026-04-01T00:00:00Z',
365
+ });
366
+
367
+ // Write managed claude agent file so update has something to check.
368
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
369
+ mkdirSync(agentsDir, { recursive: true });
370
+ writeFileSync(
371
+ path.join(agentsDir, 'define.md'),
372
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
373
+ 'utf8',
374
+ );
375
+
376
+ // Write CLAUDE.md with a stale block.
377
+ const staleBlock = buildFencedBlock(staleVersion, 'Old routing content.');
378
+ const claudeContent = proseAbove
379
+ ? `${proseAbove}\n\n${staleBlock}`
380
+ : staleBlock;
381
+ writeFileSync(path.join(consumerDir, 'CLAUDE.md'), claudeContent, 'utf8');
382
+ }
383
+
384
+ it('replaces the stale fenced block with a current one', async () => {
385
+ setupConsumerWithStaleBlock({ staleVersion: '0.1.0' });
386
+
387
+ await runUpdate(consumerDir, {
388
+ fetcher: makeFixtureFetcher(),
389
+ availableTags: AVAILABLE_TAGS,
390
+ });
391
+
392
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
393
+ assert.ok(
394
+ !content.includes('v0.1.0'),
395
+ `Stale version "v0.1.0" must be replaced in CLAUDE.md after update.\n` +
396
+ `Got:\n${content}`,
397
+ );
398
+ assert.ok(
399
+ content.includes(START_MARKER_PREFIX),
400
+ `Updated CLAUDE.md must contain the fenced start marker prefix.\n` +
401
+ `Got:\n${content}`,
402
+ );
403
+ });
404
+
405
+ it('stale block is replaced with current version marker', async () => {
406
+ setupConsumerWithStaleBlock({ staleVersion: '0.1.0' });
407
+
408
+ await runUpdate(consumerDir, {
409
+ fetcher: makeFixtureFetcher(),
410
+ availableTags: AVAILABLE_TAGS,
411
+ });
412
+
413
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
414
+ const expectedMarker = `${START_MARKER_PREFIX}${CLI_VERSION} -->`;
415
+ assert.ok(
416
+ content.includes(expectedMarker),
417
+ `Updated CLAUDE.md must use the current CLI version in the start marker.\n` +
418
+ `Expected: ${expectedMarker}\n` +
419
+ `Got:\n${content}`,
420
+ );
421
+ });
422
+
423
+ it('preserves prose above the stale block after update', async () => {
424
+ setupConsumerWithStaleBlock({
425
+ staleVersion: '0.1.0',
426
+ proseAbove: '# My Project\n\nThis is preserved prose.',
427
+ });
428
+
429
+ await runUpdate(consumerDir, {
430
+ fetcher: makeFixtureFetcher(),
431
+ availableTags: AVAILABLE_TAGS,
432
+ });
433
+
434
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
435
+ assert.ok(
436
+ content.includes('# My Project'),
437
+ `Prose heading above the block must be preserved after update.\n` +
438
+ `Got:\n${content}`,
439
+ );
440
+ assert.ok(
441
+ content.includes('This is preserved prose.'),
442
+ `Prose above the block must be preserved after update.\n` +
443
+ `Got:\n${content}`,
444
+ );
445
+ });
446
+
447
+ it('old routing content is replaced with new content after update', async () => {
448
+ setupConsumerWithStaleBlock({ staleVersion: '0.1.0' });
449
+
450
+ await runUpdate(consumerDir, {
451
+ fetcher: makeFixtureFetcher(),
452
+ availableTags: AVAILABLE_TAGS,
453
+ });
454
+
455
+ const content = readFileSync(path.join(consumerDir, 'CLAUDE.md'), 'utf8');
456
+ assert.ok(
457
+ !content.includes('Old routing content.'),
458
+ `Old routing content must be replaced by the new content after update.\n` +
459
+ `Got:\n${content}`,
460
+ );
461
+ assert.ok(
462
+ content.includes('## Routing table'),
463
+ `New routing content from the fixture fetcher must appear after update.\n` +
464
+ `Got:\n${content}`,
465
+ );
466
+ });
467
+ });
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // check — detects stale block (exit 1) and names CLAUDE.md
471
+ // ---------------------------------------------------------------------------
472
+
473
+ describe('e2e: check — stale fenced block is detected (exit 1, CLAUDE.md named)', () => {
474
+ let consumerDir;
475
+
476
+ afterEach(() => {
477
+ if (consumerDir) rmSync(consumerDir, { recursive: true, force: true });
478
+ });
479
+
480
+ /**
481
+ * Sets up a consumer repo with all managed files in sync EXCEPT for the
482
+ * CLAUDE.md fenced block, which is written with a stale version.
483
+ */
484
+ function setupConsumerWithStaleClaude(staleVersion = '0.1.0') {
485
+ consumerDir = makeTmpDir();
486
+
487
+ writeLockfile(consumerDir, {
488
+ source: 'github:dalzoubi/dev-agents',
489
+ range: '^1',
490
+ resolvedVersion: '1.2.0',
491
+ targets: ['claude'],
492
+ lastUpdated: '2026-04-01T00:00:00Z',
493
+ });
494
+
495
+ // Write managed claude files exactly matching the fetcher output.
496
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
497
+ mkdirSync(agentsDir, { recursive: true });
498
+ writeFileSync(
499
+ path.join(agentsDir, 'define.md'),
500
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
501
+ 'utf8',
502
+ );
503
+ writeFileSync(
504
+ path.join(agentsDir, 'test.md'),
505
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'test.md'), 'utf8'),
506
+ 'utf8',
507
+ );
508
+
509
+ const commandsDir = path.join(consumerDir, '.claude', 'commands');
510
+ mkdirSync(commandsDir, { recursive: true });
511
+ writeFileSync(
512
+ path.join(commandsDir, 'preflight.md'),
513
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'commands', 'preflight.md'), 'utf8'),
514
+ 'utf8',
515
+ );
516
+
517
+ // Write .claude/skills/auto-route.md matching the fetcher output so that
518
+ // file itself is not the source of drift — only CLAUDE.md is under test.
519
+ const skillsDir = path.join(consumerDir, '.claude', 'skills');
520
+ mkdirSync(skillsDir, { recursive: true });
521
+ writeFileSync(
522
+ path.join(skillsDir, 'auto-route.md'),
523
+ FIXTURE_ROUTING_CONTENT,
524
+ 'utf8',
525
+ );
526
+
527
+ // Write CLAUDE.md with a stale block (wrong version).
528
+ const staleBlock = buildFencedBlock(staleVersion, FIXTURE_ROUTING_CONTENT);
529
+ writeFileSync(
530
+ path.join(consumerDir, 'CLAUDE.md'),
531
+ `# My Project\n\n${staleBlock}\n`,
532
+ 'utf8',
533
+ );
534
+ }
535
+
536
+ it('exits 1 when the fenced block version is older than the current CLI version', async () => {
537
+ setupConsumerWithStaleClaude('0.1.0');
538
+
539
+ let exitCode;
540
+ try {
541
+ await runCheck(consumerDir, {
542
+ fetcher: makeFixtureFetcher(),
543
+ availableTags: ['v1.2.0'],
544
+ });
545
+ exitCode = 0;
546
+ } catch (err) {
547
+ exitCode = err.exitCode;
548
+ }
549
+
550
+ assert.equal(
551
+ exitCode,
552
+ 1,
553
+ 'check must exit 1 when the CLAUDE.md fenced block version is stale ' +
554
+ '(older than the current CLI version)',
555
+ );
556
+ });
557
+
558
+ it('drift error message names CLAUDE.md for a stale block', async () => {
559
+ setupConsumerWithStaleClaude('0.1.0');
560
+
561
+ let errorMessage = '';
562
+ try {
563
+ await runCheck(consumerDir, {
564
+ fetcher: makeFixtureFetcher(),
565
+ availableTags: ['v1.2.0'],
566
+ });
567
+ } catch (err) {
568
+ errorMessage = err.message ?? '';
569
+ }
570
+
571
+ assert.ok(
572
+ errorMessage.includes('CLAUDE.md'),
573
+ `check drift error must name CLAUDE.md when the fenced block is stale.\n` +
574
+ `Got: ${errorMessage}`,
575
+ );
576
+ });
577
+
578
+ it('err.drifted array includes CLAUDE.md for a stale block', async () => {
579
+ setupConsumerWithStaleClaude('0.1.0');
580
+
581
+ let drifted;
582
+ try {
583
+ await runCheck(consumerDir, {
584
+ fetcher: makeFixtureFetcher(),
585
+ availableTags: ['v1.2.0'],
586
+ });
587
+ } catch (err) {
588
+ drifted = err.drifted;
589
+ }
590
+
591
+ assert.ok(
592
+ Array.isArray(drifted),
593
+ 'expected err.drifted to be an array when drift is detected',
594
+ );
595
+ assert.ok(
596
+ drifted.some((d) => d.includes('CLAUDE.md')),
597
+ `err.drifted must include an entry mentioning CLAUDE.md for a stale block.\n` +
598
+ `Got: ${JSON.stringify(drifted)}`,
599
+ );
600
+ });
601
+ });
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // check — detects absent CLAUDE.md (exit 1, CLAUDE.md named)
605
+ // ---------------------------------------------------------------------------
606
+
607
+ describe('e2e: check — absent CLAUDE.md is detected as drift (exit 1)', () => {
608
+ let consumerDir;
609
+
610
+ afterEach(() => {
611
+ if (consumerDir) rmSync(consumerDir, { recursive: true, force: true });
612
+ });
613
+
614
+ function setupConsumerWithoutClaude() {
615
+ consumerDir = makeTmpDir();
616
+
617
+ writeLockfile(consumerDir, {
618
+ source: 'github:dalzoubi/dev-agents',
619
+ range: '^1',
620
+ resolvedVersion: '1.2.0',
621
+ targets: ['claude'],
622
+ lastUpdated: '2026-04-01T00:00:00Z',
623
+ });
624
+
625
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
626
+ mkdirSync(agentsDir, { recursive: true });
627
+ writeFileSync(
628
+ path.join(agentsDir, 'define.md'),
629
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
630
+ 'utf8',
631
+ );
632
+ writeFileSync(
633
+ path.join(agentsDir, 'test.md'),
634
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'test.md'), 'utf8'),
635
+ 'utf8',
636
+ );
637
+
638
+ const commandsDir = path.join(consumerDir, '.claude', 'commands');
639
+ mkdirSync(commandsDir, { recursive: true });
640
+ writeFileSync(
641
+ path.join(commandsDir, 'preflight.md'),
642
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'commands', 'preflight.md'), 'utf8'),
643
+ 'utf8',
644
+ );
645
+
646
+ const skillsDir = path.join(consumerDir, '.claude', 'skills');
647
+ mkdirSync(skillsDir, { recursive: true });
648
+ writeFileSync(
649
+ path.join(skillsDir, 'auto-route.md'),
650
+ FIXTURE_ROUTING_CONTENT,
651
+ 'utf8',
652
+ );
653
+
654
+ // Deliberately do NOT create CLAUDE.md — absence is the drift condition.
655
+ }
656
+
657
+ it('exits 1 when CLAUDE.md does not exist', async () => {
658
+ setupConsumerWithoutClaude();
659
+
660
+ const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
661
+ assert.ok(!existsSync(claudeMdPath), 'precondition: CLAUDE.md must not exist');
662
+
663
+ let exitCode;
664
+ try {
665
+ await runCheck(consumerDir, {
666
+ fetcher: makeFixtureFetcher(),
667
+ availableTags: ['v1.2.0'],
668
+ });
669
+ exitCode = 0;
670
+ } catch (err) {
671
+ exitCode = err.exitCode;
672
+ }
673
+
674
+ assert.equal(
675
+ exitCode,
676
+ 1,
677
+ 'check must exit 1 when CLAUDE.md does not exist (absence = drift)',
678
+ );
679
+ });
680
+
681
+ it('drift error message names CLAUDE.md when file is absent', async () => {
682
+ setupConsumerWithoutClaude();
683
+
684
+ let errorMessage = '';
685
+ try {
686
+ await runCheck(consumerDir, {
687
+ fetcher: makeFixtureFetcher(),
688
+ availableTags: ['v1.2.0'],
689
+ });
690
+ } catch (err) {
691
+ errorMessage = err.message ?? '';
692
+ }
693
+
694
+ assert.ok(
695
+ errorMessage.includes('CLAUDE.md'),
696
+ `check drift error must name CLAUDE.md when the file is absent.\n` +
697
+ `Got: ${errorMessage}`,
698
+ );
699
+ });
700
+ });
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // check — current block exits 0
704
+ // ---------------------------------------------------------------------------
705
+
706
+ describe('e2e: check — current CLAUDE.md fenced block exits 0', () => {
707
+ let consumerDir;
708
+
709
+ afterEach(() => {
710
+ if (consumerDir) rmSync(consumerDir, { recursive: true, force: true });
711
+ });
712
+
713
+ function setupConsumerWithCurrentBlock() {
714
+ consumerDir = makeTmpDir();
715
+
716
+ writeLockfile(consumerDir, {
717
+ source: 'github:dalzoubi/dev-agents',
718
+ range: '^1',
719
+ resolvedVersion: '1.2.0',
720
+ targets: ['claude'],
721
+ lastUpdated: '2026-04-01T00:00:00Z',
722
+ });
723
+
724
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
725
+ mkdirSync(agentsDir, { recursive: true });
726
+ writeFileSync(
727
+ path.join(agentsDir, 'define.md'),
728
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
729
+ 'utf8',
730
+ );
731
+ writeFileSync(
732
+ path.join(agentsDir, 'test.md'),
733
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'test.md'), 'utf8'),
734
+ 'utf8',
735
+ );
736
+
737
+ const commandsDir = path.join(consumerDir, '.claude', 'commands');
738
+ mkdirSync(commandsDir, { recursive: true });
739
+ writeFileSync(
740
+ path.join(commandsDir, 'preflight.md'),
741
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'commands', 'preflight.md'), 'utf8'),
742
+ 'utf8',
743
+ );
744
+
745
+ const skillsDir = path.join(consumerDir, '.claude', 'skills');
746
+ mkdirSync(skillsDir, { recursive: true });
747
+ writeFileSync(
748
+ path.join(skillsDir, 'auto-route.md'),
749
+ FIXTURE_ROUTING_CONTENT,
750
+ 'utf8',
751
+ );
752
+
753
+ // Write CLAUDE.md with the current (expected) fenced block.
754
+ const block = currentBlock(FIXTURE_ROUTING_CONTENT);
755
+ writeFileSync(
756
+ path.join(consumerDir, 'CLAUDE.md'),
757
+ `# My Project\n\n${block}\n`,
758
+ 'utf8',
759
+ );
760
+ }
761
+
762
+ it('exits 0 when CLAUDE.md has the current fenced block', async () => {
763
+ setupConsumerWithCurrentBlock();
764
+
765
+ let exitCode = 0;
766
+ try {
767
+ await runCheck(consumerDir, {
768
+ fetcher: makeFixtureFetcher(),
769
+ availableTags: ['v1.2.0'],
770
+ });
771
+ } catch (err) {
772
+ exitCode = err.exitCode ?? 2;
773
+ }
774
+
775
+ assert.equal(
776
+ exitCode,
777
+ 0,
778
+ 'check must exit 0 when CLAUDE.md has the current fenced block with current version and content',
779
+ );
780
+ });
781
+
782
+ it('exits 0 even when CLAUDE.md has prose surrounding the current block', async () => {
783
+ consumerDir = makeTmpDir();
784
+
785
+ writeLockfile(consumerDir, {
786
+ source: 'github:dalzoubi/dev-agents',
787
+ range: '^1',
788
+ resolvedVersion: '1.2.0',
789
+ targets: ['claude'],
790
+ lastUpdated: '2026-04-01T00:00:00Z',
791
+ });
792
+
793
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
794
+ mkdirSync(agentsDir, { recursive: true });
795
+ writeFileSync(
796
+ path.join(agentsDir, 'define.md'),
797
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
798
+ 'utf8',
799
+ );
800
+ writeFileSync(
801
+ path.join(agentsDir, 'test.md'),
802
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'test.md'), 'utf8'),
803
+ 'utf8',
804
+ );
805
+
806
+ const commandsDir = path.join(consumerDir, '.claude', 'commands');
807
+ mkdirSync(commandsDir, { recursive: true });
808
+ writeFileSync(
809
+ path.join(commandsDir, 'preflight.md'),
810
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'commands', 'preflight.md'), 'utf8'),
811
+ 'utf8',
812
+ );
813
+
814
+ const skillsDir = path.join(consumerDir, '.claude', 'skills');
815
+ mkdirSync(skillsDir, { recursive: true });
816
+ writeFileSync(
817
+ path.join(skillsDir, 'auto-route.md'),
818
+ FIXTURE_ROUTING_CONTENT,
819
+ 'utf8',
820
+ );
821
+
822
+ // Write CLAUDE.md with prose before AND after the block.
823
+ const block = currentBlock(FIXTURE_ROUTING_CONTENT);
824
+ writeFileSync(
825
+ path.join(consumerDir, 'CLAUDE.md'),
826
+ `# My Full Project\n\nProse before.\n\n${block}\n\nTrailing notes.`,
827
+ 'utf8',
828
+ );
829
+
830
+ let exitCode = 0;
831
+ try {
832
+ await runCheck(consumerDir, {
833
+ fetcher: makeFixtureFetcher(),
834
+ availableTags: ['v1.2.0'],
835
+ });
836
+ } catch (err) {
837
+ exitCode = err.exitCode ?? 2;
838
+ }
839
+
840
+ assert.equal(
841
+ exitCode,
842
+ 0,
843
+ 'check must exit 0 when block is current — surrounding prose must not affect the result',
844
+ );
845
+ });
846
+ });