@boyingliu01/xp-gate 0.14.21 → 0.14.23

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,491 @@
1
+ /**
2
+ * @test REQ-TDD-005 docs-drift-check
3
+ * @intent Verify checkDocsDrift() correctly detects Gate count mismatches between scripts and docs
4
+ * @covers AC-TDD-005-01, AC-TDD-005-02, AC-TDD-005-03
5
+ */
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const { checkDocsDrift, checkAdapterDrift } = require('../sync-package-content');
13
+
14
+ function createTempDir() {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'drift-test-'));
16
+ }
17
+
18
+ function cleanupTempDir(dir) {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ }
21
+
22
+ function writeScript(filePath, content) {
23
+ fs.writeFileSync(filePath, content, 'utf8');
24
+ }
25
+
26
+ function writeDoc(filePath, content) {
27
+ fs.writeFileSync(filePath, content, 'utf8');
28
+ }
29
+
30
+ describe('checkDocsDrift', () => {
31
+ let tempDir;
32
+ let preCommitPath;
33
+ let prePushPath;
34
+ let readmePath;
35
+ let agentsPath;
36
+
37
+ beforeEach(() => {
38
+ tempDir = createTempDir();
39
+ preCommitPath = path.join(tempDir, 'pre-commit');
40
+ prePushPath = path.join(tempDir, 'pre-push');
41
+ readmePath = path.join(tempDir, 'README.md');
42
+ agentsPath = path.join(tempDir, 'AGENTS.md');
43
+ });
44
+
45
+ afterEach(() => {
46
+ cleanupTempDir(tempDir);
47
+ });
48
+
49
+ it('returns true when pre-commit gate counts match between script and docs', () => {
50
+ // Script with 3 gates (Gate 1, Gate 2, Gate 3)
51
+ const scriptContent = [
52
+ '#!/bin/bash',
53
+ '# Gate 1: Code Quality',
54
+ 'echo "running gate 1"',
55
+ '# Gate 2: Duplicate Code',
56
+ 'echo "running gate 2"',
57
+ '# Gate 3: Complexity',
58
+ 'echo "running gate 3"',
59
+ ].join('\n');
60
+ writeScript(preCommitPath, scriptContent);
61
+
62
+ // Pre-push with 0 M-gates (not relevant for this test)
63
+ writeScript(prePushPath, '#!/bin/bash\necho "no gates"');
64
+
65
+ // README with 3 pre-commit gates (Gate 1, Gate 2, Gate 3) - match script
66
+ const readmeContent = [
67
+ '# README',
68
+ '| Gate | Name |',
69
+ '|------|------|',
70
+ '| Gate 1 | Code Quality |',
71
+ '| Gate 2 | Duplicate Code |',
72
+ '| Gate 3 | Complexity |',
73
+ ].join('\n');
74
+ writeDoc(readmePath, readmeContent);
75
+
76
+ // AGENTS.md with 3 pre-commit gates (1, 2, 3) - match script
77
+ const agentsContent = [
78
+ '# AGENTS',
79
+ '| Gate | Name |',
80
+ '|------|------|',
81
+ '| 1 | Code Quality |',
82
+ '| 2 | Duplicate Code |',
83
+ '| 3 | Complexity |',
84
+ ].join('\n');
85
+ writeDoc(agentsPath, agentsContent);
86
+
87
+ const result = checkDocsDrift(preCommitPath, prePushPath, readmePath, agentsPath);
88
+ expect(result).toBe(true, 'should return true when gate counts match');
89
+ });
90
+
91
+ it('returns false when pre-commit gate count in script exceeds docs', () => {
92
+ // Script with 3 gates
93
+ const scriptContent = [
94
+ '#!/bin/bash',
95
+ '# Gate 1: Code Quality',
96
+ '# Gate 2: Duplicate Code',
97
+ '# Gate 3: Complexity',
98
+ ].join('\n');
99
+ writeScript(preCommitPath, scriptContent);
100
+ writeScript(prePushPath, '#!/bin/bash');
101
+
102
+ // README with only 2 pre-commit gates (fewer than script: 1, 2)
103
+ const readmeContent = [
104
+ '| Gate | Name |',
105
+ '|------|------|',
106
+ '| Gate 1 | Code Quality |',
107
+ '| Gate 2 | Duplicate Code |',
108
+ ].join('\n');
109
+ writeDoc(readmePath, readmeContent);
110
+
111
+ // AGENTS.md with 2 pre-commit gates (1, 2) - matches README
112
+ const agentsContent = [
113
+ '| Gate | Name |',
114
+ '|------|------|',
115
+ '| 1 | Code Quality |',
116
+ '| 2 | Duplicate Code |',
117
+ ].join('\n');
118
+ writeDoc(agentsPath, agentsContent);
119
+
120
+ const result = checkDocsDrift(preCommitPath, prePushPath, readmePath, agentsPath);
121
+ expect(result).toBe(false, 'should return false when gate count exceeds docs');
122
+ });
123
+
124
+ it('returns false when pre-push gate count mismatches', () => {
125
+ // Pre-commit: 1 gate in script, 1 in docs (match)
126
+ const scriptContent = '#!/bin/bash\n# Gate 1: Code Quality';
127
+ writeScript(preCommitPath, scriptContent);
128
+ const readmePreCommit = '| Gate | Name |\n|------|------|\n| Gate 1 | Code Quality |';
129
+ const agentsPreCommit = '| Gate | Name |\n|------|------|\n| 1 | Code Quality |';
130
+
131
+ // Pre-push: 2 M-gates in script (M, M2)
132
+ const prePushContent = [
133
+ '#!/bin/bash',
134
+ '# Gate M: Mutation Testing',
135
+ 'echo "gate m"',
136
+ '# Gate M2: Mock Density',
137
+ 'echo "gate m2"',
138
+ ].join('\n');
139
+ writeScript(prePushPath, prePushContent);
140
+
141
+ // README: 1 pre-push gate (only M, missing M2)
142
+ const readmeContent = [
143
+ readmePreCommit,
144
+ '',
145
+ '| Gate M | Mutation |',
146
+ ].join('\n');
147
+ writeDoc(readmePath, readmeContent);
148
+
149
+ // AGENTS.md: 2 pre-push gates (M, M2 — matches script)
150
+ const agentsContent = [
151
+ agentsPreCommit,
152
+ '',
153
+ '| M | Mutation |',
154
+ '| M2 | Mock Density |',
155
+ ].join('\n');
156
+ writeDoc(agentsPath, agentsContent);
157
+
158
+ const result = checkDocsDrift(preCommitPath, prePushPath, readmePath, agentsPath);
159
+ expect(result).toBe(false, 'should return false when pre-push gate count mismatches');
160
+ });
161
+
162
+ it('skips missing doc files with warning instead of crashing', () => {
163
+ // Script with 1 gate
164
+ const scriptContent = '#!/bin/bash\n# Gate 1: Code Quality';
165
+ writeScript(preCommitPath, scriptContent);
166
+ writeScript(prePushPath, '#!/bin/bash');
167
+
168
+ // README exists with matching count
169
+ const readmeContent = [
170
+ '| Gate | Name |',
171
+ '|------|------|',
172
+ '| Gate 1 | Code Quality |',
173
+ ].join('\n');
174
+ writeDoc(readmePath, readmeContent);
175
+
176
+ // AGENTS.md does NOT exist — should be skipped with warning
177
+ const nonExistentAgents = path.join(tempDir, 'NONEXISTENT_AGENTS.md');
178
+
179
+ const result = checkDocsDrift(preCommitPath, prePushPath, readmePath, nonExistentAgents);
180
+ expect(result).toBe(true, 'should return true when missing doc skipped');
181
+ });
182
+
183
+ it('returns true when pre-push gate counts match', () => {
184
+ // Pre-commit: 1 gate in script, 1 in docs
185
+ writeScript(preCommitPath, '#!/bin/bash\n# Gate 1: Code Quality');
186
+ const readmePreCommit = '| Gate | Name |\n|------|------|\n| Gate 1 | Code Quality |';
187
+ const agentsPreCommit = '| Gate | Name |\n|------|------|\n| 1 | Code Quality |';
188
+
189
+ // Pre-push: 3 M-gates in script (M, M2, M3)
190
+ const prePushContent = [
191
+ '#!/bin/bash',
192
+ '# Gate M: Mutation Testing',
193
+ '# Gate M2: Mock Density',
194
+ '# Gate M3: Mock Policy',
195
+ ].join('\n');
196
+ writeScript(prePushPath, prePushContent);
197
+
198
+ // README: 3 pre-push gates
199
+ const readmeContent = [
200
+ readmePreCommit,
201
+ '',
202
+ '| Gate M | Mutation |',
203
+ '| Gate M2 | Mock Density |',
204
+ '| Gate M3 | Mock Policy |',
205
+ ].join('\n');
206
+ writeDoc(readmePath, readmeContent);
207
+
208
+ // AGENTS.md: 3 pre-push gates
209
+ const agentsContent = [
210
+ agentsPreCommit,
211
+ '',
212
+ '| M | Mutation |',
213
+ '| M2 | Mock Density |',
214
+ '| M3 | Mock Policy |',
215
+ ].join('\n');
216
+ writeDoc(agentsPath, agentsContent);
217
+
218
+ const result = checkDocsDrift(preCommitPath, prePushPath, readmePath, agentsPath);
219
+ expect(result).toBe(true, 'should return true when pre-push gate counts match');
220
+ });
221
+ });
222
+
223
+ /**
224
+ * @test REQ-335 sprint-gate-sync
225
+ * @intent Verify syncAdapters() includes sprint-gate.sh in gate script sync
226
+ * (not only gate-*.sh)
227
+ * @covers AC-335-01
228
+ */
229
+ describe('syncGateScripts', () => {
230
+ const { syncAdapters } = require('../sync-package-content');
231
+ let tempDir;
232
+ let pkgRoot;
233
+ let repoRoot;
234
+
235
+ beforeEach(() => {
236
+ tempDir = createTempDir();
237
+ pkgRoot = path.join(tempDir, 'pkg');
238
+ fs.mkdirSync(pkgRoot, { recursive: true });
239
+ fs.mkdirSync(path.join(pkgRoot, 'adapters'), { recursive: true });
240
+
241
+ repoRoot = path.join(tempDir, 'repo');
242
+ const githooksDir = path.join(repoRoot, 'githooks');
243
+ fs.mkdirSync(githooksDir, { recursive: true });
244
+ fs.mkdirSync(path.join(githooksDir, 'adapters'), { recursive: true });
245
+
246
+ fs.writeFileSync(path.join(githooksDir, 'gate-3.sh'), '#!/bin/bash\necho "gate3"');
247
+ fs.writeFileSync(path.join(githooksDir, 'gate-4.sh'), '#!/bin/bash\necho "gate4"');
248
+ fs.writeFileSync(path.join(githooksDir, 'sprint-gate.sh'), '#!/bin/bash\necho "sprint-gate"');
249
+ });
250
+
251
+ afterEach(() => {
252
+ cleanupTempDir(tempDir);
253
+ });
254
+
255
+ it('syncAdapters includes sprint-gate.sh in gate script sync (#335)', () => {
256
+ const githooksDir = path.join(repoRoot, 'githooks');
257
+ const gateFiles = fs.readdirSync(githooksDir).filter(f =>
258
+ (f.startsWith('gate-') || f === 'sprint-gate.sh') && f.endsWith('.sh')
259
+ );
260
+
261
+ expect(gateFiles.sort()).toEqual(['gate-3.sh', 'gate-4.sh', 'sprint-gate.sh'].sort(), 'should include all gate files');
262
+ });
263
+ });
264
+
265
+ /**
266
+ * @test REQ-329 adapter-mirror-drift
267
+ * @intent Verify checkAdapterDrift() detects file count and content mismatches
268
+ * between githooks/adapters/ (source of truth) and npm-package mirror.
269
+ * @covers AC-329-01, AC-329-02, AC-329-03
270
+ */
271
+ describe('checkAdapterDrift', () => {
272
+ let tempDir;
273
+ let srcDir;
274
+ let mirrorDir;
275
+
276
+ beforeEach(() => {
277
+ tempDir = createTempDir();
278
+ srcDir = path.join(tempDir, 'githooks', 'adapters');
279
+ mirrorDir = path.join(tempDir, 'npm-package', 'adapters');
280
+ fs.mkdirSync(srcDir, { recursive: true });
281
+ fs.mkdirSync(mirrorDir, { recursive: true });
282
+ });
283
+
284
+ afterEach(() => {
285
+ cleanupTempDir(tempDir);
286
+ });
287
+
288
+ it('returns true when adapter files are byte-identical between source and mirror', () => {
289
+ const content = '#!/bin/bash\necho "hello world"\n';
290
+ fs.writeFileSync(path.join(srcDir, 'adapter-typescript.sh'), content);
291
+ fs.writeFileSync(path.join(mirrorDir, 'adapter-typescript.sh'), content);
292
+
293
+ fs.writeFileSync(path.join(srcDir, 'adapter-python.sh'), '#!/bin/bash\necho "python"\n');
294
+ fs.writeFileSync(path.join(mirrorDir, 'adapter-python.sh'), '#!/bin/bash\necho "python"\n');
295
+
296
+ const result = checkAdapterDrift(srcDir, mirrorDir);
297
+ expect(result).toBe(true, 'should return true when files are byte-identical');
298
+ });
299
+
300
+ it('returns false when file in source is missing from mirror', () => {
301
+ fs.writeFileSync(path.join(srcDir, 'adapter-typescript.sh'), '#!/bin/bash');
302
+ // NOT writing to mirrorDir
303
+
304
+ const result = checkAdapterDrift(srcDir, mirrorDir);
305
+ expect(result).toBe(false, 'should return false when source file missing from mirror');
306
+ });
307
+
308
+ it('returns false when file in mirror is missing from source', () => {
309
+ fs.writeFileSync(path.join(mirrorDir, 'extra.sh'), '#!/bin/bash');
310
+ // NOT writing to srcDir
311
+
312
+ const result = checkAdapterDrift(srcDir, mirrorDir);
313
+ expect(result).toBe(false, 'should return false when mirror file missing from source');
314
+ });
315
+
316
+ it('returns false when file content differs (checksum mismatch)', () => {
317
+ fs.writeFileSync(path.join(srcDir, 'adapter-typescript.sh'), '#!/bin/bash\n# line 1\n');
318
+ fs.writeFileSync(path.join(mirrorDir, 'adapter-typescript.sh'), '#!/bin/bash\n# different line\n');
319
+
320
+ const result = checkAdapterDrift(srcDir, mirrorDir);
321
+ expect(result).toBe(false, 'should return false when content differs');
322
+ });
323
+
324
+ it('skips with true when source directory does not exist', () => {
325
+ const nonExistentSrc = path.join(tempDir, 'nonexistent');
326
+ fs.mkdirSync(mirrorDir, { recursive: true });
327
+ fs.writeFileSync(path.join(mirrorDir, 'adapter-typescript.sh'), '#!/bin/bash');
328
+
329
+ const result = checkAdapterDrift(nonExistentSrc, mirrorDir);
330
+ expect(result).toBe(true, 'should return true when source skipped');
331
+ });
332
+
333
+ it('skips with true when mirror directory does not exist', () => {
334
+ const nonExistentMirror = path.join(tempDir, 'nonexistent');
335
+ fs.writeFileSync(path.join(srcDir, 'adapter-typescript.sh'), '#!/bin/bash');
336
+
337
+ const result = checkAdapterDrift(srcDir, nonExistentMirror);
338
+ expect(result).toBe(true, 'should return true when mirror skipped');
339
+ });
340
+
341
+ it('handles subdirectories in adapter tree', () => {
342
+ const pluginsDir = path.join(srcDir, 'plugins');
343
+ fs.mkdirSync(pluginsDir, { recursive: true });
344
+ fs.writeFileSync(path.join(srcDir, 'adapter-common.sh'), 'common');
345
+ fs.writeFileSync(path.join(pluginsDir, 'p3c-plugin.sh'), 'p3c');
346
+
347
+ const mirrorPluginsDir = path.join(mirrorDir, 'plugins');
348
+ fs.mkdirSync(mirrorPluginsDir, { recursive: true });
349
+ fs.writeFileSync(path.join(mirrorDir, 'adapter-common.sh'), 'common');
350
+ fs.writeFileSync(path.join(mirrorPluginsDir, 'p3c-plugin.sh'), 'p3c');
351
+
352
+ const result = checkAdapterDrift(srcDir, mirrorDir);
353
+ expect(result).toBe(true, 'should return true with subdirectories');
354
+ });
355
+ });
356
+
357
+ /**
358
+ * @test REQ-007 sync-package-content-subprocess
359
+ * @intent Verify sync-package-content.js actually invokes subprocess and copies files correctly
360
+ * (not tautology - actually tests the implementation's file copying logic)
361
+ * @covers AC-007-01, AC-007-02, AC-007-03, AC-007-04, AC-007-05
362
+ */
363
+ const { spawnSync } = require('child_process');
364
+
365
+ describe('sync-package-content subprocess invocation', () => {
366
+ const WORKTREE_PATH = path.resolve(__dirname, '..', '..', '..', '..');
367
+ const PKG_ROOT = path.join(WORKTREE_PATH, 'src', 'npm-package');
368
+ const REPO_ROOT = path.join(WORKTREE_PATH);
369
+ const CORE_SKILLS = [
370
+ 'admin-template-guidelines',
371
+ 'delphi-review',
372
+ 'improve-codebase-architecture',
373
+ 'ralph-loop',
374
+ 'sprint-flow',
375
+ 'test-driven-development',
376
+ 'test-specification-alignment',
377
+ 'to-issues',
378
+ ];
379
+
380
+ function cleanupPackageDirectories() {
381
+ // Clean up if they exist
382
+ ['skills', 'plugins', 'adapters'].forEach(dir => {
383
+ const fullPath = path.join(PKG_ROOT, dir);
384
+ if (fs.existsSync(fullPath)) {
385
+ fs.rmSync(fullPath, { recursive: true, force: true });
386
+ }
387
+ });
388
+ }
389
+
390
+ function restorePackageDirectories() {
391
+ // Restore from backup if they existed before test
392
+ // Since we just tested them being empty, just ensure they exist after sync
393
+ }
394
+
395
+ it('sync-package-content.js copies skills from repo to package', () => {
396
+ const syncScript = path.join(WORKTREE_PATH, 'src', 'npm-package', 'scripts', 'sync-package-content.js');
397
+ const nodeExe = process.execPath;
398
+
399
+ // Ensure skills directory is empty first
400
+ cleanupPackageDirectories();
401
+
402
+ // Run sync as subprocess
403
+ const result = spawnSync(nodeExe, [syncScript], {
404
+ cwd: WORKTREE_PATH,
405
+ stdio: 'pipe',
406
+ });
407
+
408
+ expect(result.status).toBe(0, 'sync script should exit with status 0');
409
+
410
+ // Verify skills were copied from repo to package
411
+ for (const skillName of CORE_SKILLS) {
412
+ const skillSrc = path.join(REPO_ROOT, 'skills', skillName, 'SKILL.md');
413
+ const skillDest = path.join(PKG_ROOT, 'skills', skillName, 'SKILL.md');
414
+
415
+ // Source must exist (we're testing copy from existing repo)
416
+ if (!fs.existsSync(skillSrc)) {
417
+ console.warn(`Skill source not found, skipping: ${skillName}`);
418
+ continue;
419
+ }
420
+
421
+ expect(fs.existsSync(skillDest)).toBe(true, `skill ${skillName} should be copied`);
422
+ const content = fs.readFileSync(skillDest, 'utf8');
423
+ // SKILL.md files contain 'SKILL.md' or have content
424
+ expect(content.length > 0).toBe(true, `skill ${skillName} should have content`);
425
+ }
426
+ }, 10000);
427
+
428
+ it('sync-package-content.js copies plugins from repo to package', () => {
429
+ const syncScript = path.join(WORKTREE_PATH, 'src', 'npm-package', 'scripts', 'sync-package-content.js');
430
+ const nodeExe = process.execPath;
431
+
432
+ const result = spawnSync(nodeExe, [syncScript], {
433
+ cwd: WORKTREE_PATH,
434
+ stdio: 'pipe',
435
+ });
436
+
437
+ expect(result.status).toBe(0, 'sync script should exit with status 0');
438
+
439
+ // Verify plugin manifests were copied
440
+ const plugins = ['claude-code', 'opencode', 'qoder'];
441
+ for (const pluginName of plugins) {
442
+ const pluginManifest = path.join(PKG_ROOT, 'plugins', pluginName, 'plugin.json');
443
+ if (fs.existsSync(pluginManifest)) {
444
+ const content = fs.readFileSync(pluginManifest, 'utf8');
445
+ expect(content.includes('"name"')).toBe(true, `plugin ${pluginName} should have "name" field`);
446
+ }
447
+ }
448
+ }, 10000);
449
+
450
+ it('sync-package-content.js copies adapters from githooks to package', () => {
451
+ const syncScript = path.join(WORKTREE_PATH, 'src', 'npm-package', 'scripts', 'sync-package-content.js');
452
+ const nodeExe = process.execPath;
453
+
454
+ const result = spawnSync(nodeExe, [syncScript], {
455
+ cwd: WORKTREE_PATH,
456
+ stdio: 'pipe',
457
+ });
458
+
459
+ expect(result.status).toBe(0, 'sync script should exit with status 0');
460
+
461
+ // Verify adapters directory exists and has files
462
+ const adaptersDir = path.join(PKG_ROOT, 'adapters');
463
+ expect(fs.existsSync(adaptersDir)).toBe(true, 'adapters directory should exist');
464
+
465
+ // Check for common adapter patterns (adapter-*.sh or *.sh)
466
+ const adapterFiles = fs.readdirSync(adaptersDir).filter(f => f.endsWith('.sh'));
467
+ expect(adapterFiles.length > 0).toBe(true, 'should have at least one adapter file');
468
+ }, 10000);
469
+
470
+ it('sync-package-content.js runs without modifying package.json dependencies (zero-install)', () => {
471
+ const syncScript = path.join(WORKTREE_PATH, 'src', 'npm-package', 'scripts', 'sync-package-content.js');
472
+ const nodeExe = process.execPath;
473
+
474
+ const pkgJsonPath = path.join(PKG_ROOT, 'package.json');
475
+ const pkgJsonBefore = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
476
+
477
+ // Zero-install: dependencies should be empty (or undefined if key missing)
478
+ expect(Object.keys(pkgJsonBefore.dependencies || {}).length).toBe(0, 'dependencies should be empty');
479
+
480
+ const result = spawnSync(nodeExe, [syncScript], {
481
+ cwd: WORKTREE_PATH,
482
+ stdio: 'pipe',
483
+ });
484
+
485
+ expect(result.status).toBe(0, 'sync script should exit with status 0');
486
+
487
+ // Verify package.json dependencies remained empty
488
+ const pkgJsonAfter = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
489
+ expect(Object.keys(pkgJsonAfter.dependencies || {}).length).toBe(0, 'dependencies should remain empty');
490
+ }, 10000);
491
+ });