@boyingliu01/xp-gate 0.14.7 → 0.14.9
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 +31 -2
- package/lib/__tests__/next-sprint.test.js +231 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/upgrade-skill-sync.test.js +65 -0
- package/lib/check.js +115 -20
- package/lib/doctor.js +63 -0
- package/lib/gate-runner.js +209 -0
- package/lib/next-sprint.js +129 -0
- package/lib/shared-utils.js +1 -1
- package/lib/sprint-status.js +3 -1
- package/lib/update-hooks.js +1 -1
- package/lib/upgrade.js +8 -0
- package/mock-policy/AGENTS.md +4 -4
- package/mock-policy/__tests__/gate-m3.test.ts +384 -0
- package/mock-policy/__tests__/schema.test.ts +239 -0
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/init-baseline.test.ts +347 -0
- package/mutation/__tests__/mutmut-runner.test.ts +561 -0
- package/mutation/__tests__/runners-index.test.ts +134 -0
- package/mutation/__tests__/stryker-runner.test.ts +455 -0
- package/mutation/__tests__/update-baseline.test.ts +393 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +4 -4
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/SKILL.md +1 -1
- package/skills/sprint-flow/references/phase-2-design.md +34 -8
- 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
|
|
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
|
|
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)',
|
|
@@ -137,6 +138,14 @@ const COMMANDS = {
|
|
|
137
138
|
},
|
|
138
139
|
usage: 'xp-gate sprint-status [--json] [--watch] [--dir <path>]'
|
|
139
140
|
},
|
|
141
|
+
'next-sprint': {
|
|
142
|
+
description: 'Analyze remaining open issues and plan next iteration',
|
|
143
|
+
run: subargs => {
|
|
144
|
+
const { handleNextSprint } = require('../lib/next-sprint.js');
|
|
145
|
+
handleNextSprint(subargs).then(code => process.exit(code));
|
|
146
|
+
},
|
|
147
|
+
usage: 'xp-gate next-sprint [--json] [--plan] [--dir <path>]'
|
|
148
|
+
},
|
|
140
149
|
'update-hooks': {
|
|
141
150
|
description: 'Sync latest hooks from xp-gate package to project',
|
|
142
151
|
run: subargs => {
|
|
@@ -165,9 +174,29 @@ const COMMANDS = {
|
|
|
165
174
|
install(subargs).then(code => process.exit(code));
|
|
166
175
|
},
|
|
167
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>]'
|
|
168
187
|
}
|
|
169
188
|
};
|
|
170
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
|
+
|
|
171
200
|
function printHelp() {
|
|
172
201
|
console.log('xp-gate - AI development workflow tool');
|
|
173
202
|
console.log('');
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
describe('next-sprint', () => {
|
|
6
|
+
let tmpDir;
|
|
7
|
+
let consoleSpy;
|
|
8
|
+
let processExitSpy;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'next-sprint-test-'));
|
|
12
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
13
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
14
|
+
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
15
|
+
// Clear require cache to ensure fresh module for each test
|
|
16
|
+
delete require.cache[require.resolve('../next-sprint')];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function createState(overrides = {}) {
|
|
25
|
+
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
26
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
27
|
+
const state = {
|
|
28
|
+
id: 'sprint-2026-06-30-01',
|
|
29
|
+
task_description: 'Test Sprint',
|
|
30
|
+
phase: 4,
|
|
31
|
+
status: 'completed',
|
|
32
|
+
started_at: new Date().toISOString(),
|
|
33
|
+
phase_history: [
|
|
34
|
+
{
|
|
35
|
+
phase: 2,
|
|
36
|
+
phase_name: 'BUILD',
|
|
37
|
+
status: 'completed',
|
|
38
|
+
reqs: {
|
|
39
|
+
'REQ-001': { name: 'Implement login', status: 'completed' },
|
|
40
|
+
'REQ-002': { name: 'Implement logout', status: 'completed' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state));
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mockGhCli(issues) {
|
|
51
|
+
const { execSync } = require('child_process');
|
|
52
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
|
|
53
|
+
if (cmd.includes('gh issue list')) {
|
|
54
|
+
return JSON.stringify(issues);
|
|
55
|
+
}
|
|
56
|
+
return '';
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('readSprintState', () => {
|
|
61
|
+
it('returns null when no sprint state exists', () => {
|
|
62
|
+
const { readSprintState } = require('../next-sprint');
|
|
63
|
+
const result = readSprintState(tmpDir);
|
|
64
|
+
expect(result).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns parsed state when sprint state exists', () => {
|
|
68
|
+
const state = createState();
|
|
69
|
+
const { readSprintState } = require('../next-sprint');
|
|
70
|
+
const result = readSprintState(tmpDir);
|
|
71
|
+
expect(result).toBeDefined();
|
|
72
|
+
expect(result.id).toBe('sprint-2026-06-30-01');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('getCurrentSprintReqs', () => {
|
|
77
|
+
it('extracts requirements from sprint state', () => {
|
|
78
|
+
createState();
|
|
79
|
+
const { readSprintState, getCurrentSprintReqs } = require('../next-sprint');
|
|
80
|
+
const state = readSprintState(tmpDir);
|
|
81
|
+
const reqs = getCurrentSprintReqs(state);
|
|
82
|
+
expect(reqs).toHaveLength(2);
|
|
83
|
+
expect(reqs[0].name).toBe('Implement login');
|
|
84
|
+
expect(reqs[1].name).toBe('Implement logout');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns empty array when no phase history', () => {
|
|
88
|
+
const { getCurrentSprintReqs } = require('../next-sprint');
|
|
89
|
+
const reqs = getCurrentSprintReqs({ phase_history: [] });
|
|
90
|
+
expect(reqs).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('filterRemainingIssues', () => {
|
|
95
|
+
it('filters out issues already in sprint', () => {
|
|
96
|
+
const issues = [
|
|
97
|
+
{ number: 1, title: 'Implement login', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
98
|
+
{ number: 2, title: 'Implement logout', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
99
|
+
{ number: 3, title: 'Implement signup', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
100
|
+
];
|
|
101
|
+
const sprintReqs = [{ name: 'Implement login' }, { name: 'Implement logout' }];
|
|
102
|
+
const { filterRemainingIssues } = require('../next-sprint');
|
|
103
|
+
const result = filterRemainingIssues(issues, sprintReqs);
|
|
104
|
+
expect(result).toHaveLength(1);
|
|
105
|
+
expect(result[0].number).toBe(3);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns all issues when no sprint reqs', () => {
|
|
109
|
+
const issues = [
|
|
110
|
+
{ number: 1, title: 'Implement login', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
111
|
+
{ number: 2, title: 'Implement logout', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
112
|
+
];
|
|
113
|
+
const { filterRemainingIssues } = require('../next-sprint');
|
|
114
|
+
const result = filterRemainingIssues(issues, []);
|
|
115
|
+
expect(result).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('formatIssuesTable', () => {
|
|
120
|
+
it('formats issues as table', () => {
|
|
121
|
+
const issues = [
|
|
122
|
+
{ number: 1, title: 'Implement login', labels: [{ name: 'feature' }], createdAt: '2026-06-30T00:00:00Z' },
|
|
123
|
+
{ number: 2, title: 'Implement logout', labels: [{ name: 'bug' }], createdAt: '2026-06-30T00:00:00Z' },
|
|
124
|
+
];
|
|
125
|
+
const { formatIssuesTable } = require('../next-sprint');
|
|
126
|
+
const result = formatIssuesTable(issues);
|
|
127
|
+
expect(result).toContain('Remaining Open Issues');
|
|
128
|
+
expect(result).toContain('Implement login');
|
|
129
|
+
expect(result).toContain('feature');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('handles empty issues', () => {
|
|
133
|
+
const { formatIssuesTable } = require('../next-sprint');
|
|
134
|
+
const result = formatIssuesTable([]);
|
|
135
|
+
expect(result).toBe('No remaining issues found.');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('generateSprintPlan', () => {
|
|
140
|
+
it('generates sprint plan for remaining issues', () => {
|
|
141
|
+
const issues = [
|
|
142
|
+
{ number: 1, title: 'Implement login', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
143
|
+
{ number: 2, title: 'Implement logout', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
144
|
+
];
|
|
145
|
+
const { generateSprintPlan } = require('../next-sprint');
|
|
146
|
+
const result = generateSprintPlan(issues);
|
|
147
|
+
expect(result).toContain('Suggested Next Sprint Plan');
|
|
148
|
+
expect(result).toContain('#1');
|
|
149
|
+
expect(result).toContain('sprint-flow');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns null when no issues', () => {
|
|
153
|
+
const { generateSprintPlan } = require('../next-sprint');
|
|
154
|
+
const result = generateSprintPlan([]);
|
|
155
|
+
expect(result).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('handleNextSprint', () => {
|
|
160
|
+
it('outputs remaining issues in table format', async () => {
|
|
161
|
+
createState();
|
|
162
|
+
const { execSync } = require('child_process');
|
|
163
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
|
|
164
|
+
if (cmd.includes('gh issue list')) {
|
|
165
|
+
return JSON.stringify([
|
|
166
|
+
{ number: 1, title: 'Implement login', labels: [{ name: 'feature' }], createdAt: '2026-06-30T00:00:00Z' },
|
|
167
|
+
{ number: 2, title: 'Implement logout', labels: [{ name: 'bug' }], createdAt: '2026-06-30T00:00:00Z' },
|
|
168
|
+
{ number: 3, title: 'Implement signup', labels: [{ name: 'enhancement' }], createdAt: '2026-06-30T00:00:00Z' },
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
172
|
+
});
|
|
173
|
+
delete require.cache[require.resolve('../next-sprint')];
|
|
174
|
+
const { handleNextSprint } = require('../next-sprint');
|
|
175
|
+
const result = await handleNextSprint(['--dir', tmpDir]);
|
|
176
|
+
expect(result).toBe(0);
|
|
177
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
178
|
+
const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
|
|
179
|
+
expect(output).toContain('Remaining Open Issues');
|
|
180
|
+
expect(output).toContain('Implement signup');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('outputs JSON when --json flag', async () => {
|
|
184
|
+
createState();
|
|
185
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
|
|
186
|
+
if (cmd.includes('gh issue list')) {
|
|
187
|
+
return JSON.stringify([
|
|
188
|
+
{ number: 1, title: 'Implement login', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
return '';
|
|
192
|
+
});
|
|
193
|
+
delete require.cache[require.resolve('../next-sprint')];
|
|
194
|
+
const { handleNextSprint } = require('../next-sprint');
|
|
195
|
+
const result = await handleNextSprint(['--json', '--dir', tmpDir]);
|
|
196
|
+
expect(result).toBe(0);
|
|
197
|
+
const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
|
|
198
|
+
const parsed = JSON.parse(output);
|
|
199
|
+
expect(parsed).toHaveProperty('current_sprint');
|
|
200
|
+
expect(parsed).toHaveProperty('remaining_issues');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('outputs plan when --plan flag', async () => {
|
|
204
|
+
createState();
|
|
205
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
|
|
206
|
+
if (cmd.includes('gh issue list')) {
|
|
207
|
+
return JSON.stringify([
|
|
208
|
+
{ number: 5, title: 'Implement password reset', labels: [], createdAt: '2026-06-30T00:00:00Z' },
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
return '';
|
|
212
|
+
});
|
|
213
|
+
delete require.cache[require.resolve('../next-sprint')];
|
|
214
|
+
const { handleNextSprint } = require('../next-sprint');
|
|
215
|
+
const result = await handleNextSprint(['--plan', '--dir', tmpDir]);
|
|
216
|
+
expect(result).toBe(0);
|
|
217
|
+
const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
|
|
218
|
+
expect(output).toContain('Suggested Next Sprint Plan');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns 1 when gh CLI not available', async () => {
|
|
222
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation(() => {
|
|
223
|
+
throw new Error('gh: command not found');
|
|
224
|
+
});
|
|
225
|
+
delete require.cache[require.resolve('../next-sprint')];
|
|
226
|
+
const { handleNextSprint } = require('../next-sprint');
|
|
227
|
+
const result = await handleNextSprint(['--dir', tmpDir]);
|
|
228
|
+
expect(result).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -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
|
+
});
|
package/lib/check.js
CHANGED
|
@@ -2,49 +2,144 @@
|
|
|
2
2
|
|
|
3
3
|
const { principles } = require('./principles.js');
|
|
4
4
|
const { arch } = require('./arch.js');
|
|
5
|
+
const { getGateInfo, runGate } = require('./gate-runner.js');
|
|
6
|
+
|
|
7
|
+
function parseCheckArgs(args) {
|
|
8
|
+
const flags = { all: false, list: false, gateIds: null };
|
|
9
|
+
const posArgs = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < args.length) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (arg === '--all') { flags.all = true; i++; }
|
|
14
|
+
else if (arg === '--list') { flags.list = true; i++; }
|
|
15
|
+
else if (arg === '--gates' && i + 1 < args.length) {
|
|
16
|
+
flags.gateIds = args[i + 1].split(',').map(s => s.trim()).filter(Boolean);
|
|
17
|
+
i += 2;
|
|
18
|
+
}
|
|
19
|
+
else if (arg.startsWith('--gates=')) {
|
|
20
|
+
flags.gateIds = arg.split('=')[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
else { posArgs.push(arg); i++; }
|
|
24
|
+
}
|
|
25
|
+
const rawTarget = posArgs[0] || null;
|
|
26
|
+
const isGateNumber = rawTarget && /^\d+$/.test(rawTarget);
|
|
27
|
+
return {
|
|
28
|
+
...flags,
|
|
29
|
+
positionals: posArgs,
|
|
30
|
+
pathArg: isGateNumber ? posArgs[1] || null : rawTarget,
|
|
31
|
+
gateNumber: isGateNumber ? rawTarget : null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
5
34
|
|
|
6
|
-
// Runs the user-invokable subset of quality gates on a path. Mirrors what the
|
|
7
|
-
// pre-commit hook does, but as a standalone CLI surface. Intentionally narrower
|
|
8
|
-
// than the full pre-commit run (no git staging context, no Boy Scout baseline).
|
|
9
|
-
// Currently delegates to: Gate 4 (Principles) + Gate 6 (Architecture).
|
|
10
35
|
async function check(args) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
36
|
+
const opts = parseCheckArgs(args);
|
|
37
|
+
|
|
38
|
+
if (opts.list) return listGates();
|
|
39
|
+
if (opts.gateNumber && !opts.all && !opts.gateIds) return runGate(opts.gateNumber, opts.pathArg);
|
|
40
|
+
if (opts.all) return checkAllGates(opts.pathArg);
|
|
41
|
+
if (opts.gateIds && opts.gateIds.length > 0) return runGatesByIds(opts.gateIds, opts.pathArg);
|
|
42
|
+
|
|
43
|
+
if (!opts.pathArg) {
|
|
44
|
+
console.error('Usage: xp-gate check <file_or_directory|gate_number> [options]');
|
|
45
|
+
console.error('');
|
|
46
|
+
console.error('Options:');
|
|
47
|
+
console.error(' --gates <ids> Run specific gates (comma-separated, e.g. "3,4,6")');
|
|
48
|
+
console.error(' --all Run all invokable gates');
|
|
49
|
+
console.error(' --list List all available gates');
|
|
50
|
+
console.error('');
|
|
51
|
+
console.error('Examples:');
|
|
52
|
+
console.error(' xp-gate check src/ # Run principles + architecture check');
|
|
53
|
+
console.error(' xp-gate check 3 # Run Gate 3 (Complexity)');
|
|
54
|
+
console.error(' xp-gate check --gates 3,4,6 # Run gates 3, 4, and 6');
|
|
55
|
+
console.error(' xp-gate check --all # Run all invokable gates');
|
|
56
|
+
console.error(' xp-gate check --list # List available gates');
|
|
14
57
|
return 1;
|
|
15
58
|
}
|
|
16
59
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
60
|
+
return runDefaultChecks(opts.pathArg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runDefaultChecks(target) {
|
|
64
|
+
let failures = 0;
|
|
65
|
+
let ran = 0;
|
|
66
|
+
|
|
67
|
+
console.log('━━━ Gate 4: Principles ━━━');
|
|
68
|
+
const principlesCode = await principles([target]);
|
|
69
|
+
if (principlesCode !== 0) failures += 1;
|
|
70
|
+
ran += 1;
|
|
71
|
+
|
|
72
|
+
console.log('━━━ Gate 6: Architecture ━━━');
|
|
73
|
+
const archCode = await arch([]);
|
|
74
|
+
if (archCode !== 0) failures += 1;
|
|
75
|
+
ran += 1;
|
|
76
|
+
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(`[xp-gate check] ${ran - failures}/${ran} gate(s) passed`);
|
|
79
|
+
return failures === 0 ? 0 : 1;
|
|
80
|
+
}
|
|
21
81
|
|
|
82
|
+
async function checkAllGates(target) {
|
|
83
|
+
const { getAllGates } = require('./gate-runner.js');
|
|
84
|
+
const invokableGates = getAllGates().filter(g => !g.preCommitOnly);
|
|
22
85
|
let failures = 0;
|
|
23
86
|
let ran = 0;
|
|
24
87
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const code = await principles([target]);
|
|
88
|
+
for (const gate of invokableGates) {
|
|
89
|
+
const code = await runGate(gate.id, target);
|
|
28
90
|
if (code !== 0) failures += 1;
|
|
29
91
|
ran += 1;
|
|
92
|
+
console.log('');
|
|
30
93
|
}
|
|
31
94
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
95
|
+
console.log(`[xp-gate check --all] ${ran - failures}/${ran} gate(s) passed`);
|
|
96
|
+
return failures === 0 ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runGatesByIds(gateIds, target) {
|
|
100
|
+
let failures = 0;
|
|
101
|
+
let ran = 0;
|
|
102
|
+
|
|
103
|
+
for (const gateId of gateIds) {
|
|
104
|
+
const code = await runGate(gateId, target);
|
|
35
105
|
if (code !== 0) failures += 1;
|
|
36
106
|
ran += 1;
|
|
107
|
+
console.log('');
|
|
37
108
|
}
|
|
38
109
|
|
|
39
110
|
if (ran === 0) {
|
|
40
|
-
console.error(`[xp-gate check] No valid gates in --gates list: ${
|
|
41
|
-
console.error('
|
|
111
|
+
console.error(`[xp-gate check] No valid gates in --gates list: ${gateIds.join(',')}`);
|
|
112
|
+
console.error(' Run: xp-gate check --list to see available gates');
|
|
42
113
|
return 1;
|
|
43
114
|
}
|
|
44
115
|
|
|
45
|
-
console.log('');
|
|
46
116
|
console.log(`[xp-gate check] ${ran - failures}/${ran} gate(s) passed`);
|
|
47
117
|
return failures === 0 ? 0 : 1;
|
|
48
118
|
}
|
|
49
119
|
|
|
120
|
+
function listGates() {
|
|
121
|
+
const { getAllGates } = require('./gate-runner.js');
|
|
122
|
+
const allGates = getAllGates();
|
|
123
|
+
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log('XP-Gate Quality Gates:');
|
|
126
|
+
console.log('┌──────┬──────────────────────────────┬──────────┬──────────────────────────────────────────┐');
|
|
127
|
+
console.log('│ Gate │ Name │ CLI │ Description │');
|
|
128
|
+
console.log('├──────┼──────────────────────────────┼──────────┼──────────────────────────────────────────┤');
|
|
129
|
+
for (const gate of allGates) {
|
|
130
|
+
const id = gate.id.padEnd(4);
|
|
131
|
+
const name = gate.name.slice(0, 28).padEnd(28);
|
|
132
|
+
const cli = gate.preCommitOnly ? 'commit' : 'check';
|
|
133
|
+
const desc = gate.description.slice(0, 40).padEnd(40);
|
|
134
|
+
console.log(`│ ${id} │ ${name} │ ${cli.padEnd(8)} │ ${desc} │`);
|
|
135
|
+
}
|
|
136
|
+
console.log('└──────┴──────────────────────────────┴──────────┴──────────────────────────────────────────┘');
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log('Legend:');
|
|
139
|
+
console.log(' check — invokable via xp-gate check <gate-id>');
|
|
140
|
+
console.log(' commit — only runs during git commit (pre-commit hook)');
|
|
141
|
+
console.log('');
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
50
145
|
module.exports = { check };
|
package/lib/doctor.js
CHANGED
|
@@ -263,6 +263,9 @@ function diagnose() {
|
|
|
263
263
|
// --- Check 8: TUI auto-registration ---
|
|
264
264
|
issues += diagnoseTuiRegistration(checks);
|
|
265
265
|
|
|
266
|
+
// --- Check 9: Installed skills version consistency (#332) ---
|
|
267
|
+
issues += diagnoseInstalledSkills(config, checks);
|
|
268
|
+
|
|
266
269
|
return { checks, issues };
|
|
267
270
|
}
|
|
268
271
|
|
|
@@ -689,6 +692,66 @@ function diagnoseTuiRegistration(checks) {
|
|
|
689
692
|
return 1;
|
|
690
693
|
}
|
|
691
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Check 9: Installed skills vs package-bundled skills (#332).
|
|
697
|
+
* Reports WARN for each installed skill whose SKILL.md differs from the bundled version.
|
|
698
|
+
* @param {object} config
|
|
699
|
+
* @param {Array} checks
|
|
700
|
+
* @returns {number} issue count
|
|
701
|
+
*/
|
|
702
|
+
function diagnoseInstalledSkills(config, checks) {
|
|
703
|
+
const installedSkills = config.installedSkills || {};
|
|
704
|
+
const skillNames = Object.keys(installedSkills);
|
|
705
|
+
if (skillNames.length === 0) {
|
|
706
|
+
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'No skills installed' });
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const bundledSkillsDir = path.join(PKG_DIR, 'skills');
|
|
711
|
+
if (!fs.existsSync(bundledSkillsDir)) {
|
|
712
|
+
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'Package skills dir not found' });
|
|
713
|
+
return 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const platform = detectPlatform();
|
|
717
|
+
let userSkillsDir;
|
|
718
|
+
if (platform === 'qoder') {
|
|
719
|
+
userSkillsDir = path.join(HOME_DIR, '.qoder', 'skills');
|
|
720
|
+
} else if (platform === 'claude-code') {
|
|
721
|
+
userSkillsDir = path.join(HOME_DIR, '.claude', 'skills');
|
|
722
|
+
} else {
|
|
723
|
+
userSkillsDir = path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let issues = 0;
|
|
727
|
+
for (const name of skillNames) {
|
|
728
|
+
const bundledSkillMd = path.join(bundledSkillsDir, name, 'SKILL.md');
|
|
729
|
+
const userSkillMd = path.join(userSkillsDir, name, 'SKILL.md');
|
|
730
|
+
|
|
731
|
+
if (!fs.existsSync(bundledSkillMd)) continue;
|
|
732
|
+
if (!fs.existsSync(userSkillMd)) {
|
|
733
|
+
checks.push({ name: `Skill: ${name}`, status: 'FAIL', detail: 'Installed SKILL.md missing' });
|
|
734
|
+
issues++;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const bundledContent = fs.readFileSync(bundledSkillMd, 'utf8');
|
|
739
|
+
const userContent = fs.readFileSync(userSkillMd, 'utf8');
|
|
740
|
+
if (bundledContent !== userContent) {
|
|
741
|
+
checks.push({
|
|
742
|
+
name: `Skill: ${name}`,
|
|
743
|
+
status: 'WARN',
|
|
744
|
+
detail: 'Outdated — run xp-gate update-skill --all',
|
|
745
|
+
});
|
|
746
|
+
issues++;
|
|
747
|
+
} else {
|
|
748
|
+
checks.push({ name: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return issues;
|
|
753
|
+
}
|
|
754
|
+
|
|
692
755
|
/**
|
|
693
756
|
* Fix TUI registration: ensure @boyingliu01/opencode-plugin/tui is in tui.json.
|
|
694
757
|
* Uses atomic write (tmp + renameSync) for JSON safety.
|