@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 +125 -0
- package/hooks/pre-push +11 -2
- package/lib/__tests__/audit-log.test.ts +107 -0
- package/lib/__tests__/gate-audit.test.ts +258 -0
- package/lib/__tests__/ui-detector.test.ts +112 -1
- package/lib/__tests__/ui-review.test.ts +119 -0
- package/lib/gate-audit.ts +263 -0
- package/lib/install-skill.js +1 -15
- package/lib/rollback.js +1 -16
- package/lib/shared-utils.js +25 -0
- package/lib/ui-detector.ts +5 -5
- package/lib/ui-review.ts +58 -53
- 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 +412 -24
- package/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
- package/skills/test-specification-alignment/SKILL.md +98 -1
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' '
|
|
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
|
|
@@ -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
|
});
|