@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.
- package/README.md +14 -0
- package/eslint.config.mjs +9 -0
- package/package.json +1 -1
- package/src/claudeMd.mjs +220 -0
- package/src/commands/check.mjs +66 -1
- package/src/commands/init.mjs +26 -0
- package/src/commands/update.mjs +24 -0
- package/tests/checkClaudeMd.test.mjs +656 -0
- package/tests/claudeMd.test.mjs +910 -0
- package/tests/e2e/claudeMd-injection.test.mjs +846 -0
- package/tests/e2e/readme-coverage.test.mjs +183 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/checkClaudeMd.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for CLAUDE.md routing-section drift detection in the `check` subcommand.
|
|
5
|
+
*
|
|
6
|
+
* The `check` command must detect drift in the fenced auto-route block that
|
|
7
|
+
* `init` / `update` inject into the consumer's CLAUDE.md.
|
|
8
|
+
*
|
|
9
|
+
* Fenced block format:
|
|
10
|
+
* <!-- dev-agents:auto-route:start managed-by: dev-agents-sync vX.Y.Z -->
|
|
11
|
+
* ...routing content from claude/skills/auto-route.md at the resolved version...
|
|
12
|
+
* <!-- dev-agents:auto-route:end -->
|
|
13
|
+
*
|
|
14
|
+
* Success criteria (all must be RED until check.mjs gains CLAUDE.md logic):
|
|
15
|
+
* 1. exit 1 + CLAUDE.md named in drift report when fenced section is absent
|
|
16
|
+
* 2. exit 1 + CLAUDE.md named when fenced section is present but stale
|
|
17
|
+
* 3. exit 0 when fenced section is present and current
|
|
18
|
+
* 4. exit 1 when CLAUDE.md does not exist (absence = drift)
|
|
19
|
+
* 5. exit 1 when block is malformed (start marker present, end marker absent)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import {
|
|
25
|
+
mkdtempSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
import { runCheck } from '../src/commands/check.mjs';
|
|
38
|
+
import { writeLockfile } from '../src/lockfile.mjs';
|
|
39
|
+
import {
|
|
40
|
+
buildFencedBlock,
|
|
41
|
+
START_MARKER_PREFIX,
|
|
42
|
+
END_MARKER,
|
|
43
|
+
} from '../src/claudeMd.mjs';
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Constants
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
|
|
51
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
52
|
+
|
|
53
|
+
// The CLI package version drives the version written into the start marker.
|
|
54
|
+
const CLI_VERSION = JSON.parse(
|
|
55
|
+
readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
|
|
56
|
+
).version;
|
|
57
|
+
|
|
58
|
+
// Routing content used for the auto-route skill in the fixture fetcher.
|
|
59
|
+
// Tests are anchored to this deterministic string so they never snapshot dates.
|
|
60
|
+
const SAMPLE_ROUTING_CONTENT = '## Auto-routing\n\nRoute `fix` → `implement`.';
|
|
61
|
+
|
|
62
|
+
// A routing content string that differs from SAMPLE_ROUTING_CONTENT, used to
|
|
63
|
+
// simulate stale / updated content between CLI versions.
|
|
64
|
+
const UPDATED_ROUTING_CONTENT = '## Auto-routing\n\nRoute `fix` → `implement`.\nRoute `test` → `test`.';
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Fixture helpers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
71
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const full = path.join(currentDir, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
collectFiles(baseDir, full, map, prefix);
|
|
76
|
+
} else {
|
|
77
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
78
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildFixtureFileMap(targets = ['claude']) {
|
|
84
|
+
const map = {};
|
|
85
|
+
for (const t of targets) {
|
|
86
|
+
const targetDir = path.join(FIXTURE_DIR, t);
|
|
87
|
+
if (!existsSync(targetDir)) continue;
|
|
88
|
+
collectFiles(targetDir, targetDir, map, t);
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds a fetcher that returns the standard fixture file map plus a synthetic
|
|
95
|
+
* `claude/skills/auto-route.md` with `routingContent`.
|
|
96
|
+
*/
|
|
97
|
+
function makeFixtureFetcherWithAutoRoute(routingContent = SAMPLE_ROUTING_CONTENT) {
|
|
98
|
+
return async (_repo, _tag, _token) => {
|
|
99
|
+
const base = buildFixtureFileMap(['claude']);
|
|
100
|
+
base['claude/skills/auto-route.md'] = routingContent;
|
|
101
|
+
return base;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Consumer-dir helpers
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function makeTmpDir() {
|
|
110
|
+
return mkdtempSync(path.join(tmpdir(), 'das-check-claudemd-'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Writes the lockfile and all managed `.claude/` files so that the check
|
|
115
|
+
* command sees them as fully in-sync. CLAUDE.md drift is the only variable
|
|
116
|
+
* under test in this file, so every other managed file must be written with
|
|
117
|
+
* content that exactly matches the corresponding fetcher output.
|
|
118
|
+
*
|
|
119
|
+
* The fetcher in these tests injects `claude/skills/auto-route.md` which
|
|
120
|
+
* check will resolve to `.claude/skills/auto-route.md`. That file must exist
|
|
121
|
+
* locally and match exactly; otherwise it would produce a spurious exit-1
|
|
122
|
+
* unrelated to the CLAUDE.md block being tested.
|
|
123
|
+
*
|
|
124
|
+
* `claudeMdContent` controls what (if anything) is written to CLAUDE.md:
|
|
125
|
+
* - string → writes that exact content to <consumerDir>/CLAUDE.md
|
|
126
|
+
* - 'omit' → does not create CLAUDE.md (CLAUDE.md is absent)
|
|
127
|
+
*/
|
|
128
|
+
function setupConsumerRepo(consumerDir, opts = {}) {
|
|
129
|
+
const {
|
|
130
|
+
resolvedVersion = '1.2.0',
|
|
131
|
+
targets = ['claude'],
|
|
132
|
+
claudeMdContent = 'omit',
|
|
133
|
+
// Content the fetcher will return for claude/skills/auto-route.md.
|
|
134
|
+
// Must match so the skills file does not appear as drifted itself.
|
|
135
|
+
routingContentForSkillsFile = SAMPLE_ROUTING_CONTENT,
|
|
136
|
+
} = opts;
|
|
137
|
+
|
|
138
|
+
writeLockfile(consumerDir, {
|
|
139
|
+
source: 'github:dalzoubi/dev-agents',
|
|
140
|
+
range: '^1',
|
|
141
|
+
resolvedVersion,
|
|
142
|
+
targets,
|
|
143
|
+
lastUpdated: '2026-04-01T00:00:00Z',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Write all managed claude fixture files so the standard file-map check
|
|
147
|
+
// passes cleanly and CLAUDE.md drift is the only variable under test.
|
|
148
|
+
if (targets.includes('claude')) {
|
|
149
|
+
const agentsDir = path.join(consumerDir, '.claude', 'agents');
|
|
150
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
151
|
+
|
|
152
|
+
writeFileSync(
|
|
153
|
+
path.join(agentsDir, 'define.md'),
|
|
154
|
+
readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
|
|
155
|
+
'utf8',
|
|
156
|
+
);
|
|
157
|
+
writeFileSync(
|
|
158
|
+
path.join(agentsDir, 'test.md'),
|
|
159
|
+
readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'test.md'), 'utf8'),
|
|
160
|
+
'utf8',
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const commandsDir = path.join(consumerDir, '.claude', 'commands');
|
|
164
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
165
|
+
writeFileSync(
|
|
166
|
+
path.join(commandsDir, 'preflight.md'),
|
|
167
|
+
readFileSync(path.join(FIXTURE_DIR, 'claude', 'commands', 'preflight.md'), 'utf8'),
|
|
168
|
+
'utf8',
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Write .claude/skills/auto-route.md matching the fetcher's output so
|
|
172
|
+
// check does not report that file as drifted. Its content must equal
|
|
173
|
+
// whatever the fetcher returns for claude/skills/auto-route.md.
|
|
174
|
+
const skillsDir = path.join(consumerDir, '.claude', 'skills');
|
|
175
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
176
|
+
writeFileSync(
|
|
177
|
+
path.join(skillsDir, 'auto-route.md'),
|
|
178
|
+
routingContentForSkillsFile,
|
|
179
|
+
'utf8',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// CLAUDE.md
|
|
184
|
+
if (typeof claudeMdContent === 'string' && claudeMdContent !== 'omit') {
|
|
185
|
+
writeFileSync(path.join(consumerDir, 'CLAUDE.md'), claudeMdContent, 'utf8');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Convenience: build the "current" fenced block the check command should expect.
|
|
190
|
+
function currentFencedBlock(routingContent = SAMPLE_ROUTING_CONTENT) {
|
|
191
|
+
return buildFencedBlock(CLI_VERSION, routingContent);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Criterion 4 — CLAUDE.md absent entirely → exit 1
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
describe('check — CLAUDE.md absent (no file) → exit 1', () => {
|
|
199
|
+
let consumerDir;
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
consumerDir = makeTmpDir();
|
|
203
|
+
// Do NOT write CLAUDE.md at all; all other managed files are in-sync.
|
|
204
|
+
setupConsumerRepo(consumerDir, { claudeMdContent: 'omit' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
afterEach(() => {
|
|
208
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('exits 1 when CLAUDE.md does not exist', async () => {
|
|
212
|
+
// Precondition: ensure the file is genuinely absent.
|
|
213
|
+
const claudeMdPath = path.join(consumerDir, 'CLAUDE.md');
|
|
214
|
+
assert.ok(!existsSync(claudeMdPath), 'precondition: CLAUDE.md must not exist');
|
|
215
|
+
|
|
216
|
+
let exitCode;
|
|
217
|
+
try {
|
|
218
|
+
await runCheck(consumerDir, {
|
|
219
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
220
|
+
availableTags: ['v1.2.0'],
|
|
221
|
+
});
|
|
222
|
+
exitCode = 0;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
exitCode = err.exitCode;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
assert.equal(exitCode, 1, 'expected exit code 1 when CLAUDE.md is absent');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('drift error names CLAUDE.md when the file is absent', async () => {
|
|
231
|
+
let errorMessage = '';
|
|
232
|
+
try {
|
|
233
|
+
await runCheck(consumerDir, {
|
|
234
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
235
|
+
availableTags: ['v1.2.0'],
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
errorMessage = err.message ?? '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
assert.ok(
|
|
242
|
+
errorMessage.includes('CLAUDE.md'),
|
|
243
|
+
`drift error must name CLAUDE.md when the file is absent, got: ${errorMessage}`,
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Criterion 1 — fenced block absent (CLAUDE.md exists but has no block) → exit 1
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
describe('check — CLAUDE.md exists but fenced block is absent → exit 1', () => {
|
|
253
|
+
let consumerDir;
|
|
254
|
+
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
consumerDir = makeTmpDir();
|
|
257
|
+
// Write a CLAUDE.md that has prose but no fenced block at all.
|
|
258
|
+
setupConsumerRepo(consumerDir, {
|
|
259
|
+
claudeMdContent: '# My Project\n\nThis is my CLAUDE.md. No routing section yet.',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(() => {
|
|
264
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('exits 1 when CLAUDE.md exists but the fenced routing section is absent', async () => {
|
|
268
|
+
let exitCode;
|
|
269
|
+
try {
|
|
270
|
+
await runCheck(consumerDir, {
|
|
271
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
272
|
+
availableTags: ['v1.2.0'],
|
|
273
|
+
});
|
|
274
|
+
exitCode = 0;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
exitCode = err.exitCode;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
assert.equal(
|
|
280
|
+
exitCode,
|
|
281
|
+
1,
|
|
282
|
+
'expected exit code 1 when fenced routing section is absent from CLAUDE.md',
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('drift error names CLAUDE.md when the fenced block is absent', async () => {
|
|
287
|
+
let errorMessage = '';
|
|
288
|
+
try {
|
|
289
|
+
await runCheck(consumerDir, {
|
|
290
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
291
|
+
availableTags: ['v1.2.0'],
|
|
292
|
+
});
|
|
293
|
+
} catch (err) {
|
|
294
|
+
errorMessage = err.message ?? '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
assert.ok(
|
|
298
|
+
errorMessage.includes('CLAUDE.md'),
|
|
299
|
+
`drift error must name CLAUDE.md when fenced block is absent, got: ${errorMessage}`,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('drifted array on the thrown error includes CLAUDE.md', async () => {
|
|
304
|
+
let drifted;
|
|
305
|
+
try {
|
|
306
|
+
await runCheck(consumerDir, {
|
|
307
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
308
|
+
availableTags: ['v1.2.0'],
|
|
309
|
+
});
|
|
310
|
+
} catch (err) {
|
|
311
|
+
drifted = err.drifted;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
assert.ok(
|
|
315
|
+
Array.isArray(drifted),
|
|
316
|
+
'expected err.drifted to be an array',
|
|
317
|
+
);
|
|
318
|
+
assert.ok(
|
|
319
|
+
drifted.some((d) => d.includes('CLAUDE.md')),
|
|
320
|
+
`expected err.drifted to include an entry mentioning CLAUDE.md, got: ${JSON.stringify(drifted)}`,
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Criterion 2 — fenced block present but stale (wrong version or content) → exit 1
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
describe('check — CLAUDE.md fenced block is stale → exit 1', () => {
|
|
330
|
+
let consumerDir;
|
|
331
|
+
|
|
332
|
+
afterEach(() => {
|
|
333
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('exits 1 when the block version is older than the current CLI version', async () => {
|
|
337
|
+
const staleBlock = buildFencedBlock('0.1.0', SAMPLE_ROUTING_CONTENT);
|
|
338
|
+
consumerDir = makeTmpDir();
|
|
339
|
+
setupConsumerRepo(consumerDir, {
|
|
340
|
+
claudeMdContent: `# My Project\n\n${staleBlock}\n`,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
let exitCode;
|
|
344
|
+
try {
|
|
345
|
+
await runCheck(consumerDir, {
|
|
346
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
347
|
+
availableTags: ['v1.2.0'],
|
|
348
|
+
});
|
|
349
|
+
exitCode = 0;
|
|
350
|
+
} catch (err) {
|
|
351
|
+
exitCode = err.exitCode;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
assert.equal(
|
|
355
|
+
exitCode,
|
|
356
|
+
1,
|
|
357
|
+
'expected exit code 1 when block version is stale (older than CLI version)',
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('drift error names CLAUDE.md when block version is stale', async () => {
|
|
362
|
+
const staleBlock = buildFencedBlock('0.1.0', SAMPLE_ROUTING_CONTENT);
|
|
363
|
+
consumerDir = makeTmpDir();
|
|
364
|
+
setupConsumerRepo(consumerDir, {
|
|
365
|
+
claudeMdContent: `# My Project\n\n${staleBlock}\n`,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
let errorMessage = '';
|
|
369
|
+
try {
|
|
370
|
+
await runCheck(consumerDir, {
|
|
371
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
372
|
+
availableTags: ['v1.2.0'],
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
errorMessage = err.message ?? '';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
assert.ok(
|
|
379
|
+
errorMessage.includes('CLAUDE.md'),
|
|
380
|
+
`drift error must name CLAUDE.md when block version is stale, got: ${errorMessage}`,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('exits 1 when the block routing content has changed (same version, different content)', async () => {
|
|
385
|
+
// Block has the current CLI version but uses outdated routing content.
|
|
386
|
+
const staleBlock = buildFencedBlock(CLI_VERSION, 'Old routing rules that have since changed.');
|
|
387
|
+
consumerDir = makeTmpDir();
|
|
388
|
+
// routingContentForSkillsFile must match what the fetcher returns so
|
|
389
|
+
// .claude/skills/auto-route.md itself is not the source of drift.
|
|
390
|
+
setupConsumerRepo(consumerDir, {
|
|
391
|
+
claudeMdContent: `# My Project\n\n${staleBlock}\n`,
|
|
392
|
+
routingContentForSkillsFile: UPDATED_ROUTING_CONTENT,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
let exitCode;
|
|
396
|
+
try {
|
|
397
|
+
await runCheck(consumerDir, {
|
|
398
|
+
// Fetcher returns UPDATED_ROUTING_CONTENT — different from what's in the block.
|
|
399
|
+
fetcher: makeFixtureFetcherWithAutoRoute(UPDATED_ROUTING_CONTENT),
|
|
400
|
+
availableTags: ['v1.2.0'],
|
|
401
|
+
});
|
|
402
|
+
exitCode = 0;
|
|
403
|
+
} catch (err) {
|
|
404
|
+
exitCode = err.exitCode;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
assert.equal(
|
|
408
|
+
exitCode,
|
|
409
|
+
1,
|
|
410
|
+
'expected exit code 1 when block routing content differs from current versioned content',
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('drifted array on the thrown error includes CLAUDE.md when block is stale', async () => {
|
|
415
|
+
const staleBlock = buildFencedBlock('0.1.0', SAMPLE_ROUTING_CONTENT);
|
|
416
|
+
consumerDir = makeTmpDir();
|
|
417
|
+
setupConsumerRepo(consumerDir, {
|
|
418
|
+
claudeMdContent: staleBlock,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
let drifted;
|
|
422
|
+
try {
|
|
423
|
+
await runCheck(consumerDir, {
|
|
424
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
425
|
+
availableTags: ['v1.2.0'],
|
|
426
|
+
});
|
|
427
|
+
} catch (err) {
|
|
428
|
+
drifted = err.drifted;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
assert.ok(
|
|
432
|
+
Array.isArray(drifted),
|
|
433
|
+
'expected err.drifted to be an array',
|
|
434
|
+
);
|
|
435
|
+
assert.ok(
|
|
436
|
+
drifted.some((d) => d.includes('CLAUDE.md')),
|
|
437
|
+
`expected err.drifted to include an entry mentioning CLAUDE.md, got: ${JSON.stringify(drifted)}`,
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Criterion 3 — fenced block present and current → exit 0
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
describe('check — CLAUDE.md fenced block is current → exit 0', () => {
|
|
447
|
+
let consumerDir;
|
|
448
|
+
|
|
449
|
+
beforeEach(() => {
|
|
450
|
+
consumerDir = makeTmpDir();
|
|
451
|
+
// Write CLAUDE.md with the exact block check should expect.
|
|
452
|
+
const block = currentFencedBlock(SAMPLE_ROUTING_CONTENT);
|
|
453
|
+
setupConsumerRepo(consumerDir, {
|
|
454
|
+
claudeMdContent: `# My Project\n\n${block}\n`,
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
afterEach(() => {
|
|
459
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('exits 0 when the fenced block version and content are current', async () => {
|
|
463
|
+
let exitCode = 0;
|
|
464
|
+
try {
|
|
465
|
+
await runCheck(consumerDir, {
|
|
466
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
467
|
+
availableTags: ['v1.2.0'],
|
|
468
|
+
});
|
|
469
|
+
} catch (err) {
|
|
470
|
+
exitCode = err.exitCode ?? 2;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
assert.equal(
|
|
474
|
+
exitCode,
|
|
475
|
+
0,
|
|
476
|
+
'expected exit code 0 when fenced block is present and matches current versioned content',
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('returns { inSync: true } when the fenced block is current', async () => {
|
|
481
|
+
let result;
|
|
482
|
+
try {
|
|
483
|
+
result = await runCheck(consumerDir, {
|
|
484
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
485
|
+
availableTags: ['v1.2.0'],
|
|
486
|
+
});
|
|
487
|
+
} catch (_err) {
|
|
488
|
+
// Should not throw — test the assertion below regardless.
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
assert.ok(
|
|
492
|
+
result && result.inSync === true,
|
|
493
|
+
`expected { inSync: true } when block is current, got: ${JSON.stringify(result)}`,
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('exit 0 holds even when CLAUDE.md has prose above the block', async () => {
|
|
498
|
+
// Re-setup with prose before and after the block.
|
|
499
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
500
|
+
consumerDir = makeTmpDir();
|
|
501
|
+
const block = currentFencedBlock(SAMPLE_ROUTING_CONTENT);
|
|
502
|
+
const claudeMdContent = `# My Full Project\n\nSome existing prose.\n\n${block}\n\nTrailing notes.`;
|
|
503
|
+
setupConsumerRepo(consumerDir, { claudeMdContent });
|
|
504
|
+
|
|
505
|
+
let exitCode = 0;
|
|
506
|
+
try {
|
|
507
|
+
await runCheck(consumerDir, {
|
|
508
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
509
|
+
availableTags: ['v1.2.0'],
|
|
510
|
+
});
|
|
511
|
+
} catch (err) {
|
|
512
|
+
exitCode = err.exitCode ?? 2;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
assert.equal(
|
|
516
|
+
exitCode,
|
|
517
|
+
0,
|
|
518
|
+
'expected exit code 0 when block is current (prose surrounding the block must not affect result)',
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Criterion 5 — malformed block (start present, end missing) → exit 1
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
describe('check — CLAUDE.md has malformed block (start present, end absent) → exit 1', () => {
|
|
528
|
+
let consumerDir;
|
|
529
|
+
|
|
530
|
+
beforeEach(() => {
|
|
531
|
+
consumerDir = makeTmpDir();
|
|
532
|
+
// Write CLAUDE.md with a dangling start marker and no end marker.
|
|
533
|
+
const startMarker = `${START_MARKER_PREFIX}${CLI_VERSION} -->`;
|
|
534
|
+
const malformedContent = `# My Project\n\n${startMarker}\nRouting content with no closing marker.`;
|
|
535
|
+
setupConsumerRepo(consumerDir, { claudeMdContent: malformedContent });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
afterEach(() => {
|
|
539
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('exits 1 when the start marker is present but the end marker is missing', async () => {
|
|
543
|
+
let exitCode;
|
|
544
|
+
try {
|
|
545
|
+
await runCheck(consumerDir, {
|
|
546
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
547
|
+
availableTags: ['v1.2.0'],
|
|
548
|
+
});
|
|
549
|
+
exitCode = 0;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
exitCode = err.exitCode;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
assert.equal(
|
|
555
|
+
exitCode,
|
|
556
|
+
1,
|
|
557
|
+
'expected exit code 1 when start marker is present but end marker is missing (malformed block)',
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('drift error names CLAUDE.md for a malformed block', async () => {
|
|
562
|
+
let errorMessage = '';
|
|
563
|
+
try {
|
|
564
|
+
await runCheck(consumerDir, {
|
|
565
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
566
|
+
availableTags: ['v1.2.0'],
|
|
567
|
+
});
|
|
568
|
+
} catch (err) {
|
|
569
|
+
errorMessage = err.message ?? '';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
assert.ok(
|
|
573
|
+
errorMessage.includes('CLAUDE.md'),
|
|
574
|
+
`drift error must name CLAUDE.md for a malformed block, got: ${errorMessage}`,
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('drifted array on the thrown error includes CLAUDE.md for a malformed block', async () => {
|
|
579
|
+
let drifted;
|
|
580
|
+
try {
|
|
581
|
+
await runCheck(consumerDir, {
|
|
582
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
583
|
+
availableTags: ['v1.2.0'],
|
|
584
|
+
});
|
|
585
|
+
} catch (err) {
|
|
586
|
+
drifted = err.drifted;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
assert.ok(
|
|
590
|
+
Array.isArray(drifted),
|
|
591
|
+
'expected err.drifted to be an array',
|
|
592
|
+
);
|
|
593
|
+
assert.ok(
|
|
594
|
+
drifted.some((d) => d.includes('CLAUDE.md')),
|
|
595
|
+
`expected err.drifted to include CLAUDE.md for malformed block, got: ${JSON.stringify(drifted)}`,
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('does not exit 2 (tooling error) for a malformed block — malformed is drift, not a crash', async () => {
|
|
600
|
+
let exitCode;
|
|
601
|
+
try {
|
|
602
|
+
await runCheck(consumerDir, {
|
|
603
|
+
fetcher: makeFixtureFetcherWithAutoRoute(),
|
|
604
|
+
availableTags: ['v1.2.0'],
|
|
605
|
+
});
|
|
606
|
+
exitCode = 0;
|
|
607
|
+
} catch (err) {
|
|
608
|
+
exitCode = err.exitCode;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
assert.notEqual(
|
|
612
|
+
exitCode,
|
|
613
|
+
2,
|
|
614
|
+
'malformed block must be treated as drift (exit 1), not a tooling error (exit 2)',
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// Guard — CLAUDE.md check must not affect the tooling-error (exit 2) path
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
describe('check — CLAUDE.md drift does not interact with tooling errors (exit 2)', () => {
|
|
624
|
+
it('still exits 2 on fetcher auth failure regardless of CLAUDE.md state', async () => {
|
|
625
|
+
const consumerDir = makeTmpDir();
|
|
626
|
+
try {
|
|
627
|
+
// No CLAUDE.md — would normally be drift — but fetcher errors first.
|
|
628
|
+
setupConsumerRepo(consumerDir, { claudeMdContent: 'omit' });
|
|
629
|
+
|
|
630
|
+
const authFailFetcher = async () => {
|
|
631
|
+
const err = new Error('Authentication failed: bad credentials');
|
|
632
|
+
err.type = 'auth';
|
|
633
|
+
throw err;
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
let exitCode;
|
|
637
|
+
try {
|
|
638
|
+
await runCheck(consumerDir, {
|
|
639
|
+
fetcher: authFailFetcher,
|
|
640
|
+
availableTags: ['v1.2.0'],
|
|
641
|
+
});
|
|
642
|
+
exitCode = 0;
|
|
643
|
+
} catch (err) {
|
|
644
|
+
exitCode = err.exitCode;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
assert.equal(
|
|
648
|
+
exitCode,
|
|
649
|
+
2,
|
|
650
|
+
'auth failure must produce exit code 2 even when CLAUDE.md is missing',
|
|
651
|
+
);
|
|
652
|
+
} finally {
|
|
653
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
});
|