@boyingliu01/xp-gate 0.15.4 → 0.18.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.
- package/bin/xp-gate.js +29 -1
- package/hooks/lib/now-ms.sh +33 -0
- package/hooks/pre-commit +63 -47
- package/hooks/sprint-gate.sh +291 -0
- package/lib/__tests__/check-version.test.js +122 -0
- package/lib/__tests__/doctor.test.js +13 -15
- package/lib/__tests__/gate-audit.test.ts +38 -0
- package/lib/__tests__/phase-transition.test.js +241 -1
- package/lib/__tests__/retro.test.js +131 -0
- package/lib/__tests__/sprint-status-rework.test.js +184 -0
- package/lib/check-version.js +9 -0
- package/lib/doctor-tui.js +79 -9
- package/lib/doctor.js +15 -3
- package/lib/gate-audit.ts +18 -7
- package/lib/gate-runner.js +77 -30
- package/lib/init.js +10 -0
- package/lib/mattpocock-license-verdict.json +7 -0
- package/lib/phase-transition.js +251 -1
- package/lib/retro.js +222 -0
- package/lib/shared-utils.js +7 -0
- package/lib/sprint-init.js +207 -0
- package/lib/sprint-state-migrator.js +1 -0
- package/lib/sprint-status.js +111 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +3 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +59 -4
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +390 -0
- package/plugins/claude-code/skills/delphi-review/references/round-templates.md +67 -0
- package/plugins/claude-code/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/claude-code/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/claude-code/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/claude-code/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/claude-code/skills/grilling/SKILL.md +12 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +168 -43
- package/plugins/claude-code/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/claude-code/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/claude-code/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/plugins/claude-code/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/plugins/claude-code/skills/sprint-flow/references/phase-overview.md +58 -53
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +8 -3
- package/plugins/claude-code/skills/to-issues/SKILL.md +1 -1
- package/plugins/opencode/index.ts +1 -0
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +59 -4
- package/plugins/opencode/skills/delphi-review/references/requirements.md +390 -0
- package/plugins/opencode/skills/delphi-review/references/round-templates.md +67 -0
- package/plugins/opencode/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/opencode/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/opencode/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/opencode/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/opencode/skills/grilling/SKILL.md +12 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/SKILL.md +168 -43
- package/plugins/opencode/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/opencode/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/opencode/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/plugins/opencode/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/plugins/opencode/skills/sprint-flow/references/phase-overview.md +58 -53
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/SKILL.md +8 -3
- package/plugins/opencode/skills/to-issues/SKILL.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/__tests__/check-mattpocock-license.test.js +229 -0
- package/scripts/__tests__/sync-package-content.test.js +50 -0
- package/scripts/check-mattpocock-license.cjs +302 -0
- package/scripts/migrate-audit-anomalies.mjs +78 -0
- package/scripts/sync-package-content.js +52 -5
- package/skills/batch-grill-me/SKILL.md +15 -0
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +59 -4
- package/skills/delphi-review/references/requirements.md +390 -0
- package/skills/delphi-review/references/round-templates.md +67 -0
- package/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +74 -0
- package/skills/grill-with-docs/SKILL.md +7 -0
- package/skills/grilling/SKILL.md +12 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/SKILL.md +168 -43
- package/skills/sprint-flow/references/components/memory.md +1 -1
- package/skills/sprint-flow/references/components/middleware.md +1 -1
- package/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/skills/sprint-flow/references/phase-overview.md +58 -53
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/skills/test-specification-alignment/SKILL.md +8 -3
- package/skills/to-issues/SKILL.md +1 -1
|
@@ -31,6 +31,12 @@ describe('doctor', () => {
|
|
|
31
31
|
originalDetectDepsTools = [...detectDeps.GATE_CLI_TOOLS];
|
|
32
32
|
detectDeps.GATE_CLI_TOOLS.length = 0;
|
|
33
33
|
}
|
|
34
|
+
// Mock checkBash to return success by default (tests override as needed)
|
|
35
|
+
detectDeps.checkBash = () => ({
|
|
36
|
+
ok: true,
|
|
37
|
+
path: 'bash',
|
|
38
|
+
version: '5.1.16',
|
|
39
|
+
});
|
|
34
40
|
});
|
|
35
41
|
|
|
36
42
|
afterAll(() => {
|
|
@@ -217,9 +223,6 @@ describe('doctor', () => {
|
|
|
217
223
|
if (cmd === 'git --version') {
|
|
218
224
|
return 'git version 2.39.0\n';
|
|
219
225
|
}
|
|
220
|
-
if (cmd === 'bash --version') {
|
|
221
|
-
return 'GNU bash, version 5.1.16\n';
|
|
222
|
-
}
|
|
223
226
|
return '';
|
|
224
227
|
};
|
|
225
228
|
|
|
@@ -237,8 +240,6 @@ describe('doctor', () => {
|
|
|
237
240
|
callback(null, 'v20.0.0\n', '');
|
|
238
241
|
} else if (cmd === 'git --version') {
|
|
239
242
|
callback(null, 'git version 2.39.0\n', '');
|
|
240
|
-
} else if (cmd === 'bash --version') {
|
|
241
|
-
callback(null, 'GNU bash, version 5.1.16\n', '');
|
|
242
243
|
} else {
|
|
243
244
|
callback(null, '', '');
|
|
244
245
|
}
|
|
@@ -401,9 +402,6 @@ describe('doctor', () => {
|
|
|
401
402
|
if (cmd === 'git --version') {
|
|
402
403
|
return 'git version 2.39.0\n';
|
|
403
404
|
}
|
|
404
|
-
if (cmd === 'bash --version') {
|
|
405
|
-
return 'GNU bash, version 5.1.16\n';
|
|
406
|
-
}
|
|
407
405
|
return '';
|
|
408
406
|
};
|
|
409
407
|
// Async mock matching execSync above
|
|
@@ -417,8 +415,6 @@ describe('doctor', () => {
|
|
|
417
415
|
callback(null, 'v20.0.0\n', '');
|
|
418
416
|
} else if (cmd === 'git --version') {
|
|
419
417
|
callback(null, 'git version 2.39.0\n', '');
|
|
420
|
-
} else if (cmd === 'bash --version') {
|
|
421
|
-
callback(null, 'GNU bash, version 5.1.16\n', '');
|
|
422
418
|
} else {
|
|
423
419
|
callback(null, '', '');
|
|
424
420
|
}
|
|
@@ -502,9 +498,6 @@ describe('doctor', () => {
|
|
|
502
498
|
if (cmd === 'git --version') {
|
|
503
499
|
return 'git version 2.39.0\n';
|
|
504
500
|
}
|
|
505
|
-
if (cmd === 'bash --version') {
|
|
506
|
-
return 'GNU bash, version 5.1.16\n';
|
|
507
|
-
}
|
|
508
501
|
return '';
|
|
509
502
|
};
|
|
510
503
|
// Async exec mock for execWithTimeout (util.promisify(exec) in doctor.js)
|
|
@@ -518,8 +511,6 @@ describe('doctor', () => {
|
|
|
518
511
|
callback(null, 'v20.0.0\n', '');
|
|
519
512
|
} else if (cmd === 'git --version') {
|
|
520
513
|
callback(null, 'git version 2.39.0\n', '');
|
|
521
|
-
} else if (cmd === 'bash --version') {
|
|
522
|
-
callback(null, 'GNU bash, version 5.1.16\n', '');
|
|
523
514
|
} else {
|
|
524
515
|
callback(null, '', '');
|
|
525
516
|
}
|
|
@@ -569,6 +560,10 @@ describe('doctor', () => {
|
|
|
569
560
|
it('detects missing environment dependencies', async () => {
|
|
570
561
|
setupLocalInstall();
|
|
571
562
|
seedVersionCache();
|
|
563
|
+
// Make checkBash fail for this test (detect-deps module already loaded + mocked in beforeAll)
|
|
564
|
+
const detectDeps = require('../detect-deps.js');
|
|
565
|
+
const origCheckBash = detectDeps.checkBash;
|
|
566
|
+
detectDeps.checkBash = () => ({ ok: false, message: 'Not found' });
|
|
572
567
|
mockExecFail();
|
|
573
568
|
const { doctor } = require('../doctor');
|
|
574
569
|
|
|
@@ -578,6 +573,9 @@ describe('doctor', () => {
|
|
|
578
573
|
expect(logSpy).toHaveBeenCalledWith(
|
|
579
574
|
expect.stringContaining('Not found')
|
|
580
575
|
);
|
|
576
|
+
|
|
577
|
+
// Restore
|
|
578
|
+
detectDeps.checkBash = origCheckBash;
|
|
581
579
|
});
|
|
582
580
|
|
|
583
581
|
// === Issue #186: Version mismatch detection ===
|
|
@@ -201,6 +201,44 @@ describe('gate-audit', () => {
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
describe('duration_anomaly handling', () => {
|
|
205
|
+
it('should exclude duration_anomaly entries from avg_ms calculation', () => {
|
|
206
|
+
const repoRoot = join(TEST_DIR, 'anomaly-avg-test');
|
|
207
|
+
// 2 normal entries: 100ms + 200ms → avg 150ms
|
|
208
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', duration_ms: 100 }), repoRoot);
|
|
209
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', duration_ms: 200 }), repoRoot);
|
|
210
|
+
// 1 anomalous entry: should be excluded from avg
|
|
211
|
+
appendAuditEntry(
|
|
212
|
+
makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', duration_ms: 1784536915891, duration_anomaly: true }),
|
|
213
|
+
repoRoot,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const stats = computeStats(repoRoot);
|
|
217
|
+
const g1 = stats.find(s => s.gate_id === 'gate-1')!;
|
|
218
|
+
// avg should be (100+200)/2 = 150, NOT (100+200+1784536915891)/3
|
|
219
|
+
expect(g1.avg_ms).toBe(150);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should count anomaly entries separately in stats output', () => {
|
|
223
|
+
const repoRoot = join(TEST_DIR, 'anomaly-count-test');
|
|
224
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-5', duration_ms: 500 }), repoRoot);
|
|
225
|
+
appendAuditEntry(
|
|
226
|
+
makeEntry({ repo_path: repoRoot, gate_id: 'gate-5', duration_ms: 9999999999999, duration_anomaly: true }),
|
|
227
|
+
repoRoot,
|
|
228
|
+
);
|
|
229
|
+
appendAuditEntry(
|
|
230
|
+
makeEntry({ repo_path: repoRoot, gate_id: 'gate-5', duration_ms: 8888888888888, duration_anomaly: true }),
|
|
231
|
+
repoRoot,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const stats = computeStats(repoRoot);
|
|
235
|
+
const g5 = stats.find(s => s.gate_id === 'gate-5')!;
|
|
236
|
+
expect(g5.anomaly_count).toBe(2);
|
|
237
|
+
// avg_ms only from the 1 normal entry
|
|
238
|
+
expect(g5.avg_ms).toBe(500);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
204
242
|
describe('CLI helpers', () => {
|
|
205
243
|
it('should parse CLI options and skip dangling flags', () => {
|
|
206
244
|
expect(parseCliOptions(['--gate-id', 'gate-1', '--passed', 'true', '--dangling'])).toEqual({
|
|
@@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import os from 'node:os';
|
|
15
|
-
import { handlePhaseTransition, renderDashboard, PHASE_NAMES } from '../phase-transition.js';
|
|
15
|
+
import { handlePhaseTransition, renderDashboard, PHASE_NAMES, validateEvidence } from '../phase-transition.js';
|
|
16
16
|
|
|
17
17
|
describe('phase-transition', () => {
|
|
18
18
|
let tmpDir;
|
|
@@ -25,6 +25,29 @@ describe('phase-transition', () => {
|
|
|
25
25
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
/** Helper: write phase 2 evidence (requirements-reviewed.json) so phase 2 completed passes */
|
|
29
|
+
function writePhase2Evidence(dir) {
|
|
30
|
+
const outputsDir = path.join(dir, '.sprint-state', 'phase-outputs');
|
|
31
|
+
fs.mkdirSync(outputsDir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(path.join(outputsDir, 'requirements-reviewed.json'), JSON.stringify({
|
|
33
|
+
verdict: 'APPROVED',
|
|
34
|
+
requirements_hash: 'test-hash-placeholder',
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Helper: write phase 4 evidence (test-alignment-report.json) so phase 4 completed passes */
|
|
40
|
+
function writePhase4Evidence(dir) {
|
|
41
|
+
const outputsDir = path.join(dir, '.sprint-state', 'phase-outputs');
|
|
42
|
+
fs.mkdirSync(outputsDir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(outputsDir, 'test-alignment-report.json'), JSON.stringify({
|
|
44
|
+
alignment_status: 'PASS',
|
|
45
|
+
head_commit: 'unknown',
|
|
46
|
+
spec_hash: null,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
describe('handlePhaseTransition()', () => {
|
|
29
52
|
it('returns 0 and shows help with --help', async () => {
|
|
30
53
|
const code = await handlePhaseTransition(['--help']);
|
|
@@ -61,6 +84,7 @@ describe('phase-transition', () => {
|
|
|
61
84
|
});
|
|
62
85
|
|
|
63
86
|
it('records outputs when --outputs flag is provided', async () => {
|
|
87
|
+
writePhase2Evidence(tmpDir);
|
|
64
88
|
const outputs = JSON.stringify({ spec: 'path/to/spec.yaml' });
|
|
65
89
|
const code = await handlePhaseTransition(['2', 'completed', '--outputs', outputs, '--dir', tmpDir]);
|
|
66
90
|
expect(code).toBe(0);
|
|
@@ -78,6 +102,7 @@ describe('phase-transition', () => {
|
|
|
78
102
|
it('transitions through multiple phases sequentially', async () => {
|
|
79
103
|
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
80
104
|
await handlePhaseTransition(['2', 'in_progress', '--dir', tmpDir]);
|
|
105
|
+
writePhase2Evidence(tmpDir);
|
|
81
106
|
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
82
107
|
await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
83
108
|
|
|
@@ -187,6 +212,7 @@ describe('phase-transition', () => {
|
|
|
187
212
|
|
|
188
213
|
it('does NOT warn when previous phase is completed', async () => {
|
|
189
214
|
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
215
|
+
writePhase2Evidence(tmpDir);
|
|
190
216
|
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
191
217
|
|
|
192
218
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
@@ -247,8 +273,10 @@ describe('phase-transition', () => {
|
|
|
247
273
|
it('outputs sprint-audit reminder on Phase 6 completed', async () => {
|
|
248
274
|
// Set up state through Phase 5
|
|
249
275
|
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
276
|
+
writePhase2Evidence(tmpDir);
|
|
250
277
|
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
251
278
|
await handlePhaseTransition(['3', 'completed', '--dir', tmpDir]);
|
|
279
|
+
writePhase4Evidence(tmpDir);
|
|
252
280
|
await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
|
|
253
281
|
await handlePhaseTransition(['5', 'completed', '--dir', tmpDir]);
|
|
254
282
|
|
|
@@ -269,4 +297,216 @@ describe('phase-transition', () => {
|
|
|
269
297
|
expect(PHASE_NAMES[6]).toBe('CLOSE');
|
|
270
298
|
});
|
|
271
299
|
});
|
|
300
|
+
|
|
301
|
+
describe('evidence validation', () => {
|
|
302
|
+
/** Helper: create a sprint-state.json with given evidence_schema_version */
|
|
303
|
+
function createSprintState(dir, evidenceSchemaVersion) {
|
|
304
|
+
const stateDir = path.join(dir, '.sprint-state');
|
|
305
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
306
|
+
const state = {
|
|
307
|
+
_schema_version: 1,
|
|
308
|
+
id: 'sprint-test-evidence',
|
|
309
|
+
task_description: 'Evidence test',
|
|
310
|
+
phase: 4,
|
|
311
|
+
status: 'in_progress',
|
|
312
|
+
started_at: new Date().toISOString(),
|
|
313
|
+
phase_history: [
|
|
314
|
+
{ phase: 1, phase_name: 'PREP', status: 'completed' },
|
|
315
|
+
{ phase: 2, phase_name: 'DESIGN', status: 'completed' },
|
|
316
|
+
{ phase: 3, phase_name: 'BUILD', status: 'completed' },
|
|
317
|
+
{ phase: 4, phase_name: 'VERIFY', status: 'in_progress' },
|
|
318
|
+
],
|
|
319
|
+
isolation: { worktree_path: dir, branch: null },
|
|
320
|
+
outputs: {},
|
|
321
|
+
metrics: {},
|
|
322
|
+
};
|
|
323
|
+
if (evidenceSchemaVersion !== undefined) {
|
|
324
|
+
state.evidence_schema_version = evidenceSchemaVersion;
|
|
325
|
+
}
|
|
326
|
+
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state, null, 2));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Helper: write a test-alignment-report.json */
|
|
330
|
+
function writeAlignmentReport(dir, report) {
|
|
331
|
+
const outputsDir = path.join(dir, '.sprint-state', 'phase-outputs');
|
|
332
|
+
fs.mkdirSync(outputsDir, { recursive: true });
|
|
333
|
+
fs.writeFileSync(path.join(outputsDir, 'test-alignment-report.json'), JSON.stringify(report, null, 2));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
it('(a) BLOCKs when evidence file is missing for new sprint (evidence_schema_version >= 2)', () => {
|
|
337
|
+
createSprintState(tmpDir, 2);
|
|
338
|
+
const result = validateEvidence(4, tmpDir);
|
|
339
|
+
expect(result.ok).toBe(false);
|
|
340
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
341
|
+
expect(result.errors[0]).toContain('test-alignment-report.json');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('(b) BLOCKs when alignment_status is FAIL', () => {
|
|
345
|
+
createSprintState(tmpDir, 2);
|
|
346
|
+
writeAlignmentReport(tmpDir, {
|
|
347
|
+
alignment_status: 'FAIL',
|
|
348
|
+
head_commit: 'unknown',
|
|
349
|
+
spec_hash: null,
|
|
350
|
+
timestamp: new Date().toISOString(),
|
|
351
|
+
});
|
|
352
|
+
const result = validateEvidence(4, tmpDir);
|
|
353
|
+
expect(result.ok).toBe(false);
|
|
354
|
+
expect(result.errors.some(e => e.includes('alignment') || e.includes('PASS'))).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('(c) BLOCKs when head_commit is stale (does not match current HEAD)', () => {
|
|
358
|
+
createSprintState(tmpDir, 2);
|
|
359
|
+
writeAlignmentReport(tmpDir, {
|
|
360
|
+
alignment_status: 'PASS',
|
|
361
|
+
head_commit: 'deadbeef00000000000000000000000000000000',
|
|
362
|
+
spec_hash: null,
|
|
363
|
+
timestamp: new Date().toISOString(),
|
|
364
|
+
});
|
|
365
|
+
const result = validateEvidence(4, tmpDir);
|
|
366
|
+
// In tmpDir (no git repo), current HEAD is 'unknown'.
|
|
367
|
+
// A non-'unknown' head_commit that doesn't match 'unknown' should BLOCK.
|
|
368
|
+
expect(result.ok).toBe(false);
|
|
369
|
+
expect(result.errors.some(e => e.includes('head_commit'))).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('(d) BLOCKs when evidence file is malformed JSON (treated as missing)', () => {
|
|
373
|
+
createSprintState(tmpDir, 2);
|
|
374
|
+
const outputsDir = path.join(tmpDir, '.sprint-state', 'phase-outputs');
|
|
375
|
+
fs.mkdirSync(outputsDir, { recursive: true });
|
|
376
|
+
fs.writeFileSync(path.join(outputsDir, 'test-alignment-report.json'), '{invalid json!!!');
|
|
377
|
+
const result = validateEvidence(4, tmpDir);
|
|
378
|
+
expect(result.ok).toBe(false);
|
|
379
|
+
expect(result.errors.some(e => e.includes('malformed'))).toBe(true);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('(e) WARNs but does NOT block for legacy sprint (no evidence_schema_version)', () => {
|
|
383
|
+
createSprintState(tmpDir); // no evidence_schema_version
|
|
384
|
+
const result = validateEvidence(4, tmpDir);
|
|
385
|
+
expect(result.ok).toBe(true);
|
|
386
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
387
|
+
expect(result.warnings[0]).toContain('evidence_schema_version');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('(f) allows bypass with --skip-evidence and --reason via handlePhaseTransition', async () => {
|
|
391
|
+
createSprintState(tmpDir, 2);
|
|
392
|
+
// No evidence file — would normally BLOCK
|
|
393
|
+
const code = await handlePhaseTransition([
|
|
394
|
+
'4', 'completed', '--dir', tmpDir,
|
|
395
|
+
'--skip-evidence', 'Emergency hotfix',
|
|
396
|
+
]);
|
|
397
|
+
expect(code).toBe(0);
|
|
398
|
+
|
|
399
|
+
// Verify audit entry was written
|
|
400
|
+
const auditFile = path.join(tmpDir, '.xp-gate', 'audit.jsonl');
|
|
401
|
+
expect(fs.existsSync(auditFile)).toBe(true);
|
|
402
|
+
const lines = fs.readFileSync(auditFile, 'utf8').trim().split('\n');
|
|
403
|
+
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
|
404
|
+
expect(lastEntry.event).toBe('evidence_skipped');
|
|
405
|
+
expect(lastEntry.phase).toBe(4);
|
|
406
|
+
expect(lastEntry.reason).toBe('Emergency hotfix');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('(g) rejects --skip-evidence without --reason', async () => {
|
|
410
|
+
createSprintState(tmpDir, 2);
|
|
411
|
+
const code = await handlePhaseTransition([
|
|
412
|
+
'4', 'completed', '--dir', tmpDir,
|
|
413
|
+
'--skip-evidence',
|
|
414
|
+
]);
|
|
415
|
+
expect(code).toBe(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('returns ok for phases without evidence requirements (phase 1, 3, 5, 6)', () => {
|
|
419
|
+
createSprintState(tmpDir, 2);
|
|
420
|
+
for (const phase of [1, 3, 5, 6]) {
|
|
421
|
+
const result = validateEvidence(phase, tmpDir);
|
|
422
|
+
expect(result.ok).toBe(true);
|
|
423
|
+
expect(result.errors).toHaveLength(0);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('accepts valid evidence with head_commit matching current HEAD (unknown in non-git dir)', () => {
|
|
428
|
+
createSprintState(tmpDir, 2);
|
|
429
|
+
writeAlignmentReport(tmpDir, {
|
|
430
|
+
alignment_status: 'PASS',
|
|
431
|
+
head_commit: 'unknown', // matches non-git tmpDir
|
|
432
|
+
spec_hash: null,
|
|
433
|
+
timestamp: new Date().toISOString(),
|
|
434
|
+
});
|
|
435
|
+
const result = validateEvidence(4, tmpDir);
|
|
436
|
+
expect(result.ok).toBe(true);
|
|
437
|
+
expect(result.errors).toHaveLength(0);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('validates spec_hash when specification.yaml exists', () => {
|
|
441
|
+
createSprintState(tmpDir, 2);
|
|
442
|
+
// Create a specification.yaml
|
|
443
|
+
const specContent = 'requirements:\n - id: REQ-001\n';
|
|
444
|
+
fs.writeFileSync(path.join(tmpDir, 'specification.yaml'), specContent);
|
|
445
|
+
|
|
446
|
+
// Compute expected hash
|
|
447
|
+
const crypto = require('crypto');
|
|
448
|
+
const expectedHash = crypto.createHash('sha256').update(specContent, 'utf8').digest('hex');
|
|
449
|
+
|
|
450
|
+
writeAlignmentReport(tmpDir, {
|
|
451
|
+
alignment_status: 'PASS',
|
|
452
|
+
head_commit: 'unknown',
|
|
453
|
+
spec_hash: expectedHash,
|
|
454
|
+
timestamp: new Date().toISOString(),
|
|
455
|
+
});
|
|
456
|
+
const result = validateEvidence(4, tmpDir);
|
|
457
|
+
expect(result.ok).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('BLOCKs when spec_hash does not match specification.yaml', () => {
|
|
461
|
+
createSprintState(tmpDir, 2);
|
|
462
|
+
fs.writeFileSync(path.join(tmpDir, 'specification.yaml'), 'requirements:\n - id: REQ-001\n');
|
|
463
|
+
writeAlignmentReport(tmpDir, {
|
|
464
|
+
alignment_status: 'PASS',
|
|
465
|
+
head_commit: 'unknown',
|
|
466
|
+
spec_hash: 'wronghash000000000000000000000000000000000000000000000000000000',
|
|
467
|
+
timestamp: new Date().toISOString(),
|
|
468
|
+
});
|
|
469
|
+
const result = validateEvidence(4, tmpDir);
|
|
470
|
+
expect(result.ok).toBe(false);
|
|
471
|
+
expect(result.errors.some(e => e.includes('spec_hash'))).toBe(true);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('BLOCKs when required fields are missing from evidence', () => {
|
|
475
|
+
createSprintState(tmpDir, 2);
|
|
476
|
+
writeAlignmentReport(tmpDir, {
|
|
477
|
+
alignment_status: 'PASS',
|
|
478
|
+
// missing head_commit and spec_hash
|
|
479
|
+
});
|
|
480
|
+
const result = validateEvidence(4, tmpDir);
|
|
481
|
+
expect(result.ok).toBe(false);
|
|
482
|
+
expect(result.errors.some(e => e.includes('head_commit'))).toBe(true);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('does NOT validate evidence for non-completed statuses', async () => {
|
|
486
|
+
createSprintState(tmpDir, 2);
|
|
487
|
+
// in_progress should not trigger evidence validation
|
|
488
|
+
const code = await handlePhaseTransition(['4', 'in_progress', '--dir', tmpDir]);
|
|
489
|
+
expect(code).toBe(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('handlePhaseTransition BLOCKs phase 4 completed without evidence for new sprint', async () => {
|
|
493
|
+
createSprintState(tmpDir, 2);
|
|
494
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
495
|
+
const code = await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
|
|
496
|
+
expect(code).toBe(1);
|
|
497
|
+
errorSpy.mockRestore();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('handlePhaseTransition allows phase 4 completed with valid evidence', async () => {
|
|
501
|
+
createSprintState(tmpDir, 2);
|
|
502
|
+
writeAlignmentReport(tmpDir, {
|
|
503
|
+
alignment_status: 'PASS',
|
|
504
|
+
head_commit: 'unknown',
|
|
505
|
+
spec_hash: null,
|
|
506
|
+
timestamp: new Date().toISOString(),
|
|
507
|
+
});
|
|
508
|
+
const code = await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
|
|
509
|
+
expect(code).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
272
512
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD: written RED first (Phase G per design §12)
|
|
3
|
+
* @test REQ-RETRO-001 Sprint engineering retrospective CLI
|
|
4
|
+
* @intent Validate xp-gate retro generates Markdown report with rework trend + skip exposure sections
|
|
5
|
+
*/
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const { handleRetro } = require('../retro');
|
|
11
|
+
|
|
12
|
+
let TMP_DIR;
|
|
13
|
+
let cwdSpy;
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'retro-test-'));
|
|
17
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(TMP_DIR);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
cwdSpy.mockRestore();
|
|
22
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function writeAudit(entries) {
|
|
26
|
+
const dir = path.join(TMP_DIR, '.xp-gate');
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
path.join(dir, 'audit.jsonl'),
|
|
30
|
+
entries.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeHistory(entry) {
|
|
35
|
+
const dir = path.join(TMP_DIR, '.sprint-state', 'sprint-history');
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(dir, `${entry.id}.json`), JSON.stringify(entry, null, 2));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanDir() {
|
|
41
|
+
for (const sub of ['.xp-gate', '.sprint-state', '.quality-history.jsonl']) {
|
|
42
|
+
fs.rmSync(path.join(TMP_DIR, sub), { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('retro: report generation', () => {
|
|
47
|
+
// TDD: written RED first (G phase per design §12)
|
|
48
|
+
test('(a) empty data sources → skeleton report with "No data" sections', async () => {
|
|
49
|
+
cleanDir();
|
|
50
|
+
|
|
51
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
52
|
+
const code = await handleRetro(['--days', '7']);
|
|
53
|
+
const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
54
|
+
logSpy.mockRestore();
|
|
55
|
+
|
|
56
|
+
expect(code).toBe(0);
|
|
57
|
+
expect(output).toContain('# Sprint Engineering Retrospective');
|
|
58
|
+
expect(output).toContain('## Activity Summary');
|
|
59
|
+
expect(output).toContain('## Rework Rate Trend (#369)');
|
|
60
|
+
expect(output).toContain('## Evidence Skip Exposure');
|
|
61
|
+
expect(output).toContain('## Quality Trend');
|
|
62
|
+
expect(output).toContain('No data');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// TDD: written RED first (G phase per design §12)
|
|
66
|
+
test('(b) rework rate > 30% → alert line present with sprint id', async () => {
|
|
67
|
+
cleanDir();
|
|
68
|
+
writeHistory({
|
|
69
|
+
id: 'sprint-high-rework',
|
|
70
|
+
task_description: 'High rework sprint',
|
|
71
|
+
metrics: {
|
|
72
|
+
completed_at: new Date(Date.now() - 2 * 86400000).toISOString(),
|
|
73
|
+
total_sprint_commits: 10,
|
|
74
|
+
rework_rate: 0.42,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
79
|
+
const code = await handleRetro(['--days', '7']);
|
|
80
|
+
const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
81
|
+
logSpy.mockRestore();
|
|
82
|
+
|
|
83
|
+
expect(code).toBe(0);
|
|
84
|
+
expect(output).toContain('⚠️');
|
|
85
|
+
expect(output).toContain('sprint-high-rework');
|
|
86
|
+
expect(output).toContain('exceeds 30% threshold');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// TDD: written RED first (G phase per design §12)
|
|
90
|
+
test('(c) skip-evidence events > 2 per sprint → alert present', async () => {
|
|
91
|
+
cleanDir();
|
|
92
|
+
const ts = new Date().toISOString();
|
|
93
|
+
writeAudit([
|
|
94
|
+
{ event: 'evidence_skipped', phase: '3', reason: 'r1', timestamp: ts, sprint_id: 'sprint-skip' },
|
|
95
|
+
{ event: 'evidence_skipped', phase: '4', reason: 'r2', timestamp: ts, sprint_id: 'sprint-skip' },
|
|
96
|
+
{ event: 'evidence_skipped', phase: '5', reason: 'r3', timestamp: ts, sprint_id: 'sprint-skip' },
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
100
|
+
const code = await handleRetro(['--days', '7']);
|
|
101
|
+
const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
102
|
+
logSpy.mockRestore();
|
|
103
|
+
|
|
104
|
+
expect(code).toBe(0);
|
|
105
|
+
expect(output).toContain('⚠️');
|
|
106
|
+
expect(output).toContain('sprint-skip');
|
|
107
|
+
expect(output).toContain('used skip 3 times');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// TDD: written RED first (G phase per design §12)
|
|
111
|
+
test('(d) duration_anomaly entries excluded from avg_ms', async () => {
|
|
112
|
+
cleanDir();
|
|
113
|
+
const ts = new Date().toISOString();
|
|
114
|
+
writeAudit([
|
|
115
|
+
{ gate_id: 'gate-1', gate_name: 'g1', passed: true, issues_found: 0, duration_ms: 100, timestamp: ts, trigger: 'manual', repo_path: TMP_DIR, commit_hash: 'abc' },
|
|
116
|
+
{ gate_id: 'gate-1', gate_name: 'g1', passed: true, issues_found: 0, duration_ms: 200, timestamp: ts, trigger: 'manual', repo_path: TMP_DIR, commit_hash: 'abc' },
|
|
117
|
+
{ gate_id: 'gate-1', gate_name: 'g1', passed: true, issues_found: 0, duration_ms: 99999, duration_anomaly: true, timestamp: ts, trigger: 'manual', repo_path: TMP_DIR, commit_hash: 'abc' },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
121
|
+
const code = await handleRetro(['--days', '7']);
|
|
122
|
+
const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
123
|
+
logSpy.mockRestore();
|
|
124
|
+
|
|
125
|
+
expect(code).toBe(0);
|
|
126
|
+
// avg_ms = (100+200)/2 = 150 (anomaly excluded)
|
|
127
|
+
expect(output).toContain('150ms');
|
|
128
|
+
// If anomaly were included: (100+200+99999)/3 ≈ 33433ms
|
|
129
|
+
expect(output).not.toContain('33433ms');
|
|
130
|
+
});
|
|
131
|
+
});
|