@boyingliu01/xp-gate 0.14.8 → 0.14.10

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.
Files changed (50) hide show
  1. package/bin/xp-gate.js +23 -2
  2. package/lib/__tests__/install-skill.test.js +2 -1
  3. package/lib/__tests__/sprint-discovery.test.ts +6 -4
  4. package/lib/__tests__/sprint-state-manager.test.js +279 -0
  5. package/lib/__tests__/sprint-status.test.js +10 -13
  6. package/lib/__tests__/sprint-status.test.ts +4 -3
  7. package/lib/__tests__/update-hooks.test.js +12 -0
  8. package/lib/__tests__/upgrade-skill-sync.test.js +65 -0
  9. package/lib/check.js +115 -20
  10. package/lib/doctor.js +63 -0
  11. package/lib/gate-runner.js +209 -0
  12. package/lib/install-skill.js +14 -1
  13. package/lib/next-sprint.js +3 -4
  14. package/lib/shared-utils.js +1 -1
  15. package/lib/sprint-discovery.js +4 -4
  16. package/lib/sprint-state-manager.js +122 -0
  17. package/lib/sprint-state-migrator.js +153 -0
  18. package/lib/sprint-status.js +7 -5
  19. package/lib/update-hooks.js +1 -1
  20. package/lib/upgrade.js +8 -0
  21. package/mock-policy/AGENTS.md +4 -4
  22. package/mutation/AGENTS.md +4 -4
  23. package/mutation/__tests__/init-baseline.test.ts +347 -0
  24. package/mutation/__tests__/mutmut-runner.test.ts +561 -0
  25. package/mutation/__tests__/runners-index.test.ts +134 -0
  26. package/mutation/__tests__/stryker-runner.test.ts +455 -0
  27. package/mutation/__tests__/update-baseline.test.ts +393 -0
  28. package/package.json +1 -1
  29. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  30. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  31. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  32. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
  33. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +34 -8
  34. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  38. package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
  39. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +34 -8
  40. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  41. package/plugins/qoder/plugin.json +1 -1
  42. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  43. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  44. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  45. package/principles/AGENTS.md +4 -4
  46. package/skills/delphi-review/AGENTS.md +4 -4
  47. package/skills/sprint-flow/AGENTS.md +4 -4
  48. package/skills/sprint-flow/SKILL.md +1 -1
  49. package/skills/sprint-flow/references/phase-2-design.md +34 -8
  50. package/skills/test-specification-alignment/AGENTS.md +4 -4
package/bin/xp-gate.js CHANGED
@@ -14,6 +14,7 @@ const { arch } = require('../lib/arch.js');
14
14
  const { upgrade } = require('../lib/upgrade.js');
15
15
  const { bootstrap } = require('../lib/bootstrap.js');
16
16
  const { updateHooks } = require('../lib/update-hooks.js');
17
+ const gateRunner = require('../lib/gate-runner.js');
17
18
 
18
19
  function handleUIReview() {
19
20
  const { execSync } = require('child_process');
@@ -105,9 +106,9 @@ const COMMANDS = {
105
106
  usage: 'xp-gate baseline <create|show|reset|diff>'
106
107
  },
107
108
  'check': {
108
- description: 'Run user-invokable quality gates (Gate 4 Principles + Gate 6 Architecture) on a path',
109
+ description: 'Run quality gates (--list, --all, --gates <ids>, or <gate-id>)',
109
110
  run: subargs => check(subargs).then(code => process.exit(code)),
110
- usage: 'xp-gate check <file_or_directory> [--gates principles,arch]'
111
+ usage: 'xp-gate check <file_or_directory|gate_number> [--gates gates] [--all] [--list]'
111
112
  },
112
113
  'principles': {
113
114
  description: 'Run Clean Code + SOLID principles checker (Gate 4 standalone)',
@@ -173,9 +174,29 @@ const COMMANDS = {
173
174
  install(subargs).then(code => process.exit(code));
174
175
  },
175
176
  usage: 'xp-gate install [--global]'
177
+ },
178
+ 'gate-list': {
179
+ description: 'List all quality gates and their availability',
180
+ run: subargs => check(['--list']).then(code => process.exit(code)),
181
+ usage: 'xp-gate gate-list'
182
+ },
183
+ 'gate-all': {
184
+ description: 'Run all invokable quality gates',
185
+ run: subargs => check(['--all', ...subargs]).then(code => process.exit(code)),
186
+ usage: 'xp-gate gate-all [<path>]'
176
187
  }
177
188
  };
