@boyingliu01/xp-gate 0.27.6 → 0.28.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/adapter-common.sh +0 -0
- package/adapters/cpp.sh +0 -0
- package/adapters/dart.sh +0 -0
- package/adapters/flutter.sh +0 -0
- package/adapters/gate-9.sh +13 -2
- package/adapters/go.sh +0 -0
- package/adapters/iac.sh +0 -0
- package/adapters/java.sh +0 -0
- package/adapters/kotlin.sh +0 -0
- package/adapters/objectivec.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
- package/adapters/powershell.sh +0 -0
- package/adapters/python.sh +0 -0
- package/adapters/shell.sh +0 -0
- package/adapters/swift.sh +0 -0
- package/adapters/typescript.sh +0 -0
- package/bin/xp-gate.js +0 -0
- package/build-integrity/gate-10.ts +290 -263
- package/gate-10.sh +25 -6
- package/gate-12-file-hygiene.sh +35 -1
- package/gate-3.sh +78 -2
- package/gate-4.sh +0 -0
- package/gate-7.sh +0 -0
- package/gate-8.sh +0 -0
- package/gate-9.sh +13 -2
- package/hooks/adapter-common.sh +0 -0
- package/hooks/gate-10.sh +25 -6
- package/hooks/gate-12-file-hygiene.sh +35 -1
- package/hooks/gate-3.sh +78 -2
- package/hooks/gate-4.sh +0 -0
- package/hooks/gate-7.sh +0 -0
- package/hooks/gate-8.sh +0 -0
- package/hooks/gate-9.sh +13 -2
- package/hooks/lib/audit-sanitize.sh +51 -0
- package/hooks/lib/ccn-ratchet.sh +183 -0
- package/hooks/lib/docs-only.sh +49 -0
- package/hooks/post-merge +0 -0
- package/hooks/pre-commit +74 -15
- package/hooks/pre-push +36 -5
- package/hooks/sprint-gate.sh +0 -0
- package/lib/__tests__/sprint-audit.test.js +207 -207
- package/lib/gate-audit.ts +9 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +27 -6
- package/lib/sprint-audit.js +235 -235
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
- package/plugins/claude-code/bin/xp-gate-check +0 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
- package/plugins/qoder/bin/xp-gate-check +0 -0
- 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/principles/__tests__/many-exports-reexport.test.ts +40 -0
- package/principles/adapters/typescript.ts +61 -30
- package/principles/boy-scout.ts +22 -17
- package/scripts/delphi-external-review.cjs +195 -132
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/sprint-gate.sh +0 -0
- package/plugins/dsh/lib/command.d.ts +0 -32
- package/plugins/dsh/lib/command.d.ts.map +0 -1
- package/plugins/dsh/lib/command.js +0 -81
- package/plugins/dsh/lib/delphi-config.d.ts +0 -75
- package/plugins/dsh/lib/delphi-config.d.ts.map +0 -1
- package/plugins/dsh/lib/delphi-config.js +0 -183
- package/plugins/dsh/lib/delphi-run.d.ts +0 -24
- package/plugins/dsh/lib/delphi-run.d.ts.map +0 -1
- package/plugins/dsh/lib/delphi-run.js +0 -92
- package/plugins/dsh/lib/delphi.d.ts +0 -88
- package/plugins/dsh/lib/delphi.d.ts.map +0 -1
- package/plugins/dsh/lib/delphi.js +0 -161
- package/plugins/dsh/lib/dialect.d.ts +0 -24
- package/plugins/dsh/lib/dialect.d.ts.map +0 -1
- package/plugins/dsh/lib/dialect.js +0 -36
- package/plugins/dsh/lib/gate-runner.d.ts +0 -18
- package/plugins/dsh/lib/gate-runner.d.ts.map +0 -1
- package/plugins/dsh/lib/gate-runner.js +0 -46
- package/plugins/dsh/lib/index.d.ts +0 -5
- package/plugins/dsh/lib/index.d.ts.map +0 -1
- package/plugins/dsh/lib/index.js +0 -162
- package/plugins/dsh/node_modules/.vite/vitest/results.json +0 -1
|
@@ -1,207 +1,207 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for sprint-audit CLI command (Layer 2)
|
|
3
|
-
* @test REQ-338 Sprint audit — final completeness check
|
|
4
|
-
* @intent Verify sprint-audit correctly checks phase coverage, artifacts, consistency
|
|
5
|
-
* @covers AC-AUDIT-01 (SKIP when no sprint-state.json)
|
|
6
|
-
* @covers AC-AUDIT-02 (PASS when 6/6 completed)
|
|
7
|
-
* @covers AC-AUDIT-03 (PASS_WITH_WARNINGS when 4-5/6 completed)
|
|
8
|
-
* @covers AC-AUDIT-04 (FAIL when <4/6 completed)
|
|
9
|
-
* @covers AC-AUDIT-05 (FAIL on state inconsistency)
|
|
10
|
-
* @covers AC-AUDIT-06 (--json flag outputs JSON)
|
|
11
|
-
* @covers AC-AUDIT-07 (report persisted to audit-report.json)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
-
import fs from 'node:fs';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
import os from 'node:os';
|
|
18
|
-
import { handleSprintAudit } from '../sprint-audit.js';
|
|
19
|
-
|
|
20
|
-
describe('sprint-audit', () => {
|
|
21
|
-
let tmpDir;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-audit-test-'));
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
function writeState(state) {
|
|
32
|
-
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
33
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
34
|
-
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function makeState(overrides = {}) {
|
|
38
|
-
return {
|
|
39
|
-
_schema_version: 1,
|
|
40
|
-
id: 'sprint-test-audit',
|
|
41
|
-
task_description: 'Test audit feature',
|
|
42
|
-
phase: 6,
|
|
43
|
-
status: 'completed',
|
|
44
|
-
started_at: '2026-07-22T10:00:00Z',
|
|
45
|
-
phase_history: [],
|
|
46
|
-
isolation: { branch: 'sprint/test-audit', worktree_path: null },
|
|
47
|
-
outputs: {},
|
|
48
|
-
...overrides,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function makePhaseHistory(completedPhases) {
|
|
53
|
-
const phaseNames = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
54
|
-
return completedPhases.map((p, i) => ({
|
|
55
|
-
phase: p,
|
|
56
|
-
phase_name: phaseNames[p - 1],
|
|
57
|
-
status: 'completed',
|
|
58
|
-
started_at: `2026-07-22T10:${String(i).padStart(2, '0')}:00Z`,
|
|
59
|
-
completed_at: `2026-07-22T10:${String(i).padStart(2, '0')}:30Z`,
|
|
60
|
-
duration_seconds: 30,
|
|
61
|
-
}));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
describe('handleSprintAudit()', () => {
|
|
65
|
-
it('returns 0 and shows help with --help', async () => {
|
|
66
|
-
const code = await handleSprintAudit(['--help']);
|
|
67
|
-
expect(code).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('returns SKIP (exit 0) when no sprint-state.json exists', async () => {
|
|
71
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
72
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
73
|
-
expect(code).toBe(0);
|
|
74
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No sprint-state.json'));
|
|
75
|
-
logSpy.mockRestore();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('returns PASS when 6/6 phases completed', async () => {
|
|
79
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
80
|
-
|
|
81
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
82
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
83
|
-
expect(code).toBe(0);
|
|
84
|
-
// Check output contains PASS
|
|
85
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
86
|
-
expect(output).toContain('PASS');
|
|
87
|
-
expect(output).toContain('6/6');
|
|
88
|
-
logSpy.mockRestore();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('returns PASS_WITH_WARNINGS when 5/6 completed', async () => {
|
|
92
|
-
writeState(makeState({ phase: 5, phase_history: makePhaseHistory([1, 2, 3, 4, 5]) }));
|
|
93
|
-
|
|
94
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
95
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
96
|
-
expect(code).toBe(0);
|
|
97
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
98
|
-
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
99
|
-
logSpy.mockRestore();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('returns PASS_WITH_WARNINGS when 4/6 completed', async () => {
|
|
103
|
-
writeState(makeState({ phase: 4, phase_history: makePhaseHistory([1, 2, 3, 4]) }));
|
|
104
|
-
|
|
105
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
106
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
107
|
-
expect(code).toBe(0);
|
|
108
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
109
|
-
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
110
|
-
logSpy.mockRestore();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('returns FAIL when <4/6 completed', async () => {
|
|
114
|
-
writeState(makeState({ phase: 3, phase_history: makePhaseHistory([1, 2, 3]) }));
|
|
115
|
-
|
|
116
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
117
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
118
|
-
expect(code).toBe(1);
|
|
119
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
120
|
-
expect(output).toContain('FAIL');
|
|
121
|
-
logSpy.mockRestore();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('returns FAIL on state inconsistency (phase vs phase_history)', async () => {
|
|
125
|
-
const history = makePhaseHistory([1, 2, 3, 4, 5, 6]);
|
|
126
|
-
writeState(makeState({ phase: 3, phase_history: history })); // phase=3 but last entry is 6
|
|
127
|
-
|
|
128
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
129
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
130
|
-
expect(code).toBe(1);
|
|
131
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
132
|
-
expect(output).toContain('State inconsistency');
|
|
133
|
-
logSpy.mockRestore();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('outputs JSON when --json flag is set', async () => {
|
|
137
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
138
|
-
|
|
139
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
140
|
-
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
141
|
-
expect(code).toBe(0);
|
|
142
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
143
|
-
const result = JSON.parse(output);
|
|
144
|
-
expect(result.verdict).toBe('PASS');
|
|
145
|
-
expect(result.coverage.completed).toBe(6);
|
|
146
|
-
logSpy.mockRestore();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('counts skipped phases as completed in coverage', async () => {
|
|
150
|
-
const history = makePhaseHistory([1, 2, 3, 4, 5]);
|
|
151
|
-
history.push({
|
|
152
|
-
phase: 6, phase_name: 'CLOSE', status: 'skipped',
|
|
153
|
-
started_at: '2026-07-22T10:05:00Z', completed_at: '2026-07-22T10:05:00Z', duration_seconds: 0,
|
|
154
|
-
});
|
|
155
|
-
writeState(makeState({ phase: 6, phase_history: history }));
|
|
156
|
-
|
|
157
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
158
|
-
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
159
|
-
expect(code).toBe(0);
|
|
160
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
161
|
-
const result = JSON.parse(output);
|
|
162
|
-
expect(result.coverage.completed).toBe(6);
|
|
163
|
-
expect(result.verdict).toBe('PASS');
|
|
164
|
-
logSpy.mockRestore();
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('warns about missing summary files for completed phases', async () => {
|
|
168
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
169
|
-
// No phase-outputs directory → all summaries missing
|
|
170
|
-
|
|
171
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
172
|
-
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
173
|
-
expect(code).toBe(0);
|
|
174
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
175
|
-
const result = JSON.parse(output);
|
|
176
|
-
expect(result.warnings.length).toBeGreaterThan(0);
|
|
177
|
-
expect(result.warnings.some(w => w.includes('summary missing'))).toBe(true);
|
|
178
|
-
logSpy.mockRestore();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('persists audit report to audit-report.json', async () => {
|
|
182
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
183
|
-
|
|
184
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
185
|
-
await handleSprintAudit(['--dir', tmpDir]);
|
|
186
|
-
logSpy.mockRestore();
|
|
187
|
-
|
|
188
|
-
const reportPath = path.join(tmpDir, '.sprint-state', 'audit-report.json');
|
|
189
|
-
expect(fs.existsSync(reportPath)).toBe(true);
|
|
190
|
-
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
191
|
-
expect(report.verdict).toBe('PASS');
|
|
192
|
-
expect(report.sprint_id).toBe('sprint-test-audit');
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('handles corrupted sprint-state.json gracefully', async () => {
|
|
196
|
-
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
197
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
198
|
-
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), 'not-valid-json');
|
|
199
|
-
|
|
200
|
-
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
201
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
202
|
-
expect(code).toBe(1);
|
|
203
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse'));
|
|
204
|
-
errSpy.mockRestore();
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for sprint-audit CLI command (Layer 2)
|
|
3
|
+
* @test REQ-338 Sprint audit — final completeness check
|
|
4
|
+
* @intent Verify sprint-audit correctly checks phase coverage, artifacts, consistency
|
|
5
|
+
* @covers AC-AUDIT-01 (SKIP when no sprint-state.json)
|
|
6
|
+
* @covers AC-AUDIT-02 (PASS when 6/6 completed)
|
|
7
|
+
* @covers AC-AUDIT-03 (PASS_WITH_WARNINGS when 4-5/6 completed)
|
|
8
|
+
* @covers AC-AUDIT-04 (FAIL when <4/6 completed)
|
|
9
|
+
* @covers AC-AUDIT-05 (FAIL on state inconsistency)
|
|
10
|
+
* @covers AC-AUDIT-06 (--json flag outputs JSON)
|
|
11
|
+
* @covers AC-AUDIT-07 (report persisted to audit-report.json)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { handleSprintAudit } from '../sprint-audit.js';
|
|
19
|
+
|
|
20
|
+
describe('sprint-audit', () => {
|
|
21
|
+
let tmpDir;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-audit-test-'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function writeState(state) {
|
|
32
|
+
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
33
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeState(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
_schema_version: 1,
|
|
40
|
+
id: 'sprint-test-audit',
|
|
41
|
+
task_description: 'Test audit feature',
|
|
42
|
+
phase: 6,
|
|
43
|
+
status: 'completed',
|
|
44
|
+
started_at: '2026-07-22T10:00:00Z',
|
|
45
|
+
phase_history: [],
|
|
46
|
+
isolation: { branch: 'sprint/test-audit', worktree_path: null },
|
|
47
|
+
outputs: {},
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makePhaseHistory(completedPhases) {
|
|
53
|
+
const phaseNames = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
54
|
+
return completedPhases.map((p, i) => ({
|
|
55
|
+
phase: p,
|
|
56
|
+
phase_name: phaseNames[p - 1],
|
|
57
|
+
status: 'completed',
|
|
58
|
+
started_at: `2026-07-22T10:${String(i).padStart(2, '0')}:00Z`,
|
|
59
|
+
completed_at: `2026-07-22T10:${String(i).padStart(2, '0')}:30Z`,
|
|
60
|
+
duration_seconds: 30,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('handleSprintAudit()', () => {
|
|
65
|
+
it('returns 0 and shows help with --help', async () => {
|
|
66
|
+
const code = await handleSprintAudit(['--help']);
|
|
67
|
+
expect(code).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns SKIP (exit 0) when no sprint-state.json exists', async () => {
|
|
71
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
72
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
73
|
+
expect(code).toBe(0);
|
|
74
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No sprint-state.json'));
|
|
75
|
+
logSpy.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns PASS when 6/6 phases completed', async () => {
|
|
79
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
80
|
+
|
|
81
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
82
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
83
|
+
expect(code).toBe(0);
|
|
84
|
+
// Check output contains PASS
|
|
85
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
86
|
+
expect(output).toContain('PASS');
|
|
87
|
+
expect(output).toContain('6/6');
|
|
88
|
+
logSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns PASS_WITH_WARNINGS when 5/6 completed', async () => {
|
|
92
|
+
writeState(makeState({ phase: 5, phase_history: makePhaseHistory([1, 2, 3, 4, 5]) }));
|
|
93
|
+
|
|
94
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
95
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
96
|
+
expect(code).toBe(0);
|
|
97
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
98
|
+
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
99
|
+
logSpy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns PASS_WITH_WARNINGS when 4/6 completed', async () => {
|
|
103
|
+
writeState(makeState({ phase: 4, phase_history: makePhaseHistory([1, 2, 3, 4]) }));
|
|
104
|
+
|
|
105
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
106
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
109
|
+
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
110
|
+
logSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns FAIL when <4/6 completed', async () => {
|
|
114
|
+
writeState(makeState({ phase: 3, phase_history: makePhaseHistory([1, 2, 3]) }));
|
|
115
|
+
|
|
116
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
117
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
118
|
+
expect(code).toBe(1);
|
|
119
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
120
|
+
expect(output).toContain('FAIL');
|
|
121
|
+
logSpy.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns FAIL on state inconsistency (phase vs phase_history)', async () => {
|
|
125
|
+
const history = makePhaseHistory([1, 2, 3, 4, 5, 6]);
|
|
126
|
+
writeState(makeState({ phase: 3, phase_history: history })); // phase=3 but last entry is 6
|
|
127
|
+
|
|
128
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
129
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
130
|
+
expect(code).toBe(1);
|
|
131
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
132
|
+
expect(output).toContain('State inconsistency');
|
|
133
|
+
logSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('outputs JSON when --json flag is set', async () => {
|
|
137
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
138
|
+
|
|
139
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
140
|
+
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
141
|
+
expect(code).toBe(0);
|
|
142
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
143
|
+
const result = JSON.parse(output);
|
|
144
|
+
expect(result.verdict).toBe('PASS');
|
|
145
|
+
expect(result.coverage.completed).toBe(6);
|
|
146
|
+
logSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('counts skipped phases as completed in coverage', async () => {
|
|
150
|
+
const history = makePhaseHistory([1, 2, 3, 4, 5]);
|
|
151
|
+
history.push({
|
|
152
|
+
phase: 6, phase_name: 'CLOSE', status: 'skipped',
|
|
153
|
+
started_at: '2026-07-22T10:05:00Z', completed_at: '2026-07-22T10:05:00Z', duration_seconds: 0,
|
|
154
|
+
});
|
|
155
|
+
writeState(makeState({ phase: 6, phase_history: history }));
|
|
156
|
+
|
|
157
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
158
|
+
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
159
|
+
expect(code).toBe(0);
|
|
160
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
161
|
+
const result = JSON.parse(output);
|
|
162
|
+
expect(result.coverage.completed).toBe(6);
|
|
163
|
+
expect(result.verdict).toBe('PASS');
|
|
164
|
+
logSpy.mockRestore();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('warns about missing summary files for completed phases', async () => {
|
|
168
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
169
|
+
// No phase-outputs directory → all summaries missing
|
|
170
|
+
|
|
171
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
172
|
+
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
173
|
+
expect(code).toBe(0);
|
|
174
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
175
|
+
const result = JSON.parse(output);
|
|
176
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
177
|
+
expect(result.warnings.some(w => w.includes('summary missing'))).toBe(true);
|
|
178
|
+
logSpy.mockRestore();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('persists audit report to audit-report.json', async () => {
|
|
182
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
183
|
+
|
|
184
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
185
|
+
await handleSprintAudit(['--dir', tmpDir]);
|
|
186
|
+
logSpy.mockRestore();
|
|
187
|
+
|
|
188
|
+
const reportPath = path.join(tmpDir, '.sprint-state', 'audit-report.json');
|
|
189
|
+
expect(fs.existsSync(reportPath)).toBe(true);
|
|
190
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
191
|
+
expect(report.verdict).toBe('PASS');
|
|
192
|
+
expect(report.sprint_id).toBe('sprint-test-audit');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('handles corrupted sprint-state.json gracefully', async () => {
|
|
196
|
+
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
197
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
198
|
+
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), 'not-valid-json');
|
|
199
|
+
|
|
200
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
201
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
202
|
+
expect(code).toBe(1);
|
|
203
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse'));
|
|
204
|
+
errSpy.mockRestore();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
package/lib/gate-audit.ts
CHANGED
|
@@ -32,11 +32,12 @@ export interface GateAuditEntry {
|
|
|
32
32
|
gate_name: string;
|
|
33
33
|
passed: boolean;
|
|
34
34
|
issues_found: number;
|
|
35
|
-
duration_ms: number;
|
|
35
|
+
duration_ms: number | null; // REQ-B: null = deliberately unmeasurable (see duration_reason)
|
|
36
36
|
trigger: 'commit' | 'push' | 'manual';
|
|
37
37
|
repo_path: string;
|
|
38
38
|
commit_hash: string;
|
|
39
39
|
duration_anomaly?: boolean; // Issue #370: set when duration_ms > max_duration_ms
|
|
40
|
+
duration_reason?: 'poisoned_start_ms' | 'clock_skew'; // REQ-B: why duration_ms is null
|
|
40
41
|
detail?: string; // #507: optional gate-specific payload (e.g. effective CCN threshold)
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -154,10 +155,12 @@ function aggregateByGate(entries: GateAuditEntry[]): Map<string, { total: number
|
|
|
154
155
|
const b = buckets.get(e.gate_id) ?? { total: 0, passed: 0, ms: 0, issues: 0, anomaly_count: 0 };
|
|
155
156
|
b.total += 1;
|
|
156
157
|
if (e.passed) b.passed += 1;
|
|
157
|
-
// Issue #370: exclude anomalous durations from ms aggregation
|
|
158
|
+
// Issue #370: exclude anomalous durations from ms aggregation. REQ-B: a
|
|
159
|
+
// null duration is the honest sibling of that flag — deliberately not
|
|
160
|
+
// measurable, so it stays out of the sums (and out of the denominator).
|
|
158
161
|
if (e.duration_anomaly === true) {
|
|
159
162
|
b.anomaly_count += 1;
|
|
160
|
-
} else {
|
|
163
|
+
} else if (typeof e.duration_ms === 'number') {
|
|
161
164
|
b.ms += e.duration_ms;
|
|
162
165
|
}
|
|
163
166
|
b.issues += e.issues_found;
|
|
@@ -253,12 +256,14 @@ function runCli(): void {
|
|
|
253
256
|
gate_name: opts['gate-name'] || 'unknown',
|
|
254
257
|
passed: opts['passed'] === 'true',
|
|
255
258
|
issues_found: parseInt(opts['issues-found'] || '0', 10),
|
|
256
|
-
duration_ms: parseInt(opts['duration-ms'] || '0', 10),
|
|
259
|
+
duration_ms: opts['duration-ms'] === 'null' ? null : parseInt(opts['duration-ms'] || '0', 10),
|
|
257
260
|
trigger: (opts['trigger'] as 'commit' | 'push' | 'manual') || 'manual',
|
|
258
261
|
repo_path: process.cwd(),
|
|
259
262
|
commit_hash: getCommitHash(),
|
|
260
263
|
// Issue #370: only set when explicitly flagged (omit field otherwise)
|
|
261
264
|
...(opts['duration-anomaly'] === 'true' ? { duration_anomaly: true } : {}),
|
|
265
|
+
// REQ-B: why the duration is null — never guess a number for it.
|
|
266
|
+
...(opts['duration-reason'] ? { duration_reason: opts['duration-reason'] as 'poisoned_start_ms' | 'clock_skew' } : {}),
|
|
262
267
|
// #507: only set when provided (omit field otherwise)
|
|
263
268
|
...(opts['detail'] ? { detail: opts['detail'] } : {}),
|
|
264
269
|
};
|
package/lib/retro.js
CHANGED
|
@@ -15,6 +15,14 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Upper bound for a believable gate runtime. A gate that reports more than two
|
|
20
|
+
* hours did not take two hours; it took a corrupted clock. Used to exclude
|
|
21
|
+
* samples from the duration average rather than let one poisoned row dominate
|
|
22
|
+
* it (the symptom was an "average" of 2.2e12 ms).
|
|
23
|
+
*/
|
|
24
|
+
const MAX_SANE_DURATION_MS = 2 * 60 * 60 * 1000;
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Get git commit subjects within a date window.
|
|
20
28
|
* @param {string} dir - Repository root
|
|
@@ -105,16 +113,29 @@ function buildReport(opts) {
|
|
|
105
113
|
L.push('## Activity Summary');
|
|
106
114
|
const gateEntries = audit.filter(e => e.gate_id);
|
|
107
115
|
const passed = gateEntries.filter(e => e.passed).length;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
// REQ-B: a duration is only usable when it is a finite non-negative number.
|
|
117
|
+
// `e.duration_ms || 0` used to fold null into ZERO, which both dragged the
|
|
118
|
+
// mean down and hid the fact that the sample was missing — the same class of
|
|
119
|
+
// lie as the epoch-sized values this sprint removed. The legacy
|
|
120
|
+
// duration_anomaly flag is no longer the only signal: a poisoned start now
|
|
121
|
+
// writes null outright, so anything null must be excluded and counted.
|
|
122
|
+
const measurable = gateEntries.filter(e => Number.isFinite(e.duration_ms) && e.duration_ms >= 0 && e.duration_ms <= MAX_SANE_DURATION_MS);
|
|
123
|
+
const excluded = gateEntries.length - measurable.length;
|
|
124
|
+
const nullDurations = gateEntries.filter(e => e.duration_ms === null).length;
|
|
125
|
+
const avgMs = measurable.length > 0
|
|
126
|
+
? Math.round(measurable.reduce((s, e) => s + e.duration_ms, 0) / measurable.length)
|
|
112
127
|
: 0;
|
|
113
128
|
L.push(`- Total commits in window: ${commits.length}`);
|
|
114
129
|
L.push(`- Sprint transitions recorded: ${sprints.length}`);
|
|
115
130
|
L.push(`- Gate pass rate: ${gateEntries.length > 0 ? ((passed / gateEntries.length) * 100).toFixed(1) : '0.0'}% (${gateEntries.length} gates total, ${gateEntries.length - passed} failed)`);
|
|
116
|
-
if (
|
|
117
|
-
|
|
131
|
+
if (measurable.length > 0) {
|
|
132
|
+
// p50 alongside the mean: with a long tail the mean is the least useful
|
|
133
|
+
// statistic, and "avg gate duration: 2.2e12ms" was the visible symptom.
|
|
134
|
+
const sorted = measurable.map(e => e.duration_ms).sort((a, b) => a - b);
|
|
135
|
+
const p50 = sorted[Math.floor(sorted.length / 2)];
|
|
136
|
+
L.push(`- Avg gate duration: ${avgMs}ms, p50 ${p50}ms over ${measurable.length} measurable gate(s) (${excluded} excluded${nullDurations ? `, ${nullDurations} recorded null` : ''})`);
|
|
137
|
+
} else if (gateEntries.length > 0) {
|
|
138
|
+
L.push(`- Avg gate duration: no measurable samples (${excluded} of ${gateEntries.length} excluded as null or anomalous)`);
|
|
118
139
|
}
|
|
119
140
|
L.push('');
|
|
120
141
|
|