@boyingliu01/xp-gate 0.5.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/xp-gate.js +134 -4
- package/hooks/pre-push +11 -2
- package/lib/__tests__/detect-deps.test.js +34 -0
- package/lib/__tests__/gate-audit.test.ts +199 -0
- package/lib/__tests__/install-skill.test.js +45 -0
- package/lib/detect-deps.js +19 -6
- package/lib/gate-audit.ts +279 -0
- package/lib/install-skill.js +26 -15
- package/lib/ui-detector.ts +5 -5
- package/lib/ui-review.ts +22 -22
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/qoder/AGENTS.md +93 -0
- package/plugins/qoder/README.md +87 -0
- package/plugins/qoder/widgets/quality-report.html +163 -0
- package/plugins/qoder/widgets/sprint-dashboard.html +172 -0
- package/skills/delphi-review/SKILL.md +48 -0
- package/skills/delphi-review/references/qoder-multi-model.md +191 -0
- package/skills/ralph-loop/SKILL.md +40 -0
- package/skills/sprint-flow/SKILL.md +68 -0
- package/skills/sprint-flow/references/qoder-adaptation.md +173 -0
- package/skills/test-specification-alignment/SKILL.md +23 -0
package/bin/xp-gate.js
CHANGED
|
@@ -22,7 +22,7 @@ const COMMANDS = {
|
|
|
22
22
|
'install-skill': {
|
|
23
23
|
description: 'Install a xp-gate skill from GitHub',
|
|
24
24
|
fn: installSkill,
|
|
25
|
-
usage: 'xp-gate install-skill <name>[@<version>] [--offline] [--verbose] [--force]'
|
|
25
|
+
usage: 'xp-gate install-skill <name>[@<version>] [--offline] [--verbose] [--force] [--platform opencode|qoder]'
|
|
26
26
|
},
|
|
27
27
|
'update-skill': {
|
|
28
28
|
description: 'Update installed skill(s)',
|
|
@@ -53,6 +53,11 @@ const COMMANDS = {
|
|
|
53
53
|
description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
|
|
54
54
|
fn: null,
|
|
55
55
|
usage: 'xp-gate ui-review'
|
|
56
|
+
},
|
|
57
|
+
'audit': {
|
|
58
|
+
description: 'Gate audit logging (record, --tail, --stats)',
|
|
59
|
+
fn: null,
|
|
60
|
+
usage: 'xp-gate audit [--tail [N]|--stats|record --gate-id X --gate-name Y ...]'
|
|
56
61
|
}
|
|
57
62
|
};
|
|
58
63
|
|
|
@@ -98,7 +103,7 @@ function main() {
|
|
|
98
103
|
const name = subargs[0];
|
|
99
104
|
if (!name) {
|
|
100
105
|
console.error('Error: Skill name required');
|
|
101
|
-
console.error('Usage: xp-gate install-skill <name>[@<version>]');
|
|
106
|
+
console.error('Usage: xp-gate install-skill <name>[@<version>] [--platform opencode|qoder]');
|
|
102
107
|
process.exit(1);
|
|
103
108
|
return;
|
|
104
109
|
}
|
|
@@ -153,20 +158,145 @@ function main() {
|
|
|
153
158
|
process.exit(1);
|
|
154
159
|
}
|
|
155
160
|
}
|
|
161
|
+
|
|
162
|
+
if (command === 'audit') {
|
|
163
|
+
handleAudit(subargs);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
156
166
|
|
|
157
167
|
console.error(`Unknown command: ${command}`);
|
|
158
168
|
printHelp();
|
|
159
169
|
process.exit(1);
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
function handleAudit(args) {
|
|
173
|
+
const path = require('path');
|
|
174
|
+
const auditPath = path.join(__dirname, '..', 'lib', 'gate-audit.ts');
|
|
175
|
+
|
|
176
|
+
// --tail [N]
|
|
177
|
+
const tailIdx = args.indexOf('--tail');
|
|
178
|
+
if (tailIdx !== -1) {
|
|
179
|
+
const count = parseInt(args[tailIdx + 1] || '20', 10);
|
|
180
|
+
try {
|
|
181
|
+
const { readTailEntries } = require(auditPath);
|
|
182
|
+
const entries = readTailEntries(count);
|
|
183
|
+
if (entries.length === 0) {
|
|
184
|
+
console.log('No audit entries found.');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
printAuditTable(entries);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error('Error reading audit entries:', err.message);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --stats
|
|
196
|
+
if (args.includes('--stats')) {
|
|
197
|
+
try {
|
|
198
|
+
const { computeStats } = require(auditPath);
|
|
199
|
+
const stats = computeStats();
|
|
200
|
+
if (stats.length === 0) {
|
|
201
|
+
console.log('No audit data found.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
printStatsTable(stats);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error('Error computing stats:', err.message);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// record --gate-id X --gate-name Y --passed true/false ...
|
|
213
|
+
if (args[0] === 'record') {
|
|
214
|
+
try {
|
|
215
|
+
const { appendAuditEntry } = require(auditPath);
|
|
216
|
+
const rest = args.slice(1);
|
|
217
|
+
const opts = {};
|
|
218
|
+
for (let i = 0; i < rest.length; i++) {
|
|
219
|
+
if (rest[i].startsWith('--') && i + 1 < rest.length) {
|
|
220
|
+
opts[rest[i].slice(2)] = rest[i + 1];
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const entry = {
|
|
225
|
+
timestamp: new Date().toISOString(),
|
|
226
|
+
gate_id: opts['gate-id'] || 'unknown',
|
|
227
|
+
gate_name: opts['gate-name'] || 'unknown',
|
|
228
|
+
passed: opts['passed'] === 'true',
|
|
229
|
+
issues_found: parseInt(opts['issues-found'] || '0', 10),
|
|
230
|
+
duration_ms: parseInt(opts['duration-ms'] || '0', 10),
|
|
231
|
+
trigger: opts['trigger'] || 'manual',
|
|
232
|
+
repo_path: process.cwd(),
|
|
233
|
+
commit_hash: opts['commit-hash'] || 'HEAD',
|
|
234
|
+
};
|
|
235
|
+
appendAuditEntry(entry);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error('Error recording audit entry:', err.message);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.error('Error: Unknown audit subcommand or missing flag');
|
|
244
|
+
console.error('Usage:');
|
|
245
|
+
console.error(' xp-gate audit record --gate-id X --gate-name Y --passed true/false --issues-found N --duration-ms N --trigger commit');
|
|
246
|
+
console.error(' xp-gate audit --tail [N] (default: 20)');
|
|
247
|
+
console.error(' xp-gate audit --stats');
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function printAuditTable(entries) {
|
|
252
|
+
console.log('');
|
|
253
|
+
console.log('Recent Audit Entries:');
|
|
254
|
+
console.log('┌─────────────────────┬──────────┬─────────────────────┬────────┬────────┬───────────┬──────────┐');
|
|
255
|
+
console.log('│ Timestamp │ Gate │ Name │ Passed │ Issues │ Duration │ Trigger │');
|
|
256
|
+
console.log('├─────────────────────┼──────────┼─────────────────────┼────────┼────────┼───────────┼──────────┤');
|
|
257
|
+
for (const e of entries) {
|
|
258
|
+
const ts = e.timestamp ? e.timestamp.slice(0, 19) : 'N/A';
|
|
259
|
+
const gid = (e.gate_id || '').padEnd(8);
|
|
260
|
+
const gname = (e.gate_name || '').slice(0, 19).padEnd(19);
|
|
261
|
+
const pass = (e.passed ? 'Y' : 'N').padEnd(6);
|
|
262
|
+
const issues = String(e.issues_found ?? 0).padEnd(6);
|
|
263
|
+
const dur = String(e.duration_ms ?? 0).padEnd(9);
|
|
264
|
+
const trig = (e.trigger || 'manual').padEnd(8);
|
|
265
|
+
console.log(`│ ${ts} │ ${gid} │ ${gname} │ ${pass} │ ${issues} │ ${dur} │ ${trig} │`);
|
|
266
|
+
}
|
|
267
|
+
console.log('└─────────────────────┴──────────┴─────────────────────┴────────┴────────┴───────────┴──────────┘');
|
|
268
|
+
console.log(`Total: ${entries.length} entries`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function printStatsTable(stats) {
|
|
272
|
+
console.log('');
|
|
273
|
+
console.log('Gate Statistics:');
|
|
274
|
+
console.log('┌──────────────────┬────────┬────────┬────────────┐');
|
|
275
|
+
console.log('│ Gate │ Pass% │ Avg ms │ Issues Avg │');
|
|
276
|
+
console.log('├──────────────────┼────────┼────────┼────────────┤');
|
|
277
|
+
for (const s of stats) {
|
|
278
|
+
const gid = (s.gate_id || '').slice(0, 16).padEnd(16);
|
|
279
|
+
const pp = (s.pass_pct || 'N/A').padEnd(6);
|
|
280
|
+
const avg = String(s.avg_ms ?? 0).padEnd(6);
|
|
281
|
+
const iss = String(s.avg_issues ?? 0).padEnd(10);
|
|
282
|
+
console.log(`│ ${gid} │ ${pp} │ ${avg} │ ${iss} │`);
|
|
283
|
+
}
|
|
284
|
+
console.log('└──────────────────┴────────┴────────┴────────────┘');
|
|
285
|
+
}
|
|
286
|
+
|
|
162
287
|
function parseOptions(args) {
|
|
163
|
-
const options = { offline: false, verbose: false, force: false, all: false, check: false };
|
|
164
|
-
for (
|
|
288
|
+
const options = { offline: false, verbose: false, force: false, all: false, check: false, platform: 'opencode' };
|
|
289
|
+
for (let i = 0; i < args.length; i++) {
|
|
290
|
+
const arg = args[i];
|
|
165
291
|
if (arg === '--offline') options.offline = true;
|
|
166
292
|
if (arg === '--verbose') options.verbose = true;
|
|
167
293
|
if (arg === '--force') options.force = true;
|
|
168
294
|
if (arg === '--all') options.all = true;
|
|
169
295
|
if (arg === '--check') options.check = true;
|
|
296
|
+
if (arg === '--platform' && i + 1 < args.length) {
|
|
297
|
+
options.platform = args[i + 1];
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
170
300
|
}
|
|
171
301
|
return options;
|
|
172
302
|
}
|
package/hooks/pre-push
CHANGED
|
@@ -185,20 +185,29 @@ else
|
|
|
185
185
|
for test_file in $ALL_PUSHED_TEST_FILES; do
|
|
186
186
|
if [ -f "$test_file" ]; then
|
|
187
187
|
# Count mock keyword references (precise patterns only)
|
|
188
|
+
# Use grep -o piped to wc -l for reliable single-number output (grep -c can emit
|
|
189
|
+
# multi-line output with filenames under some grep versions, causing bash arithmetic
|
|
190
|
+
# syntax errors). grep -o extracts each match on its own line, wc -l counts them.
|
|
188
191
|
MOCK_COUNT=0
|
|
189
192
|
for kw in 'jest\.mock' 'vi\.mock' 'jest\.spyOn' 'vi\.spyOn' 'jest\.fn' 'vi\.fn' \
|
|
190
193
|
'mockResolvedValue' 'mockRejectedValue' 'mockReturnValue' 'mockImplementation' \
|
|
191
194
|
'createMock' 'mockReset' 'mockClear' 'mockRestore' 'MagicMock' 'unittest\.mock' \
|
|
192
|
-
'\.patch(' 'gomock' 'mockgen' '
|
|
193
|
-
c=$(grep -o
|
|
195
|
+
'\.patch(' 'gomock' 'mockgen' '\.EXPECT()'; do
|
|
196
|
+
c=$(grep -o "$kw" "$test_file" 2>/dev/null | wc -l || echo "0")
|
|
197
|
+
# Guard against empty/non-numeric values that break bash arithmetic
|
|
198
|
+
c=${c//[^0-9]/}
|
|
199
|
+
c=${c:-0}
|
|
194
200
|
MOCK_COUNT=$((MOCK_COUNT + c))
|
|
195
201
|
done
|
|
196
202
|
|
|
197
203
|
# Count total non-empty, non-comment lines for density denominator
|
|
198
204
|
TOTAL_LINES=$(grep -v '^\s*$' "$test_file" | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '^\s*#' | wc -l | awk '{print $1}')
|
|
205
|
+
TOTAL_LINES=${TOTAL_LINES//[^0-9]/}
|
|
206
|
+
TOTAL_LINES=${TOTAL_LINES:-0}
|
|
199
207
|
|
|
200
208
|
if [ "$TOTAL_LINES" -gt 0 ] 2>/dev/null; then
|
|
201
209
|
MOCK_DENSITY=$(awk "BEGIN {printf \"%.1f\", ($MOCK_COUNT / $TOTAL_LINES) * 100}")
|
|
210
|
+
MOCK_DENSITY=${MOCK_DENSITY:-"0"}
|
|
202
211
|
else
|
|
203
212
|
MOCK_DENSITY="0"
|
|
204
213
|
fi
|
|
@@ -206,4 +206,38 @@ describe('detect-deps', () => {
|
|
|
206
206
|
expect(result.ok).toBe(false);
|
|
207
207
|
expect(result.versionMismatch.found).toBe('0.0.1');
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// --- Qoder platform tests ---
|
|
211
|
+
|
|
212
|
+
it('returns ok:true immediately for qoder platform (no hard deps)', async () => {
|
|
213
|
+
// Qoder has no superpowers/gstack dependency — should pass even with empty dirs
|
|
214
|
+
const { checkDeps } = require('../detect-deps');
|
|
215
|
+
const result = await checkDeps('qoder');
|
|
216
|
+
expect(result.ok).toBe(true);
|
|
217
|
+
expect(result.platform).toBe('qoder');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('qoder platform passes even when superpowers/gstack are completely absent', async () => {
|
|
221
|
+
// No skill directories at all — qoder should still pass
|
|
222
|
+
const { checkDeps } = require('../detect-deps');
|
|
223
|
+
const result = await checkDeps('qoder');
|
|
224
|
+
expect(result.ok).toBe(true);
|
|
225
|
+
expect(result.missing).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('claude-code platform behaves like default opencode (requires deps)', async () => {
|
|
229
|
+
// claude-code platform profile requires the same deps as opencode
|
|
230
|
+
const { checkDeps } = require('../detect-deps');
|
|
231
|
+
const result = await checkDeps('claude-code');
|
|
232
|
+
expect(result.ok).toBe(false);
|
|
233
|
+
expect(result.missing).toBe('superpowers');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('unknown platform falls back to opencode profile', async () => {
|
|
237
|
+
const { checkDeps } = require('../detect-deps');
|
|
238
|
+
const result = await checkDeps('unknown-platform');
|
|
239
|
+
// Falls back to opencode profile → requires deps → fails
|
|
240
|
+
expect(result.ok).toBe(false);
|
|
241
|
+
expect(result.missing).toBe('superpowers');
|
|
242
|
+
});
|
|
209
243
|
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test Gate Audit Logger
|
|
3
|
+
* @intent Verify JSONL audit logging for quality gate executions
|
|
4
|
+
* @covers AUDIT-001-01, AUDIT-001-02, AUDIT-001-03, AUDIT-001-04, AUDIT-001-05, AUDIT-001-06
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import {
|
|
10
|
+
appendAuditEntry,
|
|
11
|
+
readTailEntries,
|
|
12
|
+
computeStats,
|
|
13
|
+
rotateIfNeeded,
|
|
14
|
+
type GateAuditEntry,
|
|
15
|
+
} from '../gate-audit';
|
|
16
|
+
|
|
17
|
+
const TEST_DIR = join(process.cwd(), '.xp-gate-test');
|
|
18
|
+
|
|
19
|
+
function makeEntry(overrides: Partial<GateAuditEntry> = {}): GateAuditEntry {
|
|
20
|
+
return {
|
|
21
|
+
timestamp: new Date().toISOString(),
|
|
22
|
+
gate_id: 'gate-1',
|
|
23
|
+
gate_name: 'code-quality',
|
|
24
|
+
passed: true,
|
|
25
|
+
issues_found: 0,
|
|
26
|
+
duration_ms: 100,
|
|
27
|
+
trigger: 'commit',
|
|
28
|
+
repo_path: TEST_DIR,
|
|
29
|
+
commit_hash: 'abc123',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('gate-audit', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
if (existsSync(TEST_DIR)) {
|
|
37
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
if (existsSync(TEST_DIR)) {
|
|
44
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('appendAuditEntry', () => {
|
|
49
|
+
it('should create .xp-gate/ directory on first write', () => {
|
|
50
|
+
const repoRoot = join(TEST_DIR, 'new-project');
|
|
51
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot }), repoRoot);
|
|
52
|
+
expect(existsSync(join(repoRoot, '.xp-gate'))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should write valid JSONL', () => {
|
|
56
|
+
const repoRoot = join(TEST_DIR, 'jsonl-test');
|
|
57
|
+
const entry = makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', gate_name: 'dup-code' });
|
|
58
|
+
appendAuditEntry(entry, repoRoot);
|
|
59
|
+
|
|
60
|
+
const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
|
|
61
|
+
expect(existsSync(logPath)).toBe(true);
|
|
62
|
+
|
|
63
|
+
const content = readFileSync(logPath, 'utf8').trim();
|
|
64
|
+
const parsed = JSON.parse(content) as GateAuditEntry;
|
|
65
|
+
expect(parsed.gate_id).toBe('gate-2');
|
|
66
|
+
expect(parsed.gate_name).toBe('dup-code');
|
|
67
|
+
expect(typeof parsed.timestamp).toBe('string');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should append multiple entries as separate lines', () => {
|
|
71
|
+
const repoRoot = join(TEST_DIR, 'multi-test');
|
|
72
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1' }), repoRoot);
|
|
73
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2' }), repoRoot);
|
|
74
|
+
|
|
75
|
+
const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
|
|
76
|
+
const lines = readFileSync(logPath, 'utf8').trim().split('\n');
|
|
77
|
+
expect(lines.length).toBe(2);
|
|
78
|
+
expect(JSON.parse(lines[0]).gate_id).toBe('gate-1');
|
|
79
|
+
expect(JSON.parse(lines[1]).gate_id).toBe('gate-2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should never throw even when filesystem is broken', () => {
|
|
83
|
+
const badPath = '/nonexistent/path/that/cannot/exist/for/real';
|
|
84
|
+
expect(() => appendAuditEntry(makeEntry({ repo_path: badPath }), badPath)).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('readTailEntries', () => {
|
|
89
|
+
it('should return last N entries', () => {
|
|
90
|
+
const repoRoot = join(TEST_DIR, 'tail-test');
|
|
91
|
+
for (let i = 1; i <= 30; i++) {
|
|
92
|
+
appendAuditEntry(
|
|
93
|
+
makeEntry({ repo_path: repoRoot, gate_id: `gate-${i % 3}`, duration_ms: i * 10 }),
|
|
94
|
+
repoRoot,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tail = readTailEntries(5, repoRoot);
|
|
99
|
+
expect(tail.length).toBe(5);
|
|
100
|
+
expect(tail[0].duration_ms).toBe(260);
|
|
101
|
+
expect(tail[4].duration_ms).toBe(300);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return empty array when log does not exist', () => {
|
|
105
|
+
const result = readTailEntries(10, join(TEST_DIR, 'empty-project'));
|
|
106
|
+
expect(result).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should default to 20 entries', () => {
|
|
110
|
+
const repoRoot = join(TEST_DIR, 'default-tail');
|
|
111
|
+
for (let i = 0; i < 50; i++) {
|
|
112
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, duration_ms: i }), repoRoot);
|
|
113
|
+
}
|
|
114
|
+
const tail = readTailEntries(undefined, repoRoot);
|
|
115
|
+
expect(tail.length).toBe(20);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('computeStats', () => {
|
|
120
|
+
it('should aggregate per-gate statistics correctly', () => {
|
|
121
|
+
const repoRoot = join(TEST_DIR, 'stats-test');
|
|
122
|
+
// gate-1: 3 runs, 2 passed, durations 100+200+300
|
|
123
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 100 }), repoRoot);
|
|
124
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 200 }), repoRoot);
|
|
125
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: false, duration_ms: 300 }), repoRoot);
|
|
126
|
+
// gate-2: 2 runs, 2 passed
|
|
127
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 50 }), repoRoot);
|
|
128
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 150 }), repoRoot);
|
|
129
|
+
|
|
130
|
+
const stats = computeStats(repoRoot);
|
|
131
|
+
expect(stats.length).toBe(2);
|
|
132
|
+
|
|
133
|
+
const g1 = stats.find(s => s.gate_id === 'gate-1')!;
|
|
134
|
+
expect(g1.pass_pct).toBe('66.7%');
|
|
135
|
+
expect(g1.avg_ms).toBe(200);
|
|
136
|
+
|
|
137
|
+
const g2 = stats.find(s => s.gate_id === 'gate-2')!;
|
|
138
|
+
expect(g2.pass_pct).toBe('100.0%');
|
|
139
|
+
expect(g2.avg_ms).toBe(100);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return empty array when no log exists', () => {
|
|
143
|
+
const result = computeStats(join(TEST_DIR, 'no-stats'));
|
|
144
|
+
expect(result).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should calculate avg_issues correctly', () => {
|
|
148
|
+
const repoRoot = join(TEST_DIR, 'issues-test');
|
|
149
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 5 }), repoRoot);
|
|
150
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 3 }), repoRoot);
|
|
151
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 0 }), repoRoot);
|
|
152
|
+
|
|
153
|
+
const stats = computeStats(repoRoot);
|
|
154
|
+
expect(stats[0].avg_issues).toBe(2.67);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('rotateIfNeeded', () => {
|
|
159
|
+
it('should not rotate when file is under 10MB', () => {
|
|
160
|
+
const logPath = join(TEST_DIR, 'small-log', '.xp-gate', 'audit.jsonl');
|
|
161
|
+
mkdirSync(join(TEST_DIR, 'small-log', '.xp-gate'), { recursive: true });
|
|
162
|
+
writeFileSync(logPath, 'small content', 'utf8');
|
|
163
|
+
|
|
164
|
+
rotateIfNeeded(logPath);
|
|
165
|
+
expect(existsSync(logPath)).toBe(true);
|
|
166
|
+
expect(existsSync(`${logPath}.1`)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should rotate when file exceeds 10MB', () => {
|
|
170
|
+
const logPath = join(TEST_DIR, 'big-log', '.xp-gate', 'audit.jsonl');
|
|
171
|
+
mkdirSync(join(TEST_DIR, 'big-log', '.xp-gate'), { recursive: true });
|
|
172
|
+
|
|
173
|
+
// Write > 10MB of data
|
|
174
|
+
const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
|
|
175
|
+
writeFileSync(logPath, bigContent, 'utf8');
|
|
176
|
+
|
|
177
|
+
rotateIfNeeded(logPath);
|
|
178
|
+
expect(existsSync(`${logPath}.1`)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should keep max 3 archives', () => {
|
|
182
|
+
const logPath = join(TEST_DIR, 'archive-test', '.xp-gate', 'audit.jsonl');
|
|
183
|
+
mkdirSync(join(TEST_DIR, 'archive-test', '.xp-gate'), { recursive: true });
|
|
184
|
+
const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
|
|
185
|
+
|
|
186
|
+
// Create 3 rotations
|
|
187
|
+
for (let i = 0; i < 3; i++) {
|
|
188
|
+
writeFileSync(logPath, `${bigContent} round ${i}`, 'utf8');
|
|
189
|
+
rotateIfNeeded(logPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Should have .1, .2, .3 but NOT .4
|
|
193
|
+
expect(existsSync(`${logPath}.1`)).toBe(true);
|
|
194
|
+
expect(existsSync(`${logPath}.2`)).toBe(true);
|
|
195
|
+
expect(existsSync(`${logPath}.3`)).toBe(true);
|
|
196
|
+
expect(existsSync(`${logPath}.4`)).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -323,4 +323,49 @@ describe('install-skill', () => {
|
|
|
323
323
|
expect(console.warn).not.toHaveBeenCalled();
|
|
324
324
|
expect(console.error).toHaveBeenCalledWith('Error: Failed to download test-spec');
|
|
325
325
|
});
|
|
326
|
+
|
|
327
|
+
// --- Qoder platform tests ---
|
|
328
|
+
|
|
329
|
+
function qoderSkillsDir() {
|
|
330
|
+
return path.join(tmpHome, '.qoder', 'skills');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
it('qoder platform skips dep check (installs without superpowers/gstack)', async () => {
|
|
334
|
+
// Do NOT call setupValidDeps() — no superpowers/gstack present
|
|
335
|
+
mockHttpsGet({ statusCode: 200, body: '# Sprint Flow Qoder' });
|
|
336
|
+
|
|
337
|
+
const { installSkill } = require('../install-skill');
|
|
338
|
+
const result = await installSkill('sprint-flow', { platform: 'qoder' });
|
|
339
|
+
|
|
340
|
+
expect(result).toBe(0);
|
|
341
|
+
expect(console.log).toHaveBeenCalledWith('✓ sprint-flow installed');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('qoder platform installs to ~/.qoder/skills/ instead of ~/.config/opencode/skills/', async () => {
|
|
345
|
+
mockHttpsGet({ statusCode: 200, body: '# Qoder Skill' });
|
|
346
|
+
|
|
347
|
+
const { installSkill } = require('../install-skill');
|
|
348
|
+
const result = await installSkill('sprint-flow', { platform: 'qoder' });
|
|
349
|
+
|
|
350
|
+
expect(result).toBe(0);
|
|
351
|
+
// Verify installed in Qoder directory
|
|
352
|
+
const qoderFile = path.join(qoderSkillsDir(), 'sprint-flow', 'SKILL.md');
|
|
353
|
+
expect(fs.existsSync(qoderFile)).toBe(true);
|
|
354
|
+
expect(fs.readFileSync(qoderFile, 'utf8')).toContain('Qoder Skill');
|
|
355
|
+
|
|
356
|
+
// Verify NOT installed in opencode directory
|
|
357
|
+
const opencodeFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
|
|
358
|
+
expect(fs.existsSync(opencodeFile)).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('default platform still requires deps and installs to opencode dir', async () => {
|
|
362
|
+
// Without setupValidDeps, default platform should fail on dep check
|
|
363
|
+
const { installSkill } = require('../install-skill');
|
|
364
|
+
const result = await installSkill('sprint-flow');
|
|
365
|
+
|
|
366
|
+
expect(result).toBe(1);
|
|
367
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
368
|
+
expect.stringContaining('superpowers is required')
|
|
369
|
+
);
|
|
370
|
+
});
|
|
326
371
|
});
|
package/lib/detect-deps.js
CHANGED
|
@@ -18,6 +18,14 @@ const REQUIRED_DEPS = [
|
|
|
18
18
|
{ name: 'gstack', minVersion: '1.0.0' }
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
+
// Platform-specific dependency profiles
|
|
22
|
+
// Qoder has no external skill dependencies (superpowers/gstack not required)
|
|
23
|
+
const PLATFORM_PROFILES = {
|
|
24
|
+
opencode: { requiredDeps: REQUIRED_DEPS, skillsDirs: [SKILLS_DIR, OPENCODE_DIR] },
|
|
25
|
+
qoder: { requiredDeps: [], skillsDirs: [path.join(HOME, '.qoder', 'skills')] },
|
|
26
|
+
'claude-code': { requiredDeps: REQUIRED_DEPS, skillsDirs: [SKILLS_DIR, OPENCODE_DIR] },
|
|
27
|
+
};
|
|
28
|
+
|
|
21
29
|
/**
|
|
22
30
|
* Check if bash is available on the system.
|
|
23
31
|
* XP-Gate hooks are bash scripts — Windows users need Git Bash installed.
|
|
@@ -84,12 +92,17 @@ function checkBash() {
|
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
async function checkDeps() {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
async function checkDeps(platform = 'opencode') {
|
|
96
|
+
const profile = PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.opencode;
|
|
97
|
+
const { requiredDeps, skillsDirs } = profile;
|
|
98
|
+
|
|
99
|
+
// Qoder and other platforms with no hard dependencies pass immediately
|
|
100
|
+
if (requiredDeps.length === 0) {
|
|
101
|
+
return { ok: true, platform };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const dep of requiredDeps) {
|
|
105
|
+
const possiblePaths = skillsDirs.map(dir => path.join(dir, dep.name));
|
|
93
106
|
|
|
94
107
|
let depDir = null;
|
|
95
108
|
for (const p of possiblePaths) {
|