@boyingliu01/xp-gate 0.15.1 → 0.15.2
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/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 +24 -0
- package/gate-3.sh +0 -0
- package/gate-4.sh +0 -0
- package/gate-7.sh +0 -0
- package/gate-8.sh +0 -0
- package/gate-9.sh +0 -0
- package/hooks/adapter-common.sh +0 -0
- package/hooks/gate-3.sh +0 -0
- 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 +0 -0
- package/hooks/pre-commit +24 -2
- package/hooks/pre-push +204 -14
- package/lib/__tests__/phase-transition.test.js +272 -272
- package/lib/__tests__/sprint-audit.test.js +207 -207
- package/lib/doctor.js +50 -0
- package/lib/init.js +91 -0
- package/lib/language-tools.js +833 -0
- package/lib/phase-transition.js +249 -249
- package/lib/sprint-audit.js +235 -235
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- 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 +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/agents/delphi-architecture.md +48 -48
- package/plugins/qoder/agents/delphi-feasibility.md +48 -48
- package/plugins/qoder/agents/delphi-technical.md +48 -48
- 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 +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/scripts/delphi-external-review.cjs +432 -432
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
|
@@ -1,272 +1,272 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for phase-transition CLI command
|
|
3
|
-
* @test REQ-338 Phase transition CLI + dashboard rendering
|
|
4
|
-
* @intent Verify phase-transition command updates state and renders dashboard
|
|
5
|
-
* @covers AC-338-01 (CLI transitions phase and writes state)
|
|
6
|
-
* @covers AC-338-02 (--render flag outputs ASCII dashboard)
|
|
7
|
-
* @covers AC-338-03 (invalid inputs return error exit code)
|
|
8
|
-
* @covers AC-338-04 (--outputs flag records outputs in state)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
12
|
-
import fs from 'node:fs';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import os from 'node:os';
|
|
15
|
-
import { handlePhaseTransition, renderDashboard, PHASE_NAMES } from '../phase-transition.js';
|
|
16
|
-
|
|
17
|
-
describe('phase-transition', () => {
|
|
18
|
-
let tmpDir;
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-transition-test-'));
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('handlePhaseTransition()', () => {
|
|
29
|
-
it('returns 0 and shows help with --help', async () => {
|
|
30
|
-
const code = await handlePhaseTransition(['--help']);
|
|
31
|
-
expect(code).toBe(0);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('returns 1 for missing arguments', async () => {
|
|
35
|
-
const code = await handlePhaseTransition([]);
|
|
36
|
-
expect(code).toBe(1);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('returns 1 for invalid phase number', async () => {
|
|
40
|
-
const code = await handlePhaseTransition(['7', 'completed']);
|
|
41
|
-
expect(code).toBe(1);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('returns 1 for invalid status', async () => {
|
|
45
|
-
const code = await handlePhaseTransition(['1', 'invalid_status']);
|
|
46
|
-
expect(code).toBe(1);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('transitions phase and writes state file', async () => {
|
|
50
|
-
const code = await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
51
|
-
expect(code).toBe(0);
|
|
52
|
-
|
|
53
|
-
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
54
|
-
expect(fs.existsSync(stateFile)).toBe(true);
|
|
55
|
-
|
|
56
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
57
|
-
expect(state.phase).toBe(1);
|
|
58
|
-
expect(state.phase_history).toHaveLength(1);
|
|
59
|
-
expect(state.phase_history[0].status).toBe('completed');
|
|
60
|
-
expect(state.phase_history[0].phase_name).toBe('PREP');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('records outputs when --outputs flag is provided', async () => {
|
|
64
|
-
const outputs = JSON.stringify({ spec: 'path/to/spec.yaml' });
|
|
65
|
-
const code = await handlePhaseTransition(['2', 'completed', '--outputs', outputs, '--dir', tmpDir]);
|
|
66
|
-
expect(code).toBe(0);
|
|
67
|
-
|
|
68
|
-
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
69
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
70
|
-
expect(state.outputs.spec).toBe('path/to/spec.yaml');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('returns 1 for invalid --outputs JSON', async () => {
|
|
74
|
-
const code = await handlePhaseTransition(['1', 'completed', '--outputs', 'not-json', '--dir', tmpDir]);
|
|
75
|
-
expect(code).toBe(1);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('transitions through multiple phases sequentially', async () => {
|
|
79
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
80
|
-
await handlePhaseTransition(['2', 'in_progress', '--dir', tmpDir]);
|
|
81
|
-
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
82
|
-
await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
83
|
-
|
|
84
|
-
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
85
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
86
|
-
|
|
87
|
-
expect(state.phase).toBe(3);
|
|
88
|
-
expect(state.phase_history).toHaveLength(3);
|
|
89
|
-
expect(state.phase_history[0].status).toBe('completed');
|
|
90
|
-
expect(state.phase_history[1].status).toBe('completed');
|
|
91
|
-
expect(state.phase_history[2].status).toBe('in_progress');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('updates existing entry when same phase is transitioned again', async () => {
|
|
95
|
-
await handlePhaseTransition(['1', 'in_progress', '--dir', tmpDir]);
|
|
96
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
97
|
-
|
|
98
|
-
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
99
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
100
|
-
|
|
101
|
-
// Should NOT have duplicate entries
|
|
102
|
-
expect(state.phase_history).toHaveLength(1);
|
|
103
|
-
expect(state.phase_history[0].status).toBe('completed');
|
|
104
|
-
expect(state.phase_history[0].completed_at).toBeDefined();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('renderDashboard()', () => {
|
|
109
|
-
it('returns "No sprint state found" for null state', () => {
|
|
110
|
-
const result = renderDashboard(null);
|
|
111
|
-
expect(result).toBe('No sprint state found');
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('renders dashboard with all 6 phases', () => {
|
|
115
|
-
const state = {
|
|
116
|
-
id: 'sprint-test-123',
|
|
117
|
-
task_description: 'Test feature',
|
|
118
|
-
phase: 3,
|
|
119
|
-
status: 'in_progress',
|
|
120
|
-
started_at: '2026-07-21T10:00:00Z',
|
|
121
|
-
phase_history: [
|
|
122
|
-
{ phase: 1, phase_name: 'PREP', status: 'completed', duration_seconds: 120 },
|
|
123
|
-
{ phase: 2, phase_name: 'DESIGN', status: 'completed', duration_seconds: 300 },
|
|
124
|
-
{ phase: 3, phase_name: 'BUILD', status: 'in_progress' },
|
|
125
|
-
],
|
|
126
|
-
isolation: { branch: 'sprint/test-branch' },
|
|
127
|
-
outputs: {},
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const result = renderDashboard(state);
|
|
131
|
-
|
|
132
|
-
expect(result).toContain('SPRINT PROGRESS');
|
|
133
|
-
expect(result).toContain('sprint-test-123');
|
|
134
|
-
expect(result).toContain('Test feature');
|
|
135
|
-
expect(result).toContain('sprint/test-branch');
|
|
136
|
-
expect(result).toContain('Phase 1/6');
|
|
137
|
-
expect(result).toContain('Phase 6/6');
|
|
138
|
-
expect(result).toContain('✅'); // completed phases
|
|
139
|
-
expect(result).toContain('🔄'); // in_progress phase
|
|
140
|
-
expect(result).toContain('⏳'); // pending phases
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('shows outputs section when outputs exist', () => {
|
|
144
|
-
const state = {
|
|
145
|
-
id: 'sprint-out',
|
|
146
|
-
task_description: 'Feature',
|
|
147
|
-
phase: 2,
|
|
148
|
-
status: 'in_progress',
|
|
149
|
-
started_at: '2026-07-21T10:00:00Z',
|
|
150
|
-
phase_history: [],
|
|
151
|
-
isolation: { branch: 'test' },
|
|
152
|
-
outputs: { spec: 'design.yaml', pr: '#42' },
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const result = renderDashboard(state);
|
|
156
|
-
expect(result).toContain('输出物');
|
|
157
|
-
expect(result).toContain('spec: design.yaml');
|
|
158
|
-
expect(result).toContain('pr: #42');
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('handles minimal state gracefully', () => {
|
|
162
|
-
const state = {
|
|
163
|
-
id: 'sprint-min',
|
|
164
|
-
phase: 1,
|
|
165
|
-
status: 'in_progress',
|
|
166
|
-
phase_history: [],
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const result = renderDashboard(state);
|
|
170
|
-
expect(result).toContain('SPRINT PROGRESS');
|
|
171
|
-
expect(result).toContain('sprint-min');
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
describe('Layer 1: Pre-transition gate check', () => {
|
|
175
|
-
it('warns when previous phase not completed', async () => {
|
|
176
|
-
// Set up: Phase 1 completed, then try Phase 3 in_progress (skipping Phase 2)
|
|
177
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
178
|
-
|
|
179
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
180
|
-
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
181
|
-
expect(code).toBe(0);
|
|
182
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
183
|
-
expect.stringContaining('Phase 2 (DESIGN) not recorded')
|
|
184
|
-
);
|
|
185
|
-
warnSpy.mockRestore();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('does NOT warn when previous phase is completed', async () => {
|
|
189
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
190
|
-
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
191
|
-
|
|
192
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
|
-
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
194
|
-
expect(code).toBe(0);
|
|
195
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
196
|
-
warnSpy.mockRestore();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('does NOT warn when previous phase is skipped', async () => {
|
|
200
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
201
|
-
await handlePhaseTransition(['2', 'skipped', '--dir', tmpDir]);
|
|
202
|
-
|
|
203
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
204
|
-
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
205
|
-
expect(code).toBe(0);
|
|
206
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
207
|
-
warnSpy.mockRestore();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('skips check when no sprint-state.json exists', async () => {
|
|
211
|
-
// No state file — fresh project
|
|
212
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
213
|
-
const code = await handlePhaseTransition(['2', 'in_progress', '--dir', tmpDir]);
|
|
214
|
-
expect(code).toBe(0);
|
|
215
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
216
|
-
warnSpy.mockRestore();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('does NOT check for Phase 1 (no predecessor)', async () => {
|
|
220
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
221
|
-
const code = await handlePhaseTransition(['1', 'in_progress', '--dir', tmpDir]);
|
|
222
|
-
expect(code).toBe(0);
|
|
223
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
224
|
-
warnSpy.mockRestore();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('handles phase_history with gaps correctly', async () => {
|
|
228
|
-
// Set up: Phase 1 completed, Phase 3 completed (gap at Phase 2)
|
|
229
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
230
|
-
// Manually add Phase 3 without Phase 2
|
|
231
|
-
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
232
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
233
|
-
state.phase_history.push({ phase: 3, phase_name: 'BUILD', status: 'completed' });
|
|
234
|
-
state.phase = 3;
|
|
235
|
-
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
236
|
-
|
|
237
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
238
|
-
const code = await handlePhaseTransition(['4', 'in_progress', '--dir', tmpDir]);
|
|
239
|
-
expect(code).toBe(0);
|
|
240
|
-
// Phase 3 is completed, so no warning for Phase 3→4 transition
|
|
241
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
242
|
-
warnSpy.mockRestore();
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
describe('Layer 1.5: Phase 6 auto-trigger', () => {
|
|
247
|
-
it('outputs sprint-audit reminder on Phase 6 completed', async () => {
|
|
248
|
-
// Set up state through Phase 5
|
|
249
|
-
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
250
|
-
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
251
|
-
await handlePhaseTransition(['3', 'completed', '--dir', tmpDir]);
|
|
252
|
-
await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
|
|
253
|
-
await handlePhaseTransition(['5', 'completed', '--dir', tmpDir]);
|
|
254
|
-
|
|
255
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
256
|
-
const code = await handlePhaseTransition(['6', 'completed', '--dir', tmpDir]);
|
|
257
|
-
expect(code).toBe(0);
|
|
258
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
259
|
-
expect.stringContaining('sprint-audit')
|
|
260
|
-
);
|
|
261
|
-
logSpy.mockRestore();
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
describe('PHASE_NAMES export', () => {
|
|
266
|
-
it('exports PHASE_NAMES as shared constant', () => {
|
|
267
|
-
expect(PHASE_NAMES).toBeDefined();
|
|
268
|
-
expect(PHASE_NAMES[1]).toBe('PREP');
|
|
269
|
-
expect(PHASE_NAMES[6]).toBe('CLOSE');
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for phase-transition CLI command
|
|
3
|
+
* @test REQ-338 Phase transition CLI + dashboard rendering
|
|
4
|
+
* @intent Verify phase-transition command updates state and renders dashboard
|
|
5
|
+
* @covers AC-338-01 (CLI transitions phase and writes state)
|
|
6
|
+
* @covers AC-338-02 (--render flag outputs ASCII dashboard)
|
|
7
|
+
* @covers AC-338-03 (invalid inputs return error exit code)
|
|
8
|
+
* @covers AC-338-04 (--outputs flag records outputs in state)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { handlePhaseTransition, renderDashboard, PHASE_NAMES } from '../phase-transition.js';
|
|
16
|
+
|
|
17
|
+
describe('phase-transition', () => {
|
|
18
|
+
let tmpDir;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-transition-test-'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('handlePhaseTransition()', () => {
|
|
29
|
+
it('returns 0 and shows help with --help', async () => {
|
|
30
|
+
const code = await handlePhaseTransition(['--help']);
|
|
31
|
+
expect(code).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns 1 for missing arguments', async () => {
|
|
35
|
+
const code = await handlePhaseTransition([]);
|
|
36
|
+
expect(code).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns 1 for invalid phase number', async () => {
|
|
40
|
+
const code = await handlePhaseTransition(['7', 'completed']);
|
|
41
|
+
expect(code).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns 1 for invalid status', async () => {
|
|
45
|
+
const code = await handlePhaseTransition(['1', 'invalid_status']);
|
|
46
|
+
expect(code).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('transitions phase and writes state file', async () => {
|
|
50
|
+
const code = await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
51
|
+
expect(code).toBe(0);
|
|
52
|
+
|
|
53
|
+
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
54
|
+
expect(fs.existsSync(stateFile)).toBe(true);
|
|
55
|
+
|
|
56
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
57
|
+
expect(state.phase).toBe(1);
|
|
58
|
+
expect(state.phase_history).toHaveLength(1);
|
|
59
|
+
expect(state.phase_history[0].status).toBe('completed');
|
|
60
|
+
expect(state.phase_history[0].phase_name).toBe('PREP');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('records outputs when --outputs flag is provided', async () => {
|
|
64
|
+
const outputs = JSON.stringify({ spec: 'path/to/spec.yaml' });
|
|
65
|
+
const code = await handlePhaseTransition(['2', 'completed', '--outputs', outputs, '--dir', tmpDir]);
|
|
66
|
+
expect(code).toBe(0);
|
|
67
|
+
|
|
68
|
+
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
69
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
70
|
+
expect(state.outputs.spec).toBe('path/to/spec.yaml');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns 1 for invalid --outputs JSON', async () => {
|
|
74
|
+
const code = await handlePhaseTransition(['1', 'completed', '--outputs', 'not-json', '--dir', tmpDir]);
|
|
75
|
+
expect(code).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('transitions through multiple phases sequentially', async () => {
|
|
79
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
80
|
+
await handlePhaseTransition(['2', 'in_progress', '--dir', tmpDir]);
|
|
81
|
+
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
82
|
+
await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
83
|
+
|
|
84
|
+
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
85
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
86
|
+
|
|
87
|
+
expect(state.phase).toBe(3);
|
|
88
|
+
expect(state.phase_history).toHaveLength(3);
|
|
89
|
+
expect(state.phase_history[0].status).toBe('completed');
|
|
90
|
+
expect(state.phase_history[1].status).toBe('completed');
|
|
91
|
+
expect(state.phase_history[2].status).toBe('in_progress');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('updates existing entry when same phase is transitioned again', async () => {
|
|
95
|
+
await handlePhaseTransition(['1', 'in_progress', '--dir', tmpDir]);
|
|
96
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
97
|
+
|
|
98
|
+
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
99
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
100
|
+
|
|
101
|
+
// Should NOT have duplicate entries
|
|
102
|
+
expect(state.phase_history).toHaveLength(1);
|
|
103
|
+
expect(state.phase_history[0].status).toBe('completed');
|
|
104
|
+
expect(state.phase_history[0].completed_at).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('renderDashboard()', () => {
|
|
109
|
+
it('returns "No sprint state found" for null state', () => {
|
|
110
|
+
const result = renderDashboard(null);
|
|
111
|
+
expect(result).toBe('No sprint state found');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('renders dashboard with all 6 phases', () => {
|
|
115
|
+
const state = {
|
|
116
|
+
id: 'sprint-test-123',
|
|
117
|
+
task_description: 'Test feature',
|
|
118
|
+
phase: 3,
|
|
119
|
+
status: 'in_progress',
|
|
120
|
+
started_at: '2026-07-21T10:00:00Z',
|
|
121
|
+
phase_history: [
|
|
122
|
+
{ phase: 1, phase_name: 'PREP', status: 'completed', duration_seconds: 120 },
|
|
123
|
+
{ phase: 2, phase_name: 'DESIGN', status: 'completed', duration_seconds: 300 },
|
|
124
|
+
{ phase: 3, phase_name: 'BUILD', status: 'in_progress' },
|
|
125
|
+
],
|
|
126
|
+
isolation: { branch: 'sprint/test-branch' },
|
|
127
|
+
outputs: {},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = renderDashboard(state);
|
|
131
|
+
|
|
132
|
+
expect(result).toContain('SPRINT PROGRESS');
|
|
133
|
+
expect(result).toContain('sprint-test-123');
|
|
134
|
+
expect(result).toContain('Test feature');
|
|
135
|
+
expect(result).toContain('sprint/test-branch');
|
|
136
|
+
expect(result).toContain('Phase 1/6');
|
|
137
|
+
expect(result).toContain('Phase 6/6');
|
|
138
|
+
expect(result).toContain('✅'); // completed phases
|
|
139
|
+
expect(result).toContain('🔄'); // in_progress phase
|
|
140
|
+
expect(result).toContain('⏳'); // pending phases
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('shows outputs section when outputs exist', () => {
|
|
144
|
+
const state = {
|
|
145
|
+
id: 'sprint-out',
|
|
146
|
+
task_description: 'Feature',
|
|
147
|
+
phase: 2,
|
|
148
|
+
status: 'in_progress',
|
|
149
|
+
started_at: '2026-07-21T10:00:00Z',
|
|
150
|
+
phase_history: [],
|
|
151
|
+
isolation: { branch: 'test' },
|
|
152
|
+
outputs: { spec: 'design.yaml', pr: '#42' },
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const result = renderDashboard(state);
|
|
156
|
+
expect(result).toContain('输出物');
|
|
157
|
+
expect(result).toContain('spec: design.yaml');
|
|
158
|
+
expect(result).toContain('pr: #42');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles minimal state gracefully', () => {
|
|
162
|
+
const state = {
|
|
163
|
+
id: 'sprint-min',
|
|
164
|
+
phase: 1,
|
|
165
|
+
status: 'in_progress',
|
|
166
|
+
phase_history: [],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const result = renderDashboard(state);
|
|
170
|
+
expect(result).toContain('SPRINT PROGRESS');
|
|
171
|
+
expect(result).toContain('sprint-min');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('Layer 1: Pre-transition gate check', () => {
|
|
175
|
+
it('warns when previous phase not completed', async () => {
|
|
176
|
+
// Set up: Phase 1 completed, then try Phase 3 in_progress (skipping Phase 2)
|
|
177
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
178
|
+
|
|
179
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
180
|
+
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
181
|
+
expect(code).toBe(0);
|
|
182
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
183
|
+
expect.stringContaining('Phase 2 (DESIGN) not recorded')
|
|
184
|
+
);
|
|
185
|
+
warnSpy.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('does NOT warn when previous phase is completed', async () => {
|
|
189
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
190
|
+
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
191
|
+
|
|
192
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
|
+
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
194
|
+
expect(code).toBe(0);
|
|
195
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
196
|
+
warnSpy.mockRestore();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('does NOT warn when previous phase is skipped', async () => {
|
|
200
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
201
|
+
await handlePhaseTransition(['2', 'skipped', '--dir', tmpDir]);
|
|
202
|
+
|
|
203
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
204
|
+
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', tmpDir]);
|
|
205
|
+
expect(code).toBe(0);
|
|
206
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
207
|
+
warnSpy.mockRestore();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('skips check when no sprint-state.json exists', async () => {
|
|
211
|
+
// No state file — fresh project
|
|
212
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
213
|
+
const code = await handlePhaseTransition(['2', 'in_progress', '--dir', tmpDir]);
|
|
214
|
+
expect(code).toBe(0);
|
|
215
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
216
|
+
warnSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does NOT check for Phase 1 (no predecessor)', async () => {
|
|
220
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
221
|
+
const code = await handlePhaseTransition(['1', 'in_progress', '--dir', tmpDir]);
|
|
222
|
+
expect(code).toBe(0);
|
|
223
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
224
|
+
warnSpy.mockRestore();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('handles phase_history with gaps correctly', async () => {
|
|
228
|
+
// Set up: Phase 1 completed, Phase 3 completed (gap at Phase 2)
|
|
229
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
230
|
+
// Manually add Phase 3 without Phase 2
|
|
231
|
+
const stateFile = path.join(tmpDir, '.sprint-state', 'sprint-state.json');
|
|
232
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
233
|
+
state.phase_history.push({ phase: 3, phase_name: 'BUILD', status: 'completed' });
|
|
234
|
+
state.phase = 3;
|
|
235
|
+
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
236
|
+
|
|
237
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
238
|
+
const code = await handlePhaseTransition(['4', 'in_progress', '--dir', tmpDir]);
|
|
239
|
+
expect(code).toBe(0);
|
|
240
|
+
// Phase 3 is completed, so no warning for Phase 3→4 transition
|
|
241
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
242
|
+
warnSpy.mockRestore();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('Layer 1.5: Phase 6 auto-trigger', () => {
|
|
247
|
+
it('outputs sprint-audit reminder on Phase 6 completed', async () => {
|
|
248
|
+
// Set up state through Phase 5
|
|
249
|
+
await handlePhaseTransition(['1', 'completed', '--dir', tmpDir]);
|
|
250
|
+
await handlePhaseTransition(['2', 'completed', '--dir', tmpDir]);
|
|
251
|
+
await handlePhaseTransition(['3', 'completed', '--dir', tmpDir]);
|
|
252
|
+
await handlePhaseTransition(['4', 'completed', '--dir', tmpDir]);
|
|
253
|
+
await handlePhaseTransition(['5', 'completed', '--dir', tmpDir]);
|
|
254
|
+
|
|
255
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
256
|
+
const code = await handlePhaseTransition(['6', 'completed', '--dir', tmpDir]);
|
|
257
|
+
expect(code).toBe(0);
|
|
258
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
259
|
+
expect.stringContaining('sprint-audit')
|
|
260
|
+
);
|
|
261
|
+
logSpy.mockRestore();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('PHASE_NAMES export', () => {
|
|
266
|
+
it('exports PHASE_NAMES as shared constant', () => {
|
|
267
|
+
expect(PHASE_NAMES).toBeDefined();
|
|
268
|
+
expect(PHASE_NAMES[1]).toBe('PREP');
|
|
269
|
+
expect(PHASE_NAMES[6]).toBe('CLOSE');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|