@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.
@@ -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 = path.join(tmpHome, '.config', 'opencode', 'skills', skillName);
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
- // Create empty dirs (no metadata files)
151
- fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'superpowers'), {
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.0 treated as 1.0.0)', async () => {
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
  });