@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,594 @@
1
+ /**
2
+ * tests/check-constitution-version.test.mjs
3
+ *
4
+ * Tests for scripts/check-constitution-version.mjs — the PR-CI script that
5
+ * enforces the "Constitution version: vN" bump rule on prompts/constitution.md.
6
+ *
7
+ * The script does NOT exist yet (version-bump-check-script slice is pending).
8
+ * All tests here are expected to fail with "Cannot find" / non-zero exit until
9
+ * the implement agent creates the script.
10
+ *
11
+ * Approach: real temporary git repos — no mocking of git. Each test:
12
+ * 1. Creates a tmpdir and initialises a bare git repo in it.
13
+ * 2. Makes one or two commits representing "base" and "HEAD" states.
14
+ * 3. Spawns `node <scriptPath> --base <mergeBase>` as a subprocess.
15
+ * 4. Asserts exit code and (where applicable) stderr content.
16
+ *
17
+ * Node built-in test runner (node:test) — no jest/mocha/vitest.
18
+ */
19
+
20
+ import { describe, it, before, after } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import { execSync, spawnSync } from 'node:child_process';
23
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import { tmpdir } from 'node:os';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Resolve script path — absolute, relative to this test file's location.
30
+ // The script lives at <repo-root>/scripts/check-constitution-version.mjs.
31
+ // This test file lives at packages/dev-agents-sync/tests/
32
+ // so ../../.. reaches the repo root.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const TEST_FILE_DIR = fileURLToPath(new URL('.', import.meta.url));
36
+ const SCRIPT_PATH = resolve(TEST_FILE_DIR, '..', '..', '..', 'scripts', 'check-constitution-version.mjs');
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Git helpers — all operate inside a provided repoDir.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Run a git command synchronously inside repoDir; throw on non-zero.
44
+ * @param {string} repoDir
45
+ * @param {string[]} args
46
+ * @returns {string} trimmed stdout
47
+ */
48
+ function git(repoDir, args) {
49
+ const result = spawnSync('git', args, {
50
+ cwd: repoDir,
51
+ encoding: 'utf8',
52
+ env: {
53
+ ...process.env,
54
+ // Ensure a consistent git identity so tests work in CI.
55
+ GIT_AUTHOR_NAME: 'Test',
56
+ GIT_AUTHOR_EMAIL: 'test@test.invalid',
57
+ GIT_COMMITTER_NAME: 'Test',
58
+ GIT_COMMITTER_EMAIL: 'test@test.invalid',
59
+ GIT_CONFIG_NOSYSTEM: '1',
60
+ },
61
+ });
62
+ if (result.status !== 0) {
63
+ throw new Error(
64
+ `git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr}`,
65
+ );
66
+ }
67
+ return (result.stdout || '').trimEnd();
68
+ }
69
+
70
+ /**
71
+ * Initialise a new git repo in a temp directory and make an initial (empty) commit
72
+ * so the repo has a valid HEAD.
73
+ * @returns {{ repoDir: string, cleanup: () => void }}
74
+ */
75
+ function createTempRepo() {
76
+ const repoDir = mkdtempSync(join(tmpdir(), 'const-ver-test-'));
77
+ git(repoDir, ['init', '-b', 'main']);
78
+ git(repoDir, ['config', 'user.email', 'test@test.invalid']);
79
+ git(repoDir, ['config', 'user.name', 'Test']);
80
+
81
+ // Initial commit so the repo is not empty.
82
+ writeFileSync(join(repoDir, 'README.md'), 'placeholder\n');
83
+ git(repoDir, ['add', 'README.md']);
84
+ git(repoDir, ['commit', '-m', 'initial']);
85
+
86
+ return {
87
+ repoDir,
88
+ cleanup: () => rmSync(repoDir, { recursive: true, force: true }),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Write prompts/constitution.md into a repo, stage and commit it.
94
+ * @param {string} repoDir
95
+ * @param {string} content — full file content
96
+ * @param {string} message — commit message
97
+ * @returns {string} the SHA of the new commit
98
+ */
99
+ function commitConstitution(repoDir, content, message) {
100
+ const promptsDir = join(repoDir, 'prompts');
101
+ mkdirSync(promptsDir, { recursive: true });
102
+ writeFileSync(join(promptsDir, 'constitution.md'), content);
103
+ git(repoDir, ['add', join('prompts', 'constitution.md')]);
104
+ git(repoDir, ['commit', '-m', message]);
105
+ return git(repoDir, ['rev-parse', 'HEAD']);
106
+ }
107
+
108
+ /**
109
+ * Resolve the merge-base SHA of HEAD against the given baseRef.
110
+ * @param {string} repoDir
111
+ * @param {string} baseRef — a commit SHA or ref
112
+ * @returns {string} merge-base SHA
113
+ */
114
+ function mergeBase(repoDir, baseRef) {
115
+ return git(repoDir, ['merge-base', 'HEAD', baseRef]);
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Canonical constitution content builders.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const BASE_CONTENT_V1 = `# Constitution
123
+
124
+ Constitution version: v1
125
+
126
+ ## Principles
127
+
128
+ §1 Be good.
129
+ §2 Be consistent.
130
+ `;
131
+
132
+ const BASE_CONTENT_V2_BUMPED = `# Constitution
133
+
134
+ Constitution version: v2
135
+
136
+ ## Principles
137
+
138
+ §1 Be good.
139
+ §2 Be consistent.
140
+ §3 Be kind.
141
+ `;
142
+
143
+ const BASE_CONTENT_V2_UNBUMPED = `# Constitution
144
+
145
+ Constitution version: v1
146
+
147
+ ## Principles
148
+
149
+ §1 Be good.
150
+ §2 Be consistent.
151
+ §3 Be kind.
152
+ `;
153
+
154
+ const BASE_CONTENT_V0_DECREASED = `# Constitution
155
+
156
+ Constitution version: v0
157
+
158
+ ## Principles
159
+
160
+ §1 Be good.
161
+ §2 Be consistent.
162
+ §3 Be kind.
163
+ `;
164
+
165
+ const BASE_CONTENT_VERSION_LINE_ONLY_CHANGE = `# Constitution
166
+
167
+ Constitution version: v2
168
+
169
+ ## Principles
170
+
171
+ §1 Be good.
172
+ §2 Be consistent.
173
+ `;
174
+
175
+ const BASE_CONTENT_MISSING_VERSION_LINE = `# Constitution
176
+
177
+ ## Principles
178
+
179
+ §1 Be good.
180
+ §2 Be consistent.
181
+ `;
182
+
183
+ const BASE_CONTENT_MALFORMED_VERSION_LINE = `# Constitution
184
+
185
+ Constitution version: banana
186
+
187
+ ## Principles
188
+
189
+ §1 Be good.
190
+ `;
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Invoke the script as a subprocess.
194
+ // Returns { status, stdout, stderr }.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Run the check script synchronously.
199
+ * @param {string} repoDir — cwd for the invocation
200
+ * @param {string[]} extraArgs — passed after `node <scriptPath>`
201
+ * @param {Record<string,string>} [extraEnv] — merged into process.env
202
+ * @returns {{ status: number, stdout: string, stderr: string }}
203
+ */
204
+ function runScript(repoDir, extraArgs = [], extraEnv = {}) {
205
+ const result = spawnSync('node', [SCRIPT_PATH, ...extraArgs], {
206
+ cwd: repoDir,
207
+ encoding: 'utf8',
208
+ env: {
209
+ ...process.env,
210
+ // Ensure GITHUB_BASE_REF is not accidentally inherited from CI.
211
+ GITHUB_BASE_REF: undefined,
212
+ GIT_AUTHOR_NAME: 'Test',
213
+ GIT_AUTHOR_EMAIL: 'test@test.invalid',
214
+ GIT_COMMITTER_NAME: 'Test',
215
+ GIT_COMMITTER_EMAIL: 'test@test.invalid',
216
+ GIT_CONFIG_NOSYSTEM: '1',
217
+ ...extraEnv,
218
+ },
219
+ });
220
+ return {
221
+ status: result.status ?? 1,
222
+ stdout: result.stdout || '',
223
+ stderr: result.stderr || '',
224
+ };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Test suite
229
+ // ---------------------------------------------------------------------------
230
+
231
+ describe('check-constitution-version.mjs', () => {
232
+ // -------------------------------------------------------------------------
233
+ // Case 1: Pass — no body change (file byte-identical at base and HEAD)
234
+ // -------------------------------------------------------------------------
235
+ it('Pass — no body change: file byte-identical → exit 0', () => {
236
+ const { repoDir, cleanup } = createTempRepo();
237
+ try {
238
+ // base commit
239
+ const baseCommit = commitConstitution(
240
+ repoDir,
241
+ BASE_CONTENT_V1,
242
+ 'add constitution',
243
+ );
244
+
245
+ // HEAD is the same commit (nothing changed).
246
+ const base = mergeBase(repoDir, baseCommit);
247
+ const result = runScript(repoDir, ['--base', base]);
248
+
249
+ assert.equal(
250
+ result.status,
251
+ 0,
252
+ `expected exit 0, got ${result.status}. stderr: ${result.stderr}`,
253
+ );
254
+ } finally {
255
+ cleanup();
256
+ }
257
+ });
258
+
259
+ // -------------------------------------------------------------------------
260
+ // Case 2: Pass — body change with proper bump (non-version line differs, integer increased)
261
+ // -------------------------------------------------------------------------
262
+ it('Pass — body change with proper bump: non-version line differs and integer increased → exit 0', () => {
263
+ const { repoDir, cleanup } = createTempRepo();
264
+ try {
265
+ // base commit (v1)
266
+ const baseCommit = commitConstitution(
267
+ repoDir,
268
+ BASE_CONTENT_V1,
269
+ 'add constitution v1',
270
+ );
271
+
272
+ // HEAD commit (v2, with a new principle)
273
+ commitConstitution(
274
+ repoDir,
275
+ BASE_CONTENT_V2_BUMPED,
276
+ 'bump constitution to v2',
277
+ );
278
+
279
+ const base = mergeBase(repoDir, baseCommit);
280
+ const result = runScript(repoDir, ['--base', base]);
281
+
282
+ assert.equal(
283
+ result.status,
284
+ 0,
285
+ `expected exit 0, got ${result.status}. stderr: ${result.stderr}`,
286
+ );
287
+ } finally {
288
+ cleanup();
289
+ }
290
+ });
291
+
292
+ // -------------------------------------------------------------------------
293
+ // Case 3: Pass — version-line-only change (only version line differs)
294
+ // -------------------------------------------------------------------------
295
+ it('Pass — version-line-only change: only the version line differs → exit 0', () => {
296
+ const { repoDir, cleanup } = createTempRepo();
297
+ try {
298
+ // base commit (v1)
299
+ const baseCommit = commitConstitution(
300
+ repoDir,
301
+ BASE_CONTENT_V1,
302
+ 'add constitution v1',
303
+ );
304
+
305
+ // HEAD commit: same body but version bumped to v2, no other change.
306
+ commitConstitution(
307
+ repoDir,
308
+ BASE_CONTENT_VERSION_LINE_ONLY_CHANGE,
309
+ 'pre-bump constitution version to v2',
310
+ );
311
+
312
+ const base = mergeBase(repoDir, baseCommit);
313
+ const result = runScript(repoDir, ['--base', base]);
314
+
315
+ assert.equal(
316
+ result.status,
317
+ 0,
318
+ `expected exit 0, got ${result.status}. stderr: ${result.stderr}`,
319
+ );
320
+ } finally {
321
+ cleanup();
322
+ }
323
+ });
324
+
325
+ // -------------------------------------------------------------------------
326
+ // Case 4: Pass — new file (did not exist at merge base, HEAD has parseable vN ≥ 1)
327
+ // -------------------------------------------------------------------------
328
+ it('Pass — new file (no base copy): HEAD has parseable vN line → exit 0', () => {
329
+ const { repoDir, cleanup } = createTempRepo();
330
+ try {
331
+ // Capture the initial commit SHA before any constitution exists.
332
+ const beforeConstitution = git(repoDir, ['rev-parse', 'HEAD']);
333
+
334
+ // Now add the constitution at HEAD.
335
+ commitConstitution(
336
+ repoDir,
337
+ BASE_CONTENT_V1,
338
+ 'introduce constitution',
339
+ );
340
+
341
+ // Merge base is the commit before the constitution was added.
342
+ const base = mergeBase(repoDir, beforeConstitution);
343
+ const result = runScript(repoDir, ['--base', base]);
344
+
345
+ assert.equal(
346
+ result.status,
347
+ 0,
348
+ `expected exit 0, got ${result.status}. stderr: ${result.stderr}`,
349
+ );
350
+ } finally {
351
+ cleanup();
352
+ }
353
+ });
354
+
355
+ // -------------------------------------------------------------------------
356
+ // Case 5: Fail — body change without bump (non-version line differs, version same)
357
+ // -------------------------------------------------------------------------
358
+ it('Fail — body change without bump: version stayed same → exit 1 with "bump from vN to vN+1" message', () => {
359
+ const { repoDir, cleanup } = createTempRepo();
360
+ try {
361
+ // base commit (v1)
362
+ const baseCommit = commitConstitution(
363
+ repoDir,
364
+ BASE_CONTENT_V1,
365
+ 'add constitution v1',
366
+ );
367
+
368
+ // HEAD commit: body changed but version NOT bumped.
369
+ commitConstitution(
370
+ repoDir,
371
+ BASE_CONTENT_V2_UNBUMPED,
372
+ 'add new principle (forgot to bump version)',
373
+ );
374
+
375
+ const base = mergeBase(repoDir, baseCommit);
376
+ const result = runScript(repoDir, ['--base', base]);
377
+
378
+ assert.equal(
379
+ result.status,
380
+ 1,
381
+ `expected exit 1, got ${result.status}. stderr: ${result.stderr}`,
382
+ );
383
+ assert.ok(
384
+ result.stderr.includes('bump from v1 to v2'),
385
+ `expected "bump from v1 to v2" in stderr, got: ${result.stderr}`,
386
+ );
387
+ } finally {
388
+ cleanup();
389
+ }
390
+ });
391
+
392
+ // -------------------------------------------------------------------------
393
+ // Case 6: Fail — body change with version decrease (integer decreased)
394
+ // -------------------------------------------------------------------------
395
+ it('Fail — body change with version decrease: integer decreased → exit 1 with bump message', () => {
396
+ const { repoDir, cleanup } = createTempRepo();
397
+ try {
398
+ // base commit (v1)
399
+ const baseCommit = commitConstitution(
400
+ repoDir,
401
+ BASE_CONTENT_V1,
402
+ 'add constitution v1',
403
+ );
404
+
405
+ // HEAD commit: body changed AND version decreased to v0.
406
+ commitConstitution(
407
+ repoDir,
408
+ BASE_CONTENT_V0_DECREASED,
409
+ 'accidentally decreased version',
410
+ );
411
+
412
+ const base = mergeBase(repoDir, baseCommit);
413
+ const result = runScript(repoDir, ['--base', base]);
414
+
415
+ assert.equal(
416
+ result.status,
417
+ 1,
418
+ `expected exit 1, got ${result.status}. stderr: ${result.stderr}`,
419
+ );
420
+ // The decrease case should produce a "bump from vN to vN+1" message because
421
+ // the version did not increase — the specific values reference the base
422
+ // side (v1) and the required next value (v2).
423
+ assert.ok(
424
+ result.stderr.includes('bump from'),
425
+ `expected "bump from" in stderr for version-decrease case, got: ${result.stderr}`,
426
+ );
427
+ } finally {
428
+ cleanup();
429
+ }
430
+ });
431
+
432
+ // -------------------------------------------------------------------------
433
+ // Case 7a: Fail — missing version line at HEAD
434
+ // -------------------------------------------------------------------------
435
+ it('Fail — missing version line at HEAD: absent → exit 1 with "missing or malformed" message', () => {
436
+ const { repoDir, cleanup } = createTempRepo();
437
+ try {
438
+ // base commit (valid v1)
439
+ const baseCommit = commitConstitution(
440
+ repoDir,
441
+ BASE_CONTENT_V1,
442
+ 'add constitution v1',
443
+ );
444
+
445
+ // HEAD commit: version line removed.
446
+ commitConstitution(
447
+ repoDir,
448
+ BASE_CONTENT_MISSING_VERSION_LINE,
449
+ 'accidentally removed version line',
450
+ );
451
+
452
+ const base = mergeBase(repoDir, baseCommit);
453
+ const result = runScript(repoDir, ['--base', base]);
454
+
455
+ assert.equal(
456
+ result.status,
457
+ 1,
458
+ `expected exit 1, got ${result.status}. stderr: ${result.stderr}`,
459
+ );
460
+ assert.ok(
461
+ result.stderr.includes('missing or malformed'),
462
+ `expected "missing or malformed" in stderr, got: ${result.stderr}`,
463
+ );
464
+ } finally {
465
+ cleanup();
466
+ }
467
+ });
468
+
469
+ // -------------------------------------------------------------------------
470
+ // Case 7b: Fail — malformed version line at HEAD
471
+ // -------------------------------------------------------------------------
472
+ it('Fail — malformed version line at HEAD: non-integer value → exit 1 with "missing or malformed" message', () => {
473
+ const { repoDir, cleanup } = createTempRepo();
474
+ try {
475
+ // base commit (valid v1)
476
+ const baseCommit = commitConstitution(
477
+ repoDir,
478
+ BASE_CONTENT_V1,
479
+ 'add constitution v1',
480
+ );
481
+
482
+ // HEAD commit: version line present but malformed.
483
+ commitConstitution(
484
+ repoDir,
485
+ BASE_CONTENT_MALFORMED_VERSION_LINE,
486
+ 'accidentally malformed version line',
487
+ );
488
+
489
+ const base = mergeBase(repoDir, baseCommit);
490
+ const result = runScript(repoDir, ['--base', base]);
491
+
492
+ assert.equal(
493
+ result.status,
494
+ 1,
495
+ `expected exit 1, got ${result.status}. stderr: ${result.stderr}`,
496
+ );
497
+ assert.ok(
498
+ result.stderr.includes('missing or malformed'),
499
+ `expected "missing or malformed" in stderr, got: ${result.stderr}`,
500
+ );
501
+ } finally {
502
+ cleanup();
503
+ }
504
+ });
505
+
506
+ // -------------------------------------------------------------------------
507
+ // Case 8: Fail — missing base ref (neither GITHUB_BASE_REF nor --base set)
508
+ // -------------------------------------------------------------------------
509
+ it('Fail — missing base ref: neither GITHUB_BASE_REF nor --base → exit 1 with actionable message', () => {
510
+ const { repoDir, cleanup } = createTempRepo();
511
+ try {
512
+ commitConstitution(repoDir, BASE_CONTENT_V1, 'add constitution');
513
+
514
+ // Run with no --base and GITHUB_BASE_REF explicitly unset.
515
+ const result = spawnSync('node', [SCRIPT_PATH], {
516
+ cwd: repoDir,
517
+ encoding: 'utf8',
518
+ env: {
519
+ ...process.env,
520
+ GITHUB_BASE_REF: undefined,
521
+ GIT_AUTHOR_NAME: 'Test',
522
+ GIT_AUTHOR_EMAIL: 'test@test.invalid',
523
+ GIT_COMMITTER_NAME: 'Test',
524
+ GIT_COMMITTER_EMAIL: 'test@test.invalid',
525
+ GIT_CONFIG_NOSYSTEM: '1',
526
+ },
527
+ });
528
+
529
+ assert.equal(
530
+ (result.status ?? 1),
531
+ 1,
532
+ `expected exit 1, got ${result.status}. stderr: ${result.stderr}`,
533
+ );
534
+
535
+ const combined = (result.stderr || '') + (result.stdout || '');
536
+
537
+ // The error message must name both inputs so the user knows how to fix it.
538
+ assert.ok(
539
+ combined.includes('GITHUB_BASE_REF'),
540
+ `expected "GITHUB_BASE_REF" in output, got: ${combined}`,
541
+ );
542
+ assert.ok(
543
+ combined.includes('--base'),
544
+ `expected "--base" in output, got: ${combined}`,
545
+ );
546
+ } finally {
547
+ cleanup();
548
+ }
549
+ });
550
+
551
+ // -------------------------------------------------------------------------
552
+ // Case 9: Determinism — same inputs → identical exit code and output both runs
553
+ // -------------------------------------------------------------------------
554
+ it('Determinism — same inputs produce identical exit code and output on two runs', () => {
555
+ const { repoDir, cleanup } = createTempRepo();
556
+ try {
557
+ // Set up a "fail" scenario (body changed, version not bumped) so both
558
+ // stderr output and exit code are non-trivial and comparable.
559
+ const baseCommit = commitConstitution(
560
+ repoDir,
561
+ BASE_CONTENT_V1,
562
+ 'add constitution v1',
563
+ );
564
+ commitConstitution(
565
+ repoDir,
566
+ BASE_CONTENT_V2_UNBUMPED,
567
+ 'add new principle (forgot to bump version)',
568
+ );
569
+
570
+ const base = mergeBase(repoDir, baseCommit);
571
+
572
+ const run1 = runScript(repoDir, ['--base', base]);
573
+ const run2 = runScript(repoDir, ['--base', base]);
574
+
575
+ assert.equal(
576
+ run1.status,
577
+ run2.status,
578
+ `exit codes differ: run1=${run1.status} run2=${run2.status}`,
579
+ );
580
+ assert.equal(
581
+ run1.stderr,
582
+ run2.stderr,
583
+ `stderr differs between runs:\nrun1: ${run1.stderr}\nrun2: ${run2.stderr}`,
584
+ );
585
+ assert.equal(
586
+ run1.stdout,
587
+ run2.stdout,
588
+ `stdout differs between runs:\nrun1: ${run1.stdout}\nrun2: ${run2.stdout}`,
589
+ );
590
+ } finally {
591
+ cleanup();
592
+ }
593
+ });
594
+ });