178
189
 
190
+ for (let i = 0; i <= 11; i++) {
191
+ const gateId = String(i);
192
+ const gateInfo = gateRunner.GATE_REGISTRY[gateId];
193
+ COMMANDS[`gate-${gateId}`] = {
194
+ description: `Run Gate ${gateId}: ${gateInfo?.name || 'Unknown'}`,
195
+ run: subargs => gateRunner.runGate(gateId, subargs[0]).then(code => process.exit(code)),
196
+ usage: `xp-gate gate-${gateId} [<path>]`
197
+ };
198
+ }
199
+
179
200
  function printHelp() {
180
201
  console.log('xp-gate - AI development workflow tool');
181
202
  console.log('');
@@ -178,7 +178,8 @@ describe('install-skill', () => {
178
178
  expect(fs.existsSync(configFile)).toBe(true);
179
179
  const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
180
180
  expect(config.installedSkills).toHaveProperty('delphi-review');
181
- expect(config.installedSkills['delphi-review'].version).toBe('1.0.0');
181
+ // Version should be actual CLI version (0.14.10.0), not hardcoded '1.0.0'
182
+ expect(config.installedSkills['delphi-review'].version).toBe('0.14.10.0');
182
183
  expect(config.installedSkills['delphi-review'].installedAt).toMatch(/^\d{4}-/);
183
184
  });
184
185
 
@@ -322,10 +322,12 @@ describe('sprint-discovery: discoverActiveSprints', () => {
322
322
  // no id, no status, no started_at
323
323
  task_description: 'Partial fields only',
324
324
  });
325
- // Should not throw, result should be skipped (no valid id)
325
+ // After migration, missing id gets auto-generated, so result is found
326
326
  const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
327
327
  const partials = results.filter(r => r.state.task_description === 'Partial fields only');
328
- expect(partials.length).toBe(0);
328
+ expect(partials.length).toBe(1);
329
+ // Migrated state should have auto-generated id
330
+ expect(partials[0].state.id).toMatch(/^sprint-\d+$/);
329
331
  });
330
332
 
331
333
  test('handles EACCES on .sprint-state/ directory gracefully', () => {
@@ -359,7 +361,7 @@ describe('sprint-discovery: discoverActiveSprints', () => {
359
361
  const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT);
360
362
  const desc = results.filter(r => r.state.id === 'sprint-2026-06-23-nodesc');
361
363
  expect(desc.length).toBe(1);
362
- // task_description will be null/undefined, but entry should still be returned
363
- expect(desc[0].state.task_description).toBeUndefined();
364
+ // After migration, missing task_description gets default '-'
365
+ expect(desc[0].state.task_description).toBe('-');
364
366
  });
365
367
  });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tests for Sprint State Manager
