@boyingliu01/xp-gate 0.5.3 → 0.6.1

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 CHANGED
@@ -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
 
@@ -153,12 +158,132 @@ 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
288
  const options = { offline: false, verbose: false, force: false, all: false, check: false };
164
289
  for (const arg of args) {
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' '.EXPECT()'; do
193
- c=$(grep -o -c "$kw" "$test_file" 2>/dev/null || echo "0")
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
@@ -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
+ });
@@ -0,0 +1,258 @@
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
+ parseCliOptions,
15
+ getCommitHash,
16
+ type GateAuditEntry,
17
+ } from '../gate-audit';
18
+
19
+ const TEST_DIR = join(process.cwd(), '.xp-gate-test');
20
+
21
+ function makeEntry(overrides: Partial<GateAuditEntry> = {}): GateAuditEntry {
22
+ return {
23
+ timestamp: new Date().toISOString(),
24
+ gate_id: 'gate-1',
25
+ gate_name: 'code-quality',
26
+ passed: true,
27
+ issues_found: 0,
28
+ duration_ms: 100,
29
+ trigger: 'commit',
30
+ repo_path: TEST_DIR,
31
+ commit_hash: 'abc123',
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe('gate-audit', () => {
37
+ beforeEach(() => {
38
+ if (existsSync(TEST_DIR)) {
39
+ rmSync(TEST_DIR, { recursive: true, force: true });
40
+ }
41
+ mkdirSync(TEST_DIR, { recursive: true });
42
+ });
43
+
44
+ afterEach(() => {
45
+ if (existsSync(TEST_DIR)) {
46
+ rmSync(TEST_DIR, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ describe('appendAuditEntry', () => {
51
+ it('should create .xp-gate/ directory on first write', () => {
52
+ const repoRoot = join(TEST_DIR, 'new-project');
53
+ appendAuditEntry(makeEntry({ repo_path: repoRoot }), repoRoot);
54
+ expect(existsSync(join(repoRoot, '.xp-gate'))).toBe(true);
55
+ });
56
+
57
+ it('should write valid JSONL', () => {
58
+ const repoRoot = join(TEST_DIR, 'jsonl-test');
59
+ const entry = makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', gate_name: 'dup-code' });
60
+ appendAuditEntry(entry, repoRoot);
61
+
62
+ const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
63
+ expect(existsSync(logPath)).toBe(true);
64
+
65
+ const content = readFileSync(logPath, 'utf8').trim();
66
+ const parsed = JSON.parse(content) as GateAuditEntry;
67
+ expect(parsed.gate_id).toBe('gate-2');
68
+ expect(parsed.gate_name).toBe('dup-code');
69
+ expect(typeof parsed.timestamp).toBe('string');
70
+ });
71
+
72
+ it('should append multiple entries as separate lines', () => {
73
+ const repoRoot = join(TEST_DIR, 'multi-test');
74
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1' }), repoRoot);
75
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2' }), repoRoot);
76
+
77
+ const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
78
+ const lines = readFileSync(logPath, 'utf8').trim().split('\n');
79
+ expect(lines.length).toBe(2);
80
+ expect(JSON.parse(lines[0]).gate_id).toBe('gate-1');
81
+ expect(JSON.parse(lines[1]).gate_id).toBe('gate-2');
82
+ });
83
+
84
+ it('should never throw even when filesystem is broken', () => {
85
+ const badPath = '/nonexistent/path/that/cannot/exist/for/real';
86
+ expect(() => appendAuditEntry(makeEntry({ repo_path: badPath }), badPath)).not.toThrow();
87
+ });
88
+ });
89
+
90
+ describe('readTailEntries', () => {
91
+ it('should return last N entries', () => {
92
+ const repoRoot = join(TEST_DIR, 'tail-test');
93
+ for (let i = 1; i <= 30; i++) {
94
+ appendAuditEntry(
95
+ makeEntry({ repo_path: repoRoot, gate_id: `gate-${i % 3}`, duration_ms: i * 10 }),
96
+ repoRoot,
97
+ );
98
+ }
99
+
100
+ const tail = readTailEntries(5, repoRoot);
101
+ expect(tail.length).toBe(5);
102
+ expect(tail[0].duration_ms).toBe(260);
103
+ expect(tail[4].duration_ms).toBe(300);
104
+ });
105
+
106
+ it('should return empty array when log does not exist', () => {
107
+ const result = readTailEntries(10, join(TEST_DIR, 'empty-project'));
108
+ expect(result).toEqual([]);
109
+ });
110
+
111
+ it('should default to 20 entries', () => {
112
+ const repoRoot = join(TEST_DIR, 'default-tail');
113
+ for (let i = 0; i < 50; i++) {
114
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, duration_ms: i }), repoRoot);
115
+ }
116
+ const tail = readTailEntries(undefined, repoRoot);
117
+ expect(tail.length).toBe(20);
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
+ });
141
+ });
142
+
143
+ describe('computeStats', () => {
144
+ it('should aggregate per-gate statistics correctly', () => {
145
+ const repoRoot = join(TEST_DIR, 'stats-test');
146
+ // gate-1: 3 runs, 2 passed, durations 100+200+300
147
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 100 }), repoRoot);
148
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 200 }), repoRoot);
149
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: false, duration_ms: 300 }), repoRoot);
150
+ // gate-2: 2 runs, 2 passed
151
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 50 }), repoRoot);
152
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 150 }), repoRoot);
153
+
154
+ const stats = computeStats(repoRoot);
155
+ expect(stats.length).toBe(2);
156
+
157
+ const g1 = stats.find(s => s.gate_id === 'gate-1')!;
158
+ expect(g1.pass_pct).toBe('66.7%');
159
+ expect(g1.avg_ms).toBe(200);
160
+
161
+ const g2 = stats.find(s => s.gate_id === 'gate-2')!;
162
+ expect(g2.pass_pct).toBe('100.0%');
163
+ expect(g2.avg_ms).toBe(100);
164
+ });
165
+
166
+ it('should return empty array when no log exists', () => {
167
+ const result = computeStats(join(TEST_DIR, 'no-stats'));
168
+ expect(result).toEqual([]);
169
+ });
170
+
171
+ it('should calculate avg_issues correctly', () => {
172
+ const repoRoot = join(TEST_DIR, 'issues-test');
173
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 5 }), repoRoot);
174
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 3 }), repoRoot);
175
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 0 }), repoRoot);
176
+
177
+ const stats = computeStats(repoRoot);
178
+ expect(stats[0].avg_issues).toBe(2.67);
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
+ });
215
+ });
216
+
217
+ describe('rotateIfNeeded', () => {
218
+ it('should not rotate when file is under 10MB', () => {
219
+ const logPath = join(TEST_DIR, 'small-log', '.xp-gate', 'audit.jsonl');
220
+ mkdirSync(join(TEST_DIR, 'small-log', '.xp-gate'), { recursive: true });
221
+ writeFileSync(logPath, 'small content', 'utf8');
222
+
223
+ rotateIfNeeded(logPath);
224
+ expect(existsSync(logPath)).toBe(true);
225
+ expect(existsSync(`${logPath}.1`)).toBe(false);
226
+ });
227
+
228
+ it('should rotate when file exceeds 10MB', () => {
229
+ const logPath = join(TEST_DIR, 'big-log', '.xp-gate', 'audit.jsonl');
230
+ mkdirSync(join(TEST_DIR, 'big-log', '.xp-gate'), { recursive: true });
231
+
232
+ // Write > 10MB of data
233
+ const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
234
+ writeFileSync(logPath, bigContent, 'utf8');
235
+
236
+ rotateIfNeeded(logPath);
237
+ expect(existsSync(`${logPath}.1`)).toBe(true);
238
+ });
239
+
240
+ it('should keep max 3 archives', () => {
241
+ const logPath = join(TEST_DIR, 'archive-test', '.xp-gate', 'audit.jsonl');
242
+ mkdirSync(join(TEST_DIR, 'archive-test', '.xp-gate'), { recursive: true });
243
+ const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
244
+
245
+ // Create 3 rotations
246
+ for (let i = 0; i < 3; i++) {
247
+ writeFileSync(logPath, `${bigContent} round ${i}`, 'utf8');
248
+ rotateIfNeeded(logPath);
249
+ }
250
+
251
+ // Should have .1, .2, .3 but NOT .4
252
+ expect(existsSync(`${logPath}.1`)).toBe(true);
253
+ expect(existsSync(`${logPath}.2`)).toBe(true);
254
+ expect(existsSync(`${logPath}.3`)).toBe(true);
255
+ expect(existsSync(`${logPath}.4`)).toBe(false);
256
+ });
257
+ });
258
+ });
@@ -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
  });