@dalzoubi/dev-agents-sync 1.0.14 → 1.0.16

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,440 @@
1
+ /**
2
+ * tests/root-target.test.mjs
3
+ *
4
+ * Tests for the `root` target prefix:
5
+ * - resolveConsumerPath maps root/ keys to the consumer repo root
6
+ * - normalizeFileMap preserves root/ keys without warning or dropping
7
+ * - init/update emit root-targeted files unconditionally (regardless of lockfile targets)
8
+ * - check evaluates root-targeted files unconditionally
9
+ * - writeManagedFile refuses to overwrite a root-targeted file whose marker is not on the first line
10
+ * - existing .claude/.cursor/.github first-time creations remain silent (no regression)
11
+ */
12
+
13
+ import { describe, it, beforeEach, afterEach } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import {
16
+ mkdtempSync,
17
+ mkdirSync,
18
+ writeFileSync,
19
+ readFileSync,
20
+ existsSync,
21
+ rmSync,
22
+ readdirSync,
23
+ } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { resolveConsumerPath, normalizeFileMap, writeManagedFile } from '../src/writer.mjs';
29
+ import { hasMarkerOnFirstLine } from '../src/marker.mjs';
30
+ import { runInit } from '../src/commands/init.mjs';
31
+ import { runUpdate } from '../src/commands/update.mjs';
32
+ import { runCheck } from '../src/commands/check.mjs';
33
+ import { writeLockfile } from '../src/lockfile.mjs';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Fixture helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
41
+
42
+ const CONSTITUTION_FIXTURE = readFileSync(
43
+ path.join(FIXTURE_DIR, 'root', 'CONSTITUTION.md'),
44
+ 'utf8',
45
+ );
46
+
47
+ function buildFixtureFileMap(targets = ['claude', 'cursor', 'root']) {
48
+ const map = {};
49
+ for (const t of targets) {
50
+ const targetDir = path.join(FIXTURE_DIR, t);
51
+ if (!existsSync(targetDir)) continue;
52
+ collectFiles(targetDir, targetDir, map, t);
53
+ }
54
+ return map;
55
+ }
56
+
57
+ function collectFiles(baseDir, currentDir, map, prefix) {
58
+ const entries = readdirSync(currentDir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ const full = path.join(currentDir, entry.name);
61
+ if (entry.isDirectory()) {
62
+ collectFiles(baseDir, full, map, prefix);
63
+ } else {
64
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
65
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
66
+ }
67
+ }
68
+ }
69
+
70
+ function makeFullFetcher() {
71
+ return async () => buildFixtureFileMap(['claude', 'cursor', 'root']);
72
+ }
73
+
74
+ function makeTmpDir() {
75
+ return mkdtempSync(path.join(tmpdir(), 'das-root-test-'));
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // resolveConsumerPath — root target
80
+ // ---------------------------------------------------------------------------
81
+
82
+ describe('resolveConsumerPath — root target', () => {
83
+ it('maps root/CONSTITUTION.md to <consumerRoot>/CONSTITUTION.md', () => {
84
+ const root = '/some/consumer';
85
+ const result = resolveConsumerPath(root, 'root/CONSTITUTION.md');
86
+ assert.equal(result, path.join(root, 'CONSTITUTION.md'));
87
+ });
88
+
89
+ it('maps root/NESTED/FILE.md to <consumerRoot>/NESTED/FILE.md', () => {
90
+ const root = '/some/consumer';
91
+ const result = resolveConsumerPath(root, 'root/NESTED/FILE.md');
92
+ assert.equal(result, path.join(root, 'NESTED', 'FILE.md'));
93
+ });
94
+
95
+ it('still rejects path traversal in root-targeted paths', () => {
96
+ assert.throws(
97
+ () => resolveConsumerPath('/consumer', 'root/../etc/passwd'),
98
+ /traversal/i,
99
+ );
100
+ });
101
+
102
+ it('still rejects target-only root path (no file segment)', () => {
103
+ assert.throws(
104
+ () => resolveConsumerPath('/consumer', 'root'),
105
+ /target only/i,
106
+ );
107
+ });
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // normalizeFileMap — root keys
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('normalizeFileMap — root/ keys are preserved without warning', () => {
115
+ it('passes through root/CONSTITUTION.md untouched', () => {
116
+ const out = normalizeFileMap({ 'root/CONSTITUTION.md': 'content' });
117
+ assert.equal(out['root/CONSTITUTION.md'], 'content');
118
+ });
119
+
120
+ it('does not warn when processing root/ keys', () => {
121
+ const origWrite = process.stderr.write.bind(process.stderr);
122
+ let captured = '';
123
+ process.stderr.write = (chunk) => {
124
+ captured += typeof chunk === 'string' ? chunk : chunk.toString();
125
+ return true;
126
+ };
127
+ try {
128
+ normalizeFileMap({ 'root/CONSTITUTION.md': 'content', 'claude/agents/define.md': 'A' });
129
+ assert.equal(captured, '', `expected no stderr for root/ keys, got: ${captured}`);
130
+ } finally {
131
+ process.stderr.write = origWrite;
132
+ }
133
+ });
134
+ });
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // writeManagedFile — root target marker enforcement
138
+ // ---------------------------------------------------------------------------
139
+
140
+ describe('writeManagedFile — root target first-line marker enforcement', () => {
141
+ let tmpDir;
142
+
143
+ beforeEach(() => { tmpDir = makeTmpDir(); });
144
+ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
145
+
146
+ it('refuses to overwrite a root-targeted file that has no marker', () => {
147
+ const absPath = path.join(tmpDir, 'CONSTITUTION.md');
148
+ writeFileSync(absPath, '# My Constitution\nNo marker here.\n', 'utf8');
149
+
150
+ assert.throws(
151
+ () => writeManagedFile({ absPath, relKey: 'root/CONSTITUTION.md', content: 'new', force: false }),
152
+ /unmanaged|marker|force/i,
153
+ );
154
+ });
155
+
156
+ it('refuses to overwrite a root-targeted file whose marker is not the first physical line', () => {
157
+ const absPath = path.join(tmpDir, 'CONSTITUTION.md');
158
+ // Marker exists but buried after a blank line
159
+ writeFileSync(
160
+ absPath,
161
+ '\n<!-- managed-by: dev-agents-sync v1.0.0 -->\n# Constitution\n',
162
+ 'utf8',
163
+ );
164
+
165
+ assert.throws(
166
+ () => writeManagedFile({ absPath, relKey: 'root/CONSTITUTION.md', content: 'new', force: false }),
167
+ /unmanaged|marker|force/i,
168
+ );
169
+ });
170
+
171
+ it('allows overwriting a root-targeted file when marker is the literal first line', () => {
172
+ const absPath = path.join(tmpDir, 'CONSTITUTION.md');
173
+ writeFileSync(
174
+ absPath,
175
+ '<!-- managed-by: dev-agents-sync v1.0.0 -->\n# Constitution\nConstitution version: v1\n',
176
+ 'utf8',
177
+ );
178
+
179
+ assert.doesNotThrow(() =>
180
+ writeManagedFile({
181
+ absPath,
182
+ relKey: 'root/CONSTITUTION.md',
183
+ content: '<!-- managed-by: dev-agents-sync v1.1.0 -->\n# Constitution\nConstitution version: v2\n',
184
+ force: false,
185
+ }),
186
+ );
187
+
188
+ const written = readFileSync(absPath, 'utf8');
189
+ assert.ok(written.includes('v1.1.0'));
190
+ });
191
+
192
+ it('--force overwrites even when root-targeted file has no marker', () => {
193
+ const absPath = path.join(tmpDir, 'CONSTITUTION.md');
194
+ writeFileSync(absPath, '# No marker\n', 'utf8');
195
+
196
+ assert.doesNotThrow(() =>
197
+ writeManagedFile({
198
+ absPath,
199
+ relKey: 'root/CONSTITUTION.md',
200
+ content: '<!-- managed-by: dev-agents-sync v1.0.0 -->\n# Constitution\n',
201
+ force: true,
202
+ }),
203
+ );
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // init — unconditional root emission
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe('init — root-targeted files emitted unconditionally', () => {
212
+ let consumerDir;
213
+
214
+ beforeEach(() => { consumerDir = makeTmpDir(); });
215
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
216
+
217
+ it('emits CONSTITUTION.md at consumer root even when targets is ["cursor"]', async () => {
218
+ await runInit(consumerDir, {
219
+ targets: ['cursor'],
220
+ fetcher: makeFullFetcher(),
221
+ availableTags: ['v1.0.0'],
222
+ });
223
+
224
+ const constitutionPath = path.join(consumerDir, 'CONSTITUTION.md');
225
+ assert.ok(existsSync(constitutionPath), 'CONSTITUTION.md must exist at consumer root');
226
+
227
+ const content = readFileSync(constitutionPath, 'utf8');
228
+ assert.ok(hasMarkerOnFirstLine(content), 'marker must be the first physical line');
229
+ assert.ok(content.includes('Constitution version: v1'), 'body version line must be present');
230
+ });
231
+
232
+ it('emits CONSTITUTION.md even when targets is ["claude"]', async () => {
233
+ await runInit(consumerDir, {
234
+ targets: ['claude'],
235
+ fetcher: makeFullFetcher(),
236
+ availableTags: ['v1.0.0'],
237
+ });
238
+
239
+ assert.ok(
240
+ existsSync(path.join(consumerDir, 'CONSTITUTION.md')),
241
+ 'CONSTITUTION.md must exist regardless of claude-only targets',
242
+ );
243
+ });
244
+
245
+ it('still writes claude/ files when targets includes claude (no regression)', async () => {
246
+ await runInit(consumerDir, {
247
+ targets: ['claude'],
248
+ fetcher: makeFullFetcher(),
249
+ availableTags: ['v1.0.0'],
250
+ });
251
+
252
+ assert.ok(
253
+ existsSync(path.join(consumerDir, '.claude', 'agents', 'define.md')),
254
+ '.claude/agents/define.md must still be written',
255
+ );
256
+ });
257
+
258
+ it('refuses to overwrite an unmanaged CONSTITUTION.md without --force', async () => {
259
+ writeFileSync(path.join(consumerDir, 'CONSTITUTION.md'), '# My custom\n', 'utf8');
260
+
261
+ await assert.rejects(
262
+ runInit(consumerDir, {
263
+ targets: ['claude'],
264
+ fetcher: makeFullFetcher(),
265
+ availableTags: ['v1.0.0'],
266
+ }),
267
+ /unmanaged|marker|force/i,
268
+ );
269
+ });
270
+
271
+ it('refuses to overwrite a CONSTITUTION.md whose marker is buried after a blank line (H1 regression)', async () => {
272
+ // Marker exists but is NOT the first physical line — should be treated as unmanaged
273
+ writeFileSync(
274
+ path.join(consumerDir, 'CONSTITUTION.md'),
275
+ '\n<!-- managed-by: dev-agents-sync v1.0.0 -->\n# Constitution\n',
276
+ 'utf8',
277
+ );
278
+
279
+ await assert.rejects(
280
+ runInit(consumerDir, {
281
+ targets: ['claude'],
282
+ fetcher: makeFullFetcher(),
283
+ availableTags: ['v1.0.0'],
284
+ }),
285
+ /unmanaged|marker|force/i,
286
+ );
287
+ });
288
+
289
+ it('lockfile targets array does not contain "root" after init', async () => {
290
+ await runInit(consumerDir, {
291
+ targets: ['claude'],
292
+ fetcher: makeFullFetcher(),
293
+ availableTags: ['v1.0.0'],
294
+ });
295
+
296
+ const lockRaw = readFileSync(
297
+ path.join(consumerDir, '.dev-agents-sync.json'),
298
+ 'utf8',
299
+ );
300
+ const lock = JSON.parse(lockRaw);
301
+ assert.ok(!lock.targets.includes('root'), 'lockfile targets must not include "root"');
302
+ });
303
+ });
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // update — unconditional root emission
307
+ // ---------------------------------------------------------------------------
308
+
309
+ describe('update — root-targeted files emitted unconditionally', () => {
310
+ let consumerDir;
311
+
312
+ beforeEach(() => { consumerDir = makeTmpDir(); });
313
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
314
+
315
+ it('emits CONSTITUTION.md when lockfile targets is ["cursor"] only', async () => {
316
+ writeLockfile(consumerDir, {
317
+ source: 'github:dalzoubi/dev-agents',
318
+ range: '^1',
319
+ resolvedVersion: '1.0.0',
320
+ targets: ['cursor'],
321
+ lastUpdated: '2026-04-01T00:00:00Z',
322
+ });
323
+ // pre-write a cursor file so update has something to compare
324
+ const rulesDir = path.join(consumerDir, '.cursor', 'rules');
325
+ mkdirSync(rulesDir, { recursive: true });
326
+ writeFileSync(
327
+ path.join(rulesDir, 'define.mdc'),
328
+ readFileSync(path.join(FIXTURE_DIR, 'cursor', 'rules', 'define.mdc'), 'utf8'),
329
+ 'utf8',
330
+ );
331
+
332
+ // Update with a fetcher that simulates v1.1.0 (updated marker only)
333
+ const v110Fetcher = async () => {
334
+ const base = buildFixtureFileMap(['claude', 'cursor', 'root']);
335
+ const updated = {};
336
+ for (const [key, content] of Object.entries(base)) {
337
+ updated[key] = content.replace('v1.0.0', 'v1.1.0');
338
+ }
339
+ return updated;
340
+ };
341
+
342
+ await runUpdate(consumerDir, {
343
+ fetcher: v110Fetcher,
344
+ availableTags: ['v1.0.0', 'v1.1.0'],
345
+ });
346
+
347
+ const constitutionPath = path.join(consumerDir, 'CONSTITUTION.md');
348
+ assert.ok(existsSync(constitutionPath), 'CONSTITUTION.md must be emitted on update');
349
+ const content = readFileSync(constitutionPath, 'utf8');
350
+ assert.ok(hasMarkerOnFirstLine(content), 'marker must be first line after update');
351
+ });
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // check — unconditional root evaluation
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe('check — root-targeted files checked unconditionally', () => {
359
+ let consumerDir;
360
+
361
+ beforeEach(() => { consumerDir = makeTmpDir(); });
362
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
363
+
364
+ it('exits 1 (drift) when CONSTITUTION.md is missing even with cursor-only targets', async () => {
365
+ writeLockfile(consumerDir, {
366
+ source: 'github:dalzoubi/dev-agents',
367
+ range: '^1',
368
+ resolvedVersion: '1.0.0',
369
+ targets: ['cursor'],
370
+ lastUpdated: '2026-04-01T00:00:00Z',
371
+ });
372
+ // CONSTITUTION.md intentionally not written
373
+
374
+ await assert.rejects(
375
+ runCheck(consumerDir, { fetcher: makeFullFetcher() }),
376
+ /drift/i,
377
+ );
378
+ });
379
+
380
+ it('exits 1 (drift) when CONSTITUTION.md content differs from expected (version line edited)', async () => {
381
+ writeLockfile(consumerDir, {
382
+ source: 'github:dalzoubi/dev-agents',
383
+ range: '^1',
384
+ resolvedVersion: '1.0.0',
385
+ targets: ['claude'],
386
+ lastUpdated: '2026-04-01T00:00:00Z',
387
+ });
388
+
389
+ // Write a tampered CONSTITUTION.md (changed version line)
390
+ const tampered = CONSTITUTION_FIXTURE.replace(
391
+ 'Constitution version: v1',
392
+ 'Constitution version: v2',
393
+ );
394
+ writeFileSync(path.join(consumerDir, 'CONSTITUTION.md'), tampered, 'utf8');
395
+
396
+ // Also write the claude agent so that target isn't missing
397
+ mkdirSync(path.join(consumerDir, '.claude', 'agents'), { recursive: true });
398
+ writeFileSync(
399
+ path.join(consumerDir, '.claude', 'agents', 'define.md'),
400
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
401
+ 'utf8',
402
+ );
403
+
404
+ await assert.rejects(
405
+ runCheck(consumerDir, { fetcher: makeFullFetcher() }),
406
+ /drift/i,
407
+ );
408
+ });
409
+
410
+ it('exits 0 when CONSTITUTION.md matches expected exactly', async () => {
411
+ // Use a fetcher that returns only root + one claude file to keep the
412
+ // test setup minimal and deterministic.
413
+ const minimalFetcher = async () => ({
414
+ 'root/CONSTITUTION.md': CONSTITUTION_FIXTURE,
415
+ 'claude/agents/define.md': readFileSync(
416
+ path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'),
417
+ 'utf8',
418
+ ),
419
+ });
420
+
421
+ writeLockfile(consumerDir, {
422
+ source: 'github:dalzoubi/dev-agents',
423
+ range: '^1',
424
+ resolvedVersion: '1.0.0',
425
+ targets: ['claude'],
426
+ lastUpdated: '2026-04-01T00:00:00Z',
427
+ });
428
+
429
+ writeFileSync(path.join(consumerDir, 'CONSTITUTION.md'), CONSTITUTION_FIXTURE, 'utf8');
430
+
431
+ mkdirSync(path.join(consumerDir, '.claude', 'agents'), { recursive: true });
432
+ writeFileSync(
433
+ path.join(consumerDir, '.claude', 'agents', 'define.md'),
434
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
435
+ 'utf8',
436
+ );
437
+
438
+ await assert.doesNotReject(runCheck(consumerDir, { fetcher: minimalFetcher }));
439
+ });
440
+ });