@boyingliu01/xp-gate 0.6.0 → 0.6.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/lib/__tests__/audit-log.test.ts +107 -0
- package/lib/__tests__/detect-deps.test.js +188 -17
- package/lib/__tests__/doctor.test.js +1 -0
- package/lib/__tests__/gate-audit.test.ts +59 -0
- package/lib/__tests__/init.test.js +15 -0
- package/lib/__tests__/install-skill.test.js +1 -0
- package/lib/__tests__/ui-detector.test.ts +112 -1
- package/lib/__tests__/ui-review.test.ts +119 -0
- package/lib/__tests__/uninstall.test.js +1 -0
- package/lib/detect-deps.js +181 -27
- package/lib/doctor.js +7 -9
- package/lib/gate-audit.ts +26 -42
- package/lib/init.js +42 -13
- package/lib/install-skill.js +6 -23
- package/lib/rollback.js +1 -16
- package/lib/shared-paths.js +30 -0
- package/lib/shared-utils.js +25 -0
- package/lib/ui-review.ts +58 -53
- package/lib/uninstall.js +8 -9
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/skills/delphi-review/SKILL.md +112 -29
- package/skills/ralph-loop/SKILL.md +179 -8
- package/skills/sprint-flow/SKILL.md +416 -25
- package/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
- package/skills/test-specification-alignment/SKILL.md +98 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test audit-log
|
|
3
|
+
* @intent Verify UI gate bypass audit logging and threshold reporting
|
|
4
|
+
* @covers AUDIT-LOG-001
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import {
|
|
10
|
+
appendAuditEntry,
|
|
11
|
+
readAllEntries,
|
|
12
|
+
countBypassesInWindow,
|
|
13
|
+
exceedsBypassThreshold,
|
|
14
|
+
formatRetroReport,
|
|
15
|
+
type AuditEntry,
|
|
16
|
+
} from '../audit-log';
|
|
17
|
+
|
|
18
|
+
const TEST_DIR = join(process.cwd(), '.audit-log-test');
|
|
19
|
+
|
|
20
|
+
function baseEntry(overrides: Partial<Omit<AuditEntry, 'gate_count'>> = {}): Omit<AuditEntry, 'gate_count'> {
|
|
21
|
+
return {
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
branch: 'main',
|
|
24
|
+
commit: 'abc123',
|
|
25
|
+
user: 'tester',
|
|
26
|
+
reason: 'manual approval for test',
|
|
27
|
+
bypass_type: 'ui-gates',
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('audit-log', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
if (existsSync(TEST_DIR)) {
|
|
35
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
if (existsSync(TEST_DIR)) {
|
|
42
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('appendAuditEntry and readAllEntries', () => {
|
|
47
|
+
it('should append an entry and compute gate_count', () => {
|
|
48
|
+
const first = appendAuditEntry(baseEntry(), TEST_DIR);
|
|
49
|
+
const second = appendAuditEntry(baseEntry({ commit: 'def456' }), TEST_DIR);
|
|
50
|
+
|
|
51
|
+
expect(first.gate_count).toBe(0);
|
|
52
|
+
expect(second.gate_count).toBe(1);
|
|
53
|
+
|
|
54
|
+
const entries = readAllEntries(join(TEST_DIR, '.audit-log.jsonl'));
|
|
55
|
+
expect(entries).toHaveLength(2);
|
|
56
|
+
expect(entries[1].commit).toBe('def456');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return empty array when log file is missing or empty', () => {
|
|
60
|
+
expect(readAllEntries(join(TEST_DIR, 'missing.jsonl'))).toEqual([]);
|
|
61
|
+
const emptyLog = join(TEST_DIR, '.audit-log.jsonl');
|
|
62
|
+
writeFileSync(emptyLog, '', 'utf8');
|
|
63
|
+
expect(readAllEntries(emptyLog)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('rolling window calculations', () => {
|
|
68
|
+
it('should count only matching bypass type in rolling window', () => {
|
|
69
|
+
const now = new Date();
|
|
70
|
+
const old = new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000).toISOString();
|
|
71
|
+
const entries: AuditEntry[] = [
|
|
72
|
+
{ ...baseEntry({ timestamp: now.toISOString(), bypass_type: 'ui-gates' }), gate_count: 0 },
|
|
73
|
+
{ ...baseEntry({ timestamp: now.toISOString(), bypass_type: 'other' }), gate_count: 0 },
|
|
74
|
+
{ ...baseEntry({ timestamp: old, bypass_type: 'ui-gates' }), gate_count: 0 },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
expect(countBypassesInWindow(entries, 'ui-gates')).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should report threshold exceedance', () => {
|
|
81
|
+
const entries: AuditEntry[] = Array.from({ length: 4 }, (_, i) => ({
|
|
82
|
+
...baseEntry({ commit: `commit-${i}` }),
|
|
83
|
+
gate_count: i,
|
|
84
|
+
}));
|
|
85
|
+
expect(exceedsBypassThreshold(entries, 'ui-gates')).toEqual({ exceeded: true, count: 4 });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('formatRetroReport', () => {
|
|
90
|
+
it('should include recent bypasses and threshold warning', () => {
|
|
91
|
+
const entries: AuditEntry[] = Array.from({ length: 4 }, (_, i) => ({
|
|
92
|
+
...baseEntry({ reason: `reason-${i}`, branch: `branch-${i}` }),
|
|
93
|
+
gate_count: i,
|
|
94
|
+
}));
|
|
95
|
+
const report = formatRetroReport(entries, 'ui-gates');
|
|
96
|
+
expect(report).toContain('Last 30 days: 4 bypasses');
|
|
97
|
+
expect(report).toContain('EXCEEDS threshold');
|
|
98
|
+
expect(report).toContain('reason-3');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should omit recent section when no matching bypasses exist', () => {
|
|
102
|
+
const report = formatRetroReport([], 'ui-gates');
|
|
103
|
+
expect(report).toContain('Last 30 days: 0 bypasses');
|
|
104
|
+
expect(report).not.toContain('Recent bypasses');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @test detect-deps
|
|
3
|
-
* @intent Verify checkDeps() correctly detects missing/present/outdated dependencies
|
|
3
|
+
* @intent Verify checkDeps() correctly detects missing/present/outdated dependencies across platforms,
|
|
4
|
+
* detectPlatform() identifies the correct AI agent platform,
|
|
5
|
+
* and autoInstallDeps() handles install scenarios
|
|
4
6
|
*/
|
|
5
7
|
const fs = require('fs');
|
|
6
8
|
const path = require('path');
|
|
@@ -16,6 +18,7 @@ describe('detect-deps', () => {
|
|
|
16
18
|
process.env.HOME = tmpHome;
|
|
17
19
|
vi.resetModules();
|
|
18
20
|
delete require.cache[require.resolve('../detect-deps')];
|
|
21
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
19
22
|
});
|
|
20
23
|
|
|
21
24
|
afterEach(() => {
|
|
@@ -24,8 +27,10 @@ describe('detect-deps', () => {
|
|
|
24
27
|
vi.restoreAllMocks();
|
|
25
28
|
});
|
|
26
29
|
|
|
27
|
-
function makeSkillDir(skillName, contents = {}) {
|
|
28
|
-
const dir =
|
|
30
|
+
function makeSkillDir(skillName, contents = {}, baseDir) {
|
|
31
|
+
const dir = baseDir
|
|
32
|
+
? path.join(baseDir, skillName)
|
|
33
|
+
: path.join(tmpHome, '.config', 'opencode', 'skills', skillName);
|
|
29
34
|
fs.mkdirSync(dir, { recursive: true });
|
|
30
35
|
if (contents.packageJson) {
|
|
31
36
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(contents.packageJson));
|
|
@@ -48,6 +53,8 @@ describe('detect-deps', () => {
|
|
|
48
53
|
return dir;
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
// ── checkDeps: backward-compatible tests (default platform = opencode) ──
|
|
57
|
+
|
|
51
58
|
it('returns ok:false missing:superpowers when no deps exist', async () => {
|
|
52
59
|
const { checkDeps } = require('../detect-deps');
|
|
53
60
|
const result = await checkDeps();
|
|
@@ -127,7 +134,6 @@ describe('detect-deps', () => {
|
|
|
127
134
|
});
|
|
128
135
|
|
|
129
136
|
it('returns ok:true (no version check) when package.json has no version and no SKILL.md', async () => {
|
|
130
|
-
// getSkillVersion returns null → skips version check → ok
|
|
131
137
|
makeSkillDir('superpowers', { packageJson: {} });
|
|
132
138
|
makeSkillDir('gstack', { packageJson: {} });
|
|
133
139
|
const { checkDeps } = require('../detect-deps');
|
|
@@ -147,13 +153,8 @@ describe('detect-deps', () => {
|
|
|
147
153
|
});
|
|
148
154
|
|
|
149
155
|
it('returns null version when neither package.json nor SKILL.md exist (skips version check)', async () => {
|
|
150
|
-
|
|
151
|
-
fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', '
|
|
152
|
-
recursive: true,
|
|
153
|
-
});
|
|
154
|
-
fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'gstack'), {
|
|
155
|
-
recursive: true,
|
|
156
|
-
});
|
|
156
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'superpowers'), { recursive: true });
|
|
157
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'gstack'), { recursive: true });
|
|
157
158
|
const { checkDeps } = require('../detect-deps');
|
|
158
159
|
const result = await checkDeps();
|
|
159
160
|
expect(result.ok).toBe(true);
|
|
@@ -164,17 +165,14 @@ describe('detect-deps', () => {
|
|
|
164
165
|
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
|
|
165
166
|
const { checkDeps } = require('../detect-deps');
|
|
166
167
|
const result = await checkDeps();
|
|
167
|
-
// superpowers version=null → skips version check → ok
|
|
168
168
|
expect(result.ok).toBe(true);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
it('compareVersions handles partial versions (e.g. 1
|
|
172
|
-
// version "1.0" in SKILL.md regex requires X.Y.Z so won't match; use package.json
|
|
171
|
+
it('compareVersions handles partial versions (e.g. 1 treated as 1.0.0)', async () => {
|
|
173
172
|
makeSkillDir('superpowers', { packageJson: { version: '1' } });
|
|
174
173
|
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
|
|
175
174
|
const { checkDeps } = require('../detect-deps');
|
|
176
175
|
const result = await checkDeps();
|
|
177
|
-
// '1' parsed as [1] vs [1,0,0] → equal at index 0; index 1: 0 vs 0; index 2: 0 vs 0 → equal → passes
|
|
178
176
|
expect(result.ok).toBe(true);
|
|
179
177
|
});
|
|
180
178
|
|
|
@@ -196,14 +194,187 @@ describe('detect-deps', () => {
|
|
|
196
194
|
});
|
|
197
195
|
|
|
198
196
|
it('prefers SKILLS_DIR over OPENCODE_DIR when both exist', async () => {
|
|
199
|
-
// Put low version in SKILLS_DIR, high in OPENCODE_DIR
|
|
200
197
|
makeSkillDir('superpowers', { packageJson: { version: '0.0.1' } });
|
|
201
198
|
makeOpencodeDir('superpowers', { packageJson: { version: '2.0.0' } });
|
|
202
199
|
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
|
|
203
200
|
const { checkDeps } = require('../detect-deps');
|
|
204
201
|
const result = await checkDeps();
|
|
205
|
-
// Should use SKILLS_DIR (first in possiblePaths) → 0.0.1 fails
|
|
206
202
|
expect(result.ok).toBe(false);
|
|
207
203
|
expect(result.versionMismatch.found).toBe('0.0.1');
|
|
208
204
|
});
|
|
205
|
+
|
|
206
|
+
// ── Platform-specific tests (Issue #128) ──
|
|
207
|
+
|
|
208
|
+
describe('checkDeps with platform parameter', () => {
|
|
209
|
+
it('qoder: checks ~/.qoder/skills/ for dependencies', async () => {
|
|
210
|
+
const qoderSkills = path.join(tmpHome, '.qoder', 'skills');
|
|
211
|
+
makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } }, path.join(qoderSkills, 'superpowers'));
|
|
212
|
+
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } }, path.join(qoderSkills, 'gstack'));
|
|
213
|
+
const { checkDeps } = require('../detect-deps');
|
|
214
|
+
const result = await checkDeps('qoder');
|
|
215
|
+
expect(result.ok).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('qoder: returns missing when qoder skills dir is empty', async () => {
|
|
219
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
|
|
220
|
+
const { checkDeps } = require('../detect-deps');
|
|
221
|
+
const result = await checkDeps('qoder');
|
|
222
|
+
expect(result.ok).toBe(false);
|
|
223
|
+
expect(result.missing).toBe('superpowers');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('claude-code: checks ~/.claude/skills/ for dependencies', async () => {
|
|
227
|
+
const claudeSkills = path.join(tmpHome, '.claude', 'skills');
|
|
228
|
+
makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } }, path.join(claudeSkills, 'superpowers'));
|
|
229
|
+
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } }, path.join(claudeSkills, 'gstack'));
|
|
230
|
+
const { checkDeps } = require('../detect-deps');
|
|
231
|
+
const result = await checkDeps('claude-code');
|
|
232
|
+
expect(result.ok).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('claude-code: returns missing when claude skills dir is empty', async () => {
|
|
236
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
|
|
237
|
+
const { checkDeps } = require('../detect-deps');
|
|
238
|
+
const result = await checkDeps('claude-code');
|
|
239
|
+
expect(result.ok).toBe(false);
|
|
240
|
+
expect(result.missing).toBe('superpowers');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('unknown platform falls back to opencode profile', async () => {
|
|
244
|
+
makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } });
|
|
245
|
+
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
|
|
246
|
+
const { checkDeps } = require('../detect-deps');
|
|
247
|
+
const result = await checkDeps('unknown-platform');
|
|
248
|
+
expect(result.ok).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('qoder platform does NOT find deps in opencode dir', async () => {
|
|
252
|
+
// Put deps in opencode dir but NOT in qoder dir
|
|
253
|
+
makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } });
|
|
254
|
+
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
|
|
255
|
+
const { checkDeps } = require('../detect-deps');
|
|
256
|
+
const result = await checkDeps('qoder');
|
|
257
|
+
expect(result.ok).toBe(false);
|
|
258
|
+
expect(result.missing).toBe('superpowers');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── detectPlatform tests ──
|
|
263
|
+
|
|
264
|
+
describe('detectPlatform', () => {
|
|
265
|
+
it('returns "qoder" when ~/.qoder/skills/ exists', () => {
|
|
266
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
|
|
267
|
+
const { detectPlatform } = require('../detect-deps');
|
|
268
|
+
expect(detectPlatform()).toBe('qoder');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('returns "claude-code" when ~/.claude/skills/ exists', () => {
|
|
272
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
|
|
273
|
+
const { detectPlatform } = require('../detect-deps');
|
|
274
|
+
expect(detectPlatform()).toBe('claude-code');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('returns "opencode" as default when no platform dirs exist', () => {
|
|
278
|
+
const { detectPlatform } = require('../detect-deps');
|
|
279
|
+
expect(detectPlatform()).toBe('opencode');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('prefers qoder over claude-code when both exist', () => {
|
|
283
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
|
|
284
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
|
|
285
|
+
const { detectPlatform } = require('../detect-deps');
|
|
286
|
+
expect(detectPlatform()).toBe('qoder');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── getSkillsDirs tests ──
|
|
291
|
+
|
|
292
|
+
describe('getSkillsDirs', () => {
|
|
293
|
+
it('returns opencode paths for opencode platform', () => {
|
|
294
|
+
const { getSkillsDirs } = require('../detect-deps');
|
|
295
|
+
const dirs = getSkillsDirs('opencode');
|
|
296
|
+
expect(dirs[0]).toContain('.config');
|
|
297
|
+
expect(dirs[0]).toContain('opencode');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('returns qoder paths for qoder platform', () => {
|
|
301
|
+
const { getSkillsDirs } = require('../detect-deps');
|
|
302
|
+
const dirs = getSkillsDirs('qoder');
|
|
303
|
+
expect(dirs[0]).toContain('.qoder');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('returns claude-code paths for claude-code platform', () => {
|
|
307
|
+
const { getSkillsDirs } = require('../detect-deps');
|
|
308
|
+
const dirs = getSkillsDirs('claude-code');
|
|
309
|
+
expect(dirs[0]).toContain('.claude');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── PLATFORM_PROFILES tests ──
|
|
314
|
+
|
|
315
|
+
describe('PLATFORM_PROFILES', () => {
|
|
316
|
+
it('all platforms have requiredDeps defined', () => {
|
|
317
|
+
const { PLATFORM_PROFILES } = require('../detect-deps');
|
|
318
|
+
expect(PLATFORM_PROFILES.opencode.requiredDeps.length).toBe(2);
|
|
319
|
+
expect(PLATFORM_PROFILES.qoder.requiredDeps.length).toBe(2);
|
|
320
|
+
expect(PLATFORM_PROFILES['claude-code'].requiredDeps.length).toBe(2);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('all platforms have skillsDirs defined', () => {
|
|
324
|
+
const { PLATFORM_PROFILES } = require('../detect-deps');
|
|
325
|
+
expect(PLATFORM_PROFILES.opencode.skillsDirs.length).toBeGreaterThan(0);
|
|
326
|
+
expect(PLATFORM_PROFILES.qoder.skillsDirs.length).toBeGreaterThan(0);
|
|
327
|
+
expect(PLATFORM_PROFILES['claude-code'].skillsDirs.length).toBeGreaterThan(0);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── autoInstallDeps tests ──
|
|
332
|
+
|
|
333
|
+
describe('autoInstallDeps', () => {
|
|
334
|
+
it('returns ok:true with empty installed when deps already exist', async () => {
|
|
335
|
+
makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } });
|
|
336
|
+
makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
|
|
337
|
+
const { autoInstallDeps } = require('../detect-deps');
|
|
338
|
+
const result = await autoInstallDeps();
|
|
339
|
+
expect(result.ok).toBe(true);
|
|
340
|
+
expect(result.installed).toEqual([]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('creates skills directory if it does not exist', async () => {
|
|
344
|
+
const { autoInstallDeps } = require('../detect-deps');
|
|
345
|
+
// Mock execSync to avoid actual git clone
|
|
346
|
+
const { execSync } = require('child_process');
|
|
347
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
|
|
348
|
+
// Simulate successful clone by creating the target dir
|
|
349
|
+
const match = cmd.match(/"([^"]+)"$/);
|
|
350
|
+
if (match) {
|
|
351
|
+
fs.mkdirSync(match[1], { recursive: true });
|
|
352
|
+
fs.writeFileSync(path.join(match[1], 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
|
353
|
+
}
|
|
354
|
+
return '';
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const result = await autoInstallDeps();
|
|
358
|
+
// Should have attempted to install
|
|
359
|
+
expect(result.ok).toBe(true);
|
|
360
|
+
expect(result.installed).toContain('superpowers');
|
|
361
|
+
expect(result.installed).toContain('gstack');
|
|
362
|
+
|
|
363
|
+
vi.restoreAllMocks();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('returns errors when git clone fails', async () => {
|
|
367
|
+
const { autoInstallDeps } = require('../detect-deps');
|
|
368
|
+
vi.spyOn(require('child_process'), 'execSync').mockImplementation(() => {
|
|
369
|
+
throw new Error('git not found');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const result = await autoInstallDeps();
|
|
373
|
+
expect(result.ok).toBe(false);
|
|
374
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
375
|
+
expect(result.errors[0].message).toContain('git not found');
|
|
376
|
+
|
|
377
|
+
vi.restoreAllMocks();
|
|
378
|
+
});
|
|
379
|
+
});
|
|
209
380
|
});
|
|
@@ -27,6 +27,7 @@ describe('doctor', () => {
|
|
|
27
27
|
delete require.cache[require.resolve('../uninstall')];
|
|
28
28
|
delete require.cache[require.resolve('../init')];
|
|
29
29
|
delete require.cache[require.resolve('../detect-deps.js')];
|
|
30
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
30
31
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
31
32
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
32
33
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
readTailEntries,
|
|
12
12
|
computeStats,
|
|
13
13
|
rotateIfNeeded,
|
|
14
|
+
parseCliOptions,
|
|
15
|
+
getCommitHash,
|
|
14
16
|
type GateAuditEntry,
|
|
15
17
|
} from '../gate-audit';
|
|
16
18
|
|
|
@@ -114,6 +116,28 @@ describe('gate-audit', () => {
|
|
|
114
116
|
const tail = readTailEntries(undefined, repoRoot);
|
|
115
117
|
expect(tail.length).toBe(20);
|
|
116
118
|
});
|
|
119
|
+
|
|
120
|
+
it('should skip malformed JSON lines', () => {
|
|
121
|
+
const repoRoot = join(TEST_DIR, 'malformed-tail');
|
|
122
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
123
|
+
mkdirSync(logDir, { recursive: true });
|
|
124
|
+
writeFileSync(
|
|
125
|
+
join(logDir, 'audit.jsonl'),
|
|
126
|
+
`not-json\n${JSON.stringify(makeEntry({ repo_path: repoRoot, gate_id: 'gate-valid' }))}\n`,
|
|
127
|
+
'utf8',
|
|
128
|
+
);
|
|
129
|
+
const tail = readTailEntries(10, repoRoot);
|
|
130
|
+
expect(tail.length).toBe(1);
|
|
131
|
+
expect(tail[0].gate_id).toBe('gate-valid');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return empty array for empty log file', () => {
|
|
135
|
+
const repoRoot = join(TEST_DIR, 'empty-log');
|
|
136
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
137
|
+
mkdirSync(logDir, { recursive: true });
|
|
138
|
+
writeFileSync(join(logDir, 'audit.jsonl'), '', 'utf8');
|
|
139
|
+
expect(readTailEntries(10, repoRoot)).toEqual([]);
|
|
140
|
+
});
|
|
117
141
|
});
|
|
118
142
|
|
|
119
143
|
describe('computeStats', () => {
|
|
@@ -153,6 +177,41 @@ describe('gate-audit', () => {
|
|
|
153
177
|
const stats = computeStats(repoRoot);
|
|
154
178
|
expect(stats[0].avg_issues).toBe(2.67);
|
|
155
179
|
});
|
|
180
|
+
|
|
181
|
+
it('should return empty array for empty stats log', () => {
|
|
182
|
+
const repoRoot = join(TEST_DIR, 'empty-stats-log');
|
|
183
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
184
|
+
mkdirSync(logDir, { recursive: true });
|
|
185
|
+
writeFileSync(join(logDir, 'audit.jsonl'), '', 'utf8');
|
|
186
|
+
expect(computeStats(repoRoot)).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should skip malformed lines during stats aggregation', () => {
|
|
190
|
+
const repoRoot = join(TEST_DIR, 'malformed-stats');
|
|
191
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
192
|
+
mkdirSync(logDir, { recursive: true });
|
|
193
|
+
writeFileSync(
|
|
194
|
+
join(logDir, 'audit.jsonl'),
|
|
195
|
+
`${JSON.stringify(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1' }))}\n{bad json}\n`,
|
|
196
|
+
'utf8',
|
|
197
|
+
);
|
|
198
|
+
const stats = computeStats(repoRoot);
|
|
199
|
+
expect(stats).toHaveLength(1);
|
|
200
|
+
expect(stats[0].gate_id).toBe('gate-1');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('CLI helpers', () => {
|
|
205
|
+
it('should parse CLI options and skip dangling flags', () => {
|
|
206
|
+
expect(parseCliOptions(['--gate-id', 'gate-1', '--passed', 'true', '--dangling'])).toEqual({
|
|
207
|
+
'gate-id': 'gate-1',
|
|
208
|
+
passed: 'true',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should return a commit hash string or unknown', () => {
|
|
213
|
+
expect(typeof getCommitHash()).toBe('string');
|
|
214
|
+
});
|
|
156
215
|
});
|
|
157
216
|
|
|
158
217
|
describe('rotateIfNeeded', () => {
|
|
@@ -24,6 +24,7 @@ describe('init', () => {
|
|
|
24
24
|
vi.resetModules();
|
|
25
25
|
delete require.cache[require.resolve('../init')];
|
|
26
26
|
delete require.cache[require.resolve('../detect-deps.js')];
|
|
27
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
27
28
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
28
29
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
29
30
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
@@ -102,6 +103,13 @@ describe('init', () => {
|
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
it('init([]) with missing superpowers warns Missing dependencies', async () => {
|
|
106
|
+
// Mock execSync to make auto-install fail immediately
|
|
107
|
+
vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
108
|
+
if (typeof cmd === 'string' && cmd.includes('git clone')) {
|
|
109
|
+
throw new Error('git clone not available in test');
|
|
110
|
+
}
|
|
111
|
+
return '';
|
|
112
|
+
});
|
|
105
113
|
const { init } = require('../init');
|
|
106
114
|
const result = await init([]);
|
|
107
115
|
expect(result).toBe(0);
|
|
@@ -110,6 +118,13 @@ describe('init', () => {
|
|
|
110
118
|
});
|
|
111
119
|
|
|
112
120
|
it('init([]) with versionMismatch warns version detail', async () => {
|
|
121
|
+
// Mock execSync to make auto-install fail immediately
|
|
122
|
+
vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
123
|
+
if (typeof cmd === 'string' && cmd.includes('git clone')) {
|
|
124
|
+
throw new Error('git clone not available in test');
|
|
125
|
+
}
|
|
126
|
+
return '';
|
|
127
|
+
});
|
|
113
128
|
// superpowers too old, gstack good
|
|
114
129
|
const sp = path.join(skillsDir(), 'superpowers');
|
|
115
130
|
fs.mkdirSync(sp, { recursive: true });
|
|
@@ -69,6 +69,7 @@ describe('install-skill', () => {
|
|
|
69
69
|
vi.resetModules();
|
|
70
70
|
delete require.cache[require.resolve('../install-skill')];
|
|
71
71
|
delete require.cache[require.resolve('../detect-deps')];
|
|
72
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
72
73
|
delete require.cache[require.resolve('../download-skill')];
|
|
73
74
|
delete require.cache[require.resolve('../rollback')];
|
|
74
75
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -114,7 +114,63 @@ describe('ui-detector', () => {
|
|
|
114
114
|
it('should handle git command failure gracefully', async () => {
|
|
115
115
|
mockExecSync.mockImplementation(() => {
|
|
116
116
|
throw new Error('git not available');
|
|
117
|
-
|
|
117
|
+
describe('collectUiMatches and detectUiSprint', () => {
|
|
118
|
+
it('should detect UI files in components directory', async () => {
|
|
119
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
120
|
+
const result = collectUiMatches(['src/components/Button.tsx', 'src/utils/helper.ts']);
|
|
121
|
+
expect(result.isUiSprint).toBe(true);
|
|
122
|
+
expect(result.matchedFiles).toEqual(['src/components/Button.tsx']);
|
|
123
|
+
expect(result.matchedRules.length).toBeGreaterThan(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should exclude test files from matching', async () => {
|
|
127
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
128
|
+
const result = collectUiMatches(['src/components/Button.test.tsx']);
|
|
129
|
+
expect(result.isUiSprint).toBe(false);
|
|
130
|
+
expect(result.matchedFiles).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should exclude coverage directory files', async () => {
|
|
134
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
135
|
+
const result = collectUiMatches(['coverage/components/Coverage.tsx']);
|
|
136
|
+
expect(result.isUiSprint).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should handle empty file list', async () => {
|
|
140
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
141
|
+
const result = collectUiMatches([]);
|
|
142
|
+
expect(result.isUiSprint).toBe(false);
|
|
143
|
+
expect(result.matchedFiles).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should collect multiple UI files', async () => {
|
|
147
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
148
|
+
const result = collectUiMatches([
|
|
149
|
+
'src/components/Header.tsx',
|
|
150
|
+
'src/utils/api.ts',
|
|
151
|
+
'views/styles/app.css',
|
|
152
|
+
]);
|
|
153
|
+
expect(result.isUiSprint).toBe(true);
|
|
154
|
+
expect(result.matchedFiles).toHaveLength(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should aggregate unique rules across files', async () => {
|
|
158
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
159
|
+
const result = collectUiMatches([
|
|
160
|
+
'src/components/Button.tsx',
|
|
161
|
+
'src/components/Card.tsx',
|
|
162
|
+
]);
|
|
163
|
+
expect(result.isUiSprint).toBe(true);
|
|
164
|
+
expect(result.matchedFiles.length).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should respect .ui-gate-ignore patterns', async () => {
|
|
168
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
169
|
+
const withoutIgnore = collectUiMatches(['legacy/components/Old.tsx']);
|
|
170
|
+
expect(withoutIgnore.isUiSprint).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
118
174
|
const { detectUiSprint } = await import('../ui-detector');
|
|
119
175
|
const result = detectUiSprint();
|
|
120
176
|
expect(result.isUiSprint).toBe(false);
|
|
@@ -197,4 +253,59 @@ describe('ui-detector', () => {
|
|
|
197
253
|
expect(rules).toContain('style-.css');
|
|
198
254
|
});
|
|
199
255
|
});
|
|
256
|
+
|
|
257
|
+
describe('collectUiMatches', () => {
|
|
258
|
+
it('should detect UI files in components directory', async () => {
|
|
259
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
260
|
+
const result = collectUiMatches(['src/components/Button.tsx', 'src/utils/helper.ts']);
|
|
261
|
+
expect(result.isUiSprint).toBe(true);
|
|
262
|
+
expect(result.matchedFiles).toEqual(['src/components/Button.tsx']);
|
|
263
|
+
expect(result.matchedRules.length).toBeGreaterThan(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should exclude __tests__ directory files', async () => {
|
|
267
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
268
|
+
const result = collectUiMatches(['src/__tests__/Button.test.tsx']);
|
|
269
|
+
expect(result.isUiSprint).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should exclude src/__snapshots__ directory files', async () => {
|
|
273
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
274
|
+
const result = collectUiMatches(['src/__snapshots__/Button.test.tsx.snap']);
|
|
275
|
+
expect(result.isUiSprint).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should handle empty file list', async () => {
|
|
279
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
280
|
+
const result = collectUiMatches([]);
|
|
281
|
+
expect(result.isUiSprint).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should collect multiple UI files', async () => {
|
|
285
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
286
|
+
const result = collectUiMatches([
|
|
287
|
+
'src/components/Header.tsx',
|
|
288
|
+
'src/utils/api.ts',
|
|
289
|
+
'views/styles/app.css',
|
|
290
|
+
]);
|
|
291
|
+
expect(result.isUiSprint).toBe(true);
|
|
292
|
+
expect(result.matchedFiles).toHaveLength(2);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should collect unique rules across files', async () => {
|
|
296
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
297
|
+
const result = collectUiMatches([
|
|
298
|
+
'src/components/Button.tsx',
|
|
299
|
+
'src/components/Card.tsx',
|
|
300
|
+
]);
|
|
301
|
+
expect(result.isUiSprint).toBe(true);
|
|
302
|
+
expect(result.matchedFiles.length).toBeGreaterThan(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should respect .ui-gate-ignore patterns', async () => {
|
|
306
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
307
|
+
const withoutIgnore = collectUiMatches(['legacy/components/Old.tsx']);
|
|
308
|
+
expect(withoutIgnore.isUiSprint).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
200
311
|
});
|