3
+ * @test REQ-001 Sprint State Manager
4
+ * @intent Verify read/write/migration/transitionPhase/rollback
5
+ * @covers AC-001-01, AC-001-02, AC-001-03
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { SprintStateManager, LEGACY_PHASE_MAP } from '../sprint-state-manager.js';
13
+ import { migrateState, createInitialState } from '../sprint-state-migrator.js';
14
+
15
+ describe('SprintStateManager', () => {
16
+ let tmpDir;
17
+ let manager;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-state-test-'));
21
+ manager = new SprintStateManager(tmpDir);
22
+ });
23
+
24
+ afterEach(() => {
25
+ fs.rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('read()', () => {
29
+ it('returns null when file does not exist', () => {
30
+ const state = manager.read();
31
+ expect(state).toBeNull();
32
+ });
33
+
34
+ it('reads valid state file', () => {
35
+ const state = {
36
+ _schema_version: 1,
37
+ id: 'sprint-123',
38
+ task_description: 'Test sprint',
39
+ phase: 2,
40
+ status: 'in_progress',
41
+ started_at: '2026-07-14T10:00:00Z',
42
+ phase_history: [],
43
+ isolation: { worktree_path: tmpDir, branch: 'test' },
44
+ };
45
+ fs.mkdirSync(path.join(tmpDir, '.sprint-state'), { recursive: true });
46
+ fs.writeFileSync(
47
+ path.join(tmpDir, '.sprint-state', 'sprint-state.json'),
48
+ JSON.stringify(state, null, 2)
49
+ );
50
+
51
+ const result = manager.read();
52
+ expect(result.id).toBe('sprint-123');
53
+ expect(result._schema_version).toBe(1);
54
+ });
55
+
56
+ it('auto-migrates legacy state and creates backup', () => {
57
+ const legacyState = {
58
+ id: 'sprint-legacy',
59
+ task_description: 'Legacy sprint',
60
+ phase: -1, // Legacy ISOLATE → 1 (PREP)
61
+ status: 'completed',
62
+ phase_history: [
63
+ { phase: -1, phase_name: 'ISOLATE', status: 'completed', timestamp: '2026-07-14T10:00:00Z' },
64
+ ],
65
+ isolation: { worktree_path: tmpDir, branch: 'test' },
66
+ };
67
+ fs.mkdirSync(path.join(tmpDir, '.sprint-state'), { recursive: true });
68
+ fs.writeFileSync(
69
+ path.join(tmpDir, '.sprint-state', 'sprint-state.json'),
70
+ JSON.stringify(legacyState, null, 2)
71
+ );
72
+
73
+ const result = manager.read();
74
+
75
+ // Should be migrated
76
+ expect(result._schema_version).toBe(1);
77
+ expect(result.phase).toBe(1); // -1 → 1
78
+ expect(result.phase_history[0].phase).toBe(1);
79
+ expect(result.phase_history[0].phase_name).toBe('PREP');
80
+ expect(result.phase_history[0].started_at).toBe('2026-07-14T10:00:00Z'); // timestamp → started_at
81
+
82
+ // Backup should exist
83
+ const backupExists = fs.existsSync(path.join(tmpDir, '.sprint-state', 'sprint-state.json.backup'));
84
+ expect(backupExists).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe('write()', () => {
89
+ it('writes state atomically', () => {
90
+ const state = {
91
+ _schema_version: 1,
92
+ id: 'sprint-test',
93
+ task_description: 'Test',
94
+ phase: 1,
95
+ status: 'in_progress',
96
+ started_at: new Date().toISOString(),
97
+ phase_history: [],
98
+ isolation: { worktree_path: tmpDir, branch: 'test' },
99
+ };
100
+
101
+ manager.write(state);
102
+
103
+ const written = JSON.parse(
104
+ fs.readFileSync(path.join(tmpDir, '.sprint-state', 'sprint-state.json'), 'utf8')
105
+ );
106
+ expect(written.id).toBe('sprint-test');
107
+
108
+ // Tmp file should not exist (atomic write completed)
109
+ const tmpExists = fs.existsSync(path.join(tmpDir, '.sprint-state', 'sprint-state.json.tmp'));
110
+ expect(tmpExists).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('transitionPhase()', () => {
115
+ it('creates new phase_history entry', () => {
116
+ // Create initial state
117
+ const initialState = createInitialState(tmpDir);
118
+ manager.write(initialState);
119
+
120
+ // Transition to phase 2
121
+ const result = manager.transitionPhase(2, 'in_progress');
122
+
123
+ expect(result.phase).toBe(2);
124
+ expect(result.phase_history.length).toBe(1);
125
+ expect(result.phase_history[0].phase).toBe(2);
126
+ expect(result.phase_history[0].phase_name).toBe('DESIGN');
127
+ expect(result.phase_history[0].status).toBe('in_progress');
128
+ expect(result.phase_history[0].started_at).toBeDefined();
129
+ });
130
+
131
+ it('updates existing phase_history entry', () => {
132
+ const initialState = createInitialState(tmpDir);
133
+ manager.write(initialState);
134
+
135
+ // Start phase 2
136
+ manager.transitionPhase(2, 'in_progress');
137
+
138
+ // Complete phase 2
139
+ const result = manager.transitionPhase(2, 'completed');
140
+
141
+ expect(result.phase_history[0].status).toBe('completed');
142
+ expect(result.phase_history[0].completed_at).toBeDefined();
143
+ expect(result.phase_history[0].duration_seconds).toBeGreaterThanOrEqual(0);
144
+ });
145
+
146
+ it('calls onTransition callback', () => {
147
+ const initialState = createInitialState(tmpDir);
148
+ manager.write(initialState);
149
+
150
+ let callbackCalled = false;
151
+ let callbackState = null;
152
+
153
+ manager.transitionPhase(2, 'in_progress', {
154
+ onTransition: (state) => {
155
+ callbackCalled = true;
156
+ callbackState = state;
157
+ },
158
+ });
159
+
160
+ expect(callbackCalled).toBe(true);
161
+ expect(callbackState.phase).toBe(2);
162
+ });
163
+
164
+ it('handles onTransition callback errors gracefully', () => {
165
+ const initialState = createInitialState(tmpDir);
166
+ manager.write(initialState);
167
+
168
+ // Should not throw
169
+ expect(() => {
170
+ manager.transitionPhase(2, 'in_progress', {
171
+ onTransition: () => {
172
+ throw new Error('Render failed');
173
+ },
174
+ });
175
+ }).not.toThrow();
176
+
177
+ // State should still be updated
178
+ const state = manager.read();
179
+ expect(state.phase).toBe(2);
180
+ });
181
+
182
+ it('updates outputs when provided', () => {
183
+ const initialState = createInitialState(tmpDir);
184
+ manager.write(initialState);
185
+
186
+ const result = manager.transitionPhase(2, 'completed', {
187
+ outputs: { specification: 'spec.yaml' },
188
+ });
189
+
190
+ expect(result.outputs.specification).toBe('spec.yaml');
191
+ });
192
+ });
193
+
194
+ describe('migrate()', () => {
195
+ it('adds _schema_version', () => {
196
+ const state = { id: 'test', phase: 1, phase_history: [] };
197
+ const migrated = migrateState(state, manager.stateDir);
198
+ expect(migrated._schema_version).toBe(1);
199
+ });
200
+
201
+ it('sets default task_description if missing', () => {
202
+ const state = { id: 'test', phase: 1, phase_history: [] };
203
+ const migrated = migrateState(state, manager.stateDir);
204
+ expect(migrated.task_description).toBe('-');
205
+ });
206
+
207
+ it('migrates all legacy phase numbers', () => {
208
+ for (const [legacy, expected] of Object.entries(LEGACY_PHASE_MAP)) {
209
+ const state = {
210
+ id: 'test',
211
+ phase: Number(legacy),
212
+ phase_history: [{ phase: Number(legacy), status: 'completed' }],
213
+ };
214
+ const migrated = migrateState(state, manager.stateDir);
215
+ expect(migrated.phase).toBe(expected.phase);
216
+ expect(migrated.phase_history[0].phase).toBe(expected.phase);
217
+ expect(migrated.phase_history[0].phase_name).toBe(expected.phase_name);
218
+ }
219
+ });
220
+
221
+ it('preserves unknown fields', () => {
222
+ const state = {
223
+ id: 'test',
224
+ phase: 1,
225
+ phase_history: [],
226
+ custom_field: 'preserved',
227
+ };
228
+ const migrated = migrateState(state, manager.stateDir);
229
+ expect(migrated.custom_field).toBe('preserved');
230
+ });
231
+ });
232
+
233
+ describe('rollback()', () => {
234
+ it('returns false when no backup exists', () => {
235
+ const result = manager.rollback();
236
+ expect(result).toBe(false);
237
+ });
238
+
239
+ it('restores from backup', () => {
240
+ // Create initial state and backup
241
+ const originalState = {
242
+ _schema_version: 1,
243
+ id: 'original',
244
+ task_description: 'Original',
245
+ phase: 1,
246
+ status: 'in_progress',
247
+ started_at: '2026-07-14T10:00:00Z',
248
+ phase_history: [],
249
+ isolation: { worktree_path: tmpDir, branch: 'test' },
250
+ };
251
+ fs.mkdirSync(path.join(tmpDir, '.sprint-state'), { recursive: true });
252
+ fs.writeFileSync(
253
+ path.join(tmpDir, '.sprint-state', 'sprint-state.json'),
254
+ JSON.stringify(originalState, null, 2)
255
+ );
256
+ fs.writeFileSync(
257
+ path.join(tmpDir, '.sprint-state', 'sprint-state.json.backup'),
258
+ JSON.stringify(originalState, null, 2)
259
+ );
260
+
261
+ // Modify current state
262
+ const modifiedState = { ...originalState, id: 'modified' };
263
+ fs.writeFileSync(
264
+ path.join(tmpDir, '.sprint-state', 'sprint-state.json'),
265
+ JSON.stringify(modifiedState, null, 2)
266
+ );
267
+
268
+ // Rollback
269
+ const result = manager.rollback();
270
+ expect(result).toBe(true);
271
+
272
+ // Verify restored
273
+ const restored = JSON.parse(
274
+ fs.readFileSync(path.join(tmpDir, '.sprint-state', 'sprint-state.json'), 'utf8')
275
+ );
276
+ expect(restored.id).toBe('original');
277
+ });
278
+ });
279
+ });
@@ -14,12 +14,13 @@ const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-status-test-'));
14
14
  const STATE_DIR = path.join(TMP_DIR, '.sprint-state');
15
15
  const STATE_FILE = path.join(STATE_DIR, 'sprint-state.json');
16
16
 
17
- // Active sprint state (canonical format per SKILL.md L893-942)
17
+ // Active sprint state (v1 schema per sprint-state-manager.js)
18
18
  const ACTIVE_STATE = {
19
+ _schema_version: 1,
19
20
  id: 'sprint-2026-06-16-01',
20
21
  task_description: 'Test sprint',
21
- phase: 2,
22
- status: 'running',
22
+ phase: 3,
23
+ status: 'in_progress',
23
24
  started_at: '2026-06-16T10:00:00Z',
24
25
  isolation: {
25
26
  worktree_path: '.worktrees/sprint/sprint-2026-06-16-01',
@@ -33,15 +34,11 @@ const ACTIVE_STATE = {
33
34
  user_decision: 'accepted'
34
35
  },
35
36
  phase_history: [
36
- { phase: -1, phase_name: 'ISOLATE', status: 'completed',
37
+ { phase: 1, phase_name: 'PREP', status: 'completed',
37
38
  started_at: '2026-06-16T10:00:00Z', completed_at: '2026-06-16T10:03:00Z', duration_seconds: 180 },
38
- { phase: -0.5, phase_name: 'AUTO-ESTIMATE', status: 'completed',
39
- started_at: '2026-06-16T10:03:00Z', completed_at: '2026-06-16T10:05:00Z', duration_seconds: 120 },
40
- { phase: 0, phase_name: 'THINK', status: 'completed',
39
+ { phase: 2, phase_name: 'DESIGN', status: 'completed',
41
40
  started_at: '2026-06-16T10:05:00Z', completed_at: '2026-06-16T10:15:00Z', duration_seconds: 600 },
42
- { phase: 1, phase_name: 'PLAN', status: 'completed',
43
- started_at: '2026-06-16T10:15:00Z', completed_at: '2026-06-16T10:25:00Z', duration_seconds: 600 },
44
- { phase: 2, phase_name: 'BUILD', status: 'in_progress',
41
+ { phase: 3, phase_name: 'BUILD', status: 'in_progress',
45
42
  started_at: '2026-06-16T10:25:00Z', completed_at: null, duration_seconds: null,
46
43
  reqs: {
47
44
  'REQ-001': { name: 'Feature A', status: 'completed' },
@@ -87,8 +84,8 @@ describe('sprint-status: readSprintState', () => {
87
84
  const state = sprintStatus.readSprintState(TMP_DIR);
88
85
  expect(state).not.toBeNull();
89
86
  expect(state.task_description).toBe('Test sprint');
90
- expect(state.phase).toBe(2);
91
- expect(state.status).toBe('running');
87
+ expect(state.phase).toBe(3);
88
+ expect(state.status).toBe('in_progress');
92
89
  expect(Array.isArray(state.phase_history)).toBe(true);
93
90
  expect(state.phase_history.length).toBeGreaterThan(0);
94
91
  });
@@ -166,7 +163,7 @@ describe('sprint-status: formatSprintTable', () => {
166
163
  const json = sprintStatus.jsonMode(ACTIVE_STATE);
167
164
  const parsed = JSON.parse(json);
168
165
  expect(parsed.task_description).toBe('Test sprint');
169
- expect(parsed.phase).toBe(2);
166
+ expect(parsed.phase).toBe(3);
170
167
  });
171
168
 
172
169
  // AC-SPRINTSTATUS-001-09: Stale state detection
@@ -16,12 +16,13 @@ const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-status-test-'));
16
16
  const STATE_DIR = path.join(TMP_DIR, '.sprint-state');
17
17
  const STATE_FILE = path.join(STATE_DIR, 'sprint-state.json');
18
18
 
19
- // Active sprint state (canonical format per SKILL.md L893-942, 6-phase schema)
19
+ // Active sprint state (v1 schema per sprint-state-manager.js, 6-phase schema)
20
20
  const ACTIVE_STATE = {
21
+ _schema_version: 1,
21
22
  id: 'sprint-2026-06-16-01',
22
23
  task_description: 'Test sprint',
23
24
  phase: 3,
24
- status: 'running',
25
+ status: 'in_progress',
25
26
  started_at: '2026-06-16T10:00:00Z',
26
27
  isolation: {
27
28
  worktree_path: '.worktrees/sprint/sprint-2026-06-16-01',
@@ -95,7 +96,7 @@ describe('sprint-status: readSprintState', () => {
95
96
  const stateObj = state as Record<string, unknown>;
96
97
  expect(stateObj.task_description).toBe('Test sprint');
97
98
  expect(stateObj.phase).toBe(3);
98
- expect(stateObj.status).toBe('running');
99
+ expect(stateObj.status).toBe('in_progress');
99
100
  expect(Array.isArray(stateObj.phase_history)).toBe(true);
100
101
  });
101
102
 
@@ -140,6 +140,18 @@ describe('updateHooks', () => {
140
140
  expect(fs.readFileSync(path.join(dest, 'gate-4.sh'), 'utf8')).toContain('gate4-v2');
141
141
  });
142
142
 
143
+ it('copies sprint-gate.sh alongside gate-*.sh scripts (#335)', () => {
144
+ createPackageSource();
145
+ fs.writeFileSync(path.join(tmpPackage, 'sprint-gate.sh'), '#!/bin/bash\necho "sprint-gate-v1"');
146
+ const mod = getModule();
147
+ const dest = path.join(tmpProject, 'gates-sprint');
148
+ fs.mkdirSync(dest, { recursive: true });
149
+
150
+ mod.copyGateScripts(tmpPackage, dest, false, true);
151
+
152
+ expect(fs.readFileSync(path.join(dest, 'sprint-gate.sh'), 'utf8')).toContain('sprint-gate-v1');
153
+ });
154
+
143
155
  it('does not copy non-gate files', () => {
144
156
  createPackageSource();
145
157
  fs.writeFileSync(path.join(tmpPackage, 'some-other.sh'), 'echo other');
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @test REQ-332 xp-gate upgrade --apply must update installed skills
3
+ * @intent After upgrade --apply, all installed skills should be refreshed to match the new package version
4
+ * @covers AC-332-01, AC-332-02, AC-332-03, AC-332-04
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ describe('upgrade --apply skill sync — #332', () => {
11
+ // AC-332-01: upgrade.js handleApplyMode calls updateSkill after successful upgrade
12
+ it('AC-332-01: upgrade.js contains updateSkill call after plugin upgrade', () => {
13
+ const source = fs.readFileSync(require.resolve('../upgrade.js'), 'utf8');
14
+ expect(source).toContain("require('./update-skill.js')");
15
+ expect(source).toContain('updateSkill(null, { all: true');
16
+ });
17
+
18
+ // AC-332-02: updateSkill call is inside try/catch (non-blocking)
19
+ it('AC-332-02: updateSkill call is wrapped in try/catch for non-blocking behavior', () => {
20
+ const source = fs.readFileSync(require.resolve('../upgrade.js'), 'utf8');
21
+ const lines = source.split('\n');
22
+ let foundRequire = false;
23
+ let inTryBlock = false;
24
+ for (let i = 0; i < lines.length; i++) {
25
+ if (lines[i].includes("require('./update-skill.js')")) {
26
+ foundRequire = true;
27
+ for (let j = i - 1; j >= 0; j--) {
28
+ if (/^\s*try\s*\{/.test(lines[j])) {
29
+ inTryBlock = true;
30
+ break;
31
+ }
32
+ if (/^\s*(function|async function|=>)/.test(lines[j])) break;
33
+ }
34
+ break;
35
+ }
36
+ }
37
+ expect(foundRequire).toBe(true);
38
+ expect(inTryBlock).toBe(true);
39
+ });
40
+
41
+ // AC-332-03: updateSkill call is placed after upgradeOpenCodePlugin (correct ordering)
42
+ it('AC-332-03: updateSkill is called after upgradeOpenCodePlugin in handleApplyMode', () => {
43
+ const source = fs.readFileSync(require.resolve('../upgrade.js'), 'utf8');
44
+ const pluginIdx = source.indexOf('upgradeOpenCodePlugin()');
45
+ const skillIdx = source.indexOf("require('./update-skill.js')");
46
+ expect(pluginIdx).toBeGreaterThan(-1);
47
+ expect(skillIdx).toBeGreaterThan(pluginIdx);
48
+ });
49
+ });
50
+
51
+ describe('doctor skill version check — #332', () => {
52
+ // AC-332-04: doctor.js diagnose() includes skill version consistency check
53
+ it('AC-332-04: doctor.js diagnose() contains diagnoseInstalledSkills call', () => {
54
+ const source = fs.readFileSync(require.resolve('../doctor.js'), 'utf8');
55
+ expect(source).toContain('diagnoseInstalledSkills');
56
+ });
57
+
58
+ // AC-332-05: diagnoseInstalledSkills compares installed skills against package-bundled skills
59
+ it('AC-332-05: diagnoseInstalledSkills reads config installedSkills and package skills dir', () => {
60
+ const source = fs.readFileSync(require.resolve('../doctor.js'), 'utf8');
61
+ expect(source).toContain('installedSkills');
62
+ expect(source).toContain('diagnoseInstalledSkills');
63
+ expect(source).toContain('skills');
64
+ });
65
+ });