@boyingliu01/xp-gate 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/xp-gate.js +125 -0
- package/hooks/pre-push +11 -2
- package/lib/__tests__/gate-audit.test.ts +199 -0
- package/lib/gate-audit.ts +279 -0
- package/lib/ui-detector.ts +5 -5
- package/lib/ui-review.ts +22 -22
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/skills/sprint-flow/SKILL.md +108 -0
- package/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +238 -0
- package/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
- package/skills/sprint-flow/templates/auto-estimate-output-template.md +130 -0
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,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test Gate Audit Logger
|
|
3
|
+
* @intent Verify JSONL audit logging for quality gate executions
|
|
4
|
+
* @covers AUDIT-001-01, AUDIT-001-02, AUDIT-001-03, AUDIT-001-04, AUDIT-001-05, AUDIT-001-06
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import {
|
|
10
|
+
appendAuditEntry,
|
|
11
|
+
readTailEntries,
|
|
12
|
+
computeStats,
|
|
13
|
+
rotateIfNeeded,
|
|
14
|
+
type GateAuditEntry,
|
|
15
|
+
} from '../gate-audit';
|
|
16
|
+
|
|
17
|
+
const TEST_DIR = join(process.cwd(), '.xp-gate-test');
|
|
18
|
+
|
|
19
|
+
function makeEntry(overrides: Partial<GateAuditEntry> = {}): GateAuditEntry {
|
|
20
|
+
return {
|
|
21
|
+
timestamp: new Date().toISOString(),
|
|
22
|
+
gate_id: 'gate-1',
|
|
23
|
+
gate_name: 'code-quality',
|
|
24
|
+
passed: true,
|
|
25
|
+
issues_found: 0,
|
|
26
|
+
duration_ms: 100,
|
|
27
|
+
trigger: 'commit',
|
|
28
|
+
repo_path: TEST_DIR,
|
|
29
|
+
commit_hash: 'abc123',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('gate-audit', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
if (existsSync(TEST_DIR)) {
|
|
37
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
if (existsSync(TEST_DIR)) {
|
|
44
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('appendAuditEntry', () => {
|
|
49
|
+
it('should create .xp-gate/ directory on first write', () => {
|
|
50
|
+
const repoRoot = join(TEST_DIR, 'new-project');
|
|
51
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot }), repoRoot);
|
|
52
|
+
expect(existsSync(join(repoRoot, '.xp-gate'))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should write valid JSONL', () => {
|
|
56
|
+
const repoRoot = join(TEST_DIR, 'jsonl-test');
|
|
57
|
+
const entry = makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', gate_name: 'dup-code' });
|
|
58
|
+
appendAuditEntry(entry, repoRoot);
|
|
59
|
+
|
|
60
|
+
const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
|
|
61
|
+
expect(existsSync(logPath)).toBe(true);
|
|
62
|
+
|
|
63
|
+
const content = readFileSync(logPath, 'utf8').trim();
|
|
64
|
+
const parsed = JSON.parse(content) as GateAuditEntry;
|
|
65
|
+
expect(parsed.gate_id).toBe('gate-2');
|
|
66
|
+
expect(parsed.gate_name).toBe('dup-code');
|
|
67
|
+
expect(typeof parsed.timestamp).toBe('string');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should append multiple entries as separate lines', () => {
|
|
71
|
+
const repoRoot = join(TEST_DIR, 'multi-test');
|
|
72
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1' }), repoRoot);
|
|
73
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2' }), repoRoot);
|
|
74
|
+
|
|
75
|
+
const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
|
|
76
|
+
const lines = readFileSync(logPath, 'utf8').trim().split('\n');
|
|
77
|
+
expect(lines.length).toBe(2);
|
|
78
|
+
expect(JSON.parse(lines[0]).gate_id).toBe('gate-1');
|
|
79
|
+
expect(JSON.parse(lines[1]).gate_id).toBe('gate-2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should never throw even when filesystem is broken', () => {
|
|
83
|
+
const badPath = '/nonexistent/path/that/cannot/exist/for/real';
|
|
84
|
+
expect(() => appendAuditEntry(makeEntry({ repo_path: badPath }), badPath)).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('readTailEntries', () => {
|
|
89
|
+
it('should return last N entries', () => {
|
|
90
|
+
const repoRoot = join(TEST_DIR, 'tail-test');
|
|
91
|
+
for (let i = 1; i <= 30; i++) {
|
|
92
|
+
appendAuditEntry(
|
|
93
|
+
makeEntry({ repo_path: repoRoot, gate_id: `gate-${i % 3}`, duration_ms: i * 10 }),
|
|
94
|
+
repoRoot,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tail = readTailEntries(5, repoRoot);
|
|
99
|
+
expect(tail.length).toBe(5);
|
|
100
|
+
expect(tail[0].duration_ms).toBe(260);
|
|
101
|
+
expect(tail[4].duration_ms).toBe(300);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return empty array when log does not exist', () => {
|
|
105
|
+
const result = readTailEntries(10, join(TEST_DIR, 'empty-project'));
|
|
106
|
+
expect(result).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should default to 20 entries', () => {
|
|
110
|
+
const repoRoot = join(TEST_DIR, 'default-tail');
|
|
111
|
+
for (let i = 0; i < 50; i++) {
|
|
112
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, duration_ms: i }), repoRoot);
|
|
113
|
+
}
|
|
114
|
+
const tail = readTailEntries(undefined, repoRoot);
|
|
115
|
+
expect(tail.length).toBe(20);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('computeStats', () => {
|
|
120
|
+
it('should aggregate per-gate statistics correctly', () => {
|
|
121
|
+
const repoRoot = join(TEST_DIR, 'stats-test');
|
|
122
|
+
// gate-1: 3 runs, 2 passed, durations 100+200+300
|
|
123
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 100 }), repoRoot);
|
|
124
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 200 }), repoRoot);
|
|
125
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: false, duration_ms: 300 }), repoRoot);
|
|
126
|
+
// gate-2: 2 runs, 2 passed
|
|
127
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 50 }), repoRoot);
|
|
128
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 150 }), repoRoot);
|
|
129
|
+
|
|
130
|
+
const stats = computeStats(repoRoot);
|
|
131
|
+
expect(stats.length).toBe(2);
|
|
132
|
+
|
|
133
|
+
const g1 = stats.find(s => s.gate_id === 'gate-1')!;
|
|
134
|
+
expect(g1.pass_pct).toBe('66.7%');
|
|
135
|
+
expect(g1.avg_ms).toBe(200);
|
|
136
|
+
|
|
137
|
+
const g2 = stats.find(s => s.gate_id === 'gate-2')!;
|
|
138
|
+
expect(g2.pass_pct).toBe('100.0%');
|
|
139
|
+
expect(g2.avg_ms).toBe(100);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return empty array when no log exists', () => {
|
|
143
|
+
const result = computeStats(join(TEST_DIR, 'no-stats'));
|
|
144
|
+
expect(result).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should calculate avg_issues correctly', () => {
|
|
148
|
+
const repoRoot = join(TEST_DIR, 'issues-test');
|
|
149
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 5 }), repoRoot);
|
|
150
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 3 }), repoRoot);
|
|
151
|
+
appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 0 }), repoRoot);
|
|
152
|
+
|
|
153
|
+
const stats = computeStats(repoRoot);
|
|
154
|
+
expect(stats[0].avg_issues).toBe(2.67);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('rotateIfNeeded', () => {
|
|
159
|
+
it('should not rotate when file is under 10MB', () => {
|
|
160
|
+
const logPath = join(TEST_DIR, 'small-log', '.xp-gate', 'audit.jsonl');
|
|
161
|
+
mkdirSync(join(TEST_DIR, 'small-log', '.xp-gate'), { recursive: true });
|
|
162
|
+
writeFileSync(logPath, 'small content', 'utf8');
|
|
163
|
+
|
|
164
|
+
rotateIfNeeded(logPath);
|
|
165
|
+
expect(existsSync(logPath)).toBe(true);
|
|
166
|
+
expect(existsSync(`${logPath}.1`)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should rotate when file exceeds 10MB', () => {
|
|
170
|
+
const logPath = join(TEST_DIR, 'big-log', '.xp-gate', 'audit.jsonl');
|
|
171
|
+
mkdirSync(join(TEST_DIR, 'big-log', '.xp-gate'), { recursive: true });
|
|
172
|
+
|
|
173
|
+
// Write > 10MB of data
|
|
174
|
+
const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
|
|
175
|
+
writeFileSync(logPath, bigContent, 'utf8');
|
|
176
|
+
|
|
177
|
+
rotateIfNeeded(logPath);
|
|
178
|
+
expect(existsSync(`${logPath}.1`)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should keep max 3 archives', () => {
|
|
182
|
+
const logPath = join(TEST_DIR, 'archive-test', '.xp-gate', 'audit.jsonl');
|
|
183
|
+
mkdirSync(join(TEST_DIR, 'archive-test', '.xp-gate'), { recursive: true });
|
|
184
|
+
const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
|
|
185
|
+
|
|
186
|
+
// Create 3 rotations
|
|
187
|
+
for (let i = 0; i < 3; i++) {
|
|
188
|
+
writeFileSync(logPath, `${bigContent} round ${i}`, 'utf8');
|
|
189
|
+
rotateIfNeeded(logPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Should have .1, .2, .3 but NOT .4
|
|
193
|
+
expect(existsSync(`${logPath}.1`)).toBe(true);
|
|
194
|
+
expect(existsSync(`${logPath}.2`)).toBe(true);
|
|
195
|
+
expect(existsSync(`${logPath}.3`)).toBe(true);
|
|
196
|
+
expect(existsSync(`${logPath}.4`)).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate Audit Logger — structured JSONL audit for quality gate executions.
|
|
3
|
+
*
|
|
4
|
+
* Log file: .xp-gate/audit.jsonl
|
|
5
|
+
* Rotation: max 10 MB, rename to .1, keep up to 3 archives.
|
|
6
|
+
* Fail-safe: write errors never block the caller.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
appendFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
statSync,
|
|
16
|
+
chmodSync,
|
|
17
|
+
} from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const AUDIT_DIR = '.xp-gate';
|
|
23
|
+
const AUDIT_FILE = 'audit.jsonl';
|
|
24
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
25
|
+
const MAX_ARCHIVES = 3;
|
|
26
|
+
|
|
27
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface GateAuditEntry {
|
|
30
|
+
timestamp: string;
|
|
31
|
+
gate_id: string;
|
|
32
|
+
gate_name: string;
|
|
33
|
+
passed: boolean;
|
|
34
|
+
issues_found: number;
|
|
35
|
+
duration_ms: number;
|
|
36
|
+
trigger: 'commit' | 'push' | 'manual';
|
|
37
|
+
repo_path: string;
|
|
38
|
+
commit_hash: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Append one audit entry to .xp-gate/audit.jsonl.
|
|
45
|
+
* Creates the directory lazily on first write.
|
|
46
|
+
* NEVER throws — errors are logged to stderr only.
|
|
47
|
+
*/
|
|
48
|
+
export function appendAuditEntry(
|
|
49
|
+
entry: GateAuditEntry,
|
|
50
|
+
repoRoot: string = process.cwd(),
|
|
51
|
+
): void {
|
|
52
|
+
try {
|
|
53
|
+
const logPath = join(repoRoot, AUDIT_DIR, AUDIT_FILE);
|
|
54
|
+
|
|
55
|
+
// Lazy-create directory
|
|
56
|
+
const dirPath = join(repoRoot, AUDIT_DIR);
|
|
57
|
+
if (!existsSync(dirPath)) {
|
|
58
|
+
mkdirSync(dirPath, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Rotate before write if file is too large
|
|
62
|
+
rotateIfNeeded(logPath);
|
|
63
|
+
|
|
64
|
+
const line = JSON.stringify(entry) + '\n';
|
|
65
|
+
|
|
66
|
+
// POSIX append with atomic semantics
|
|
67
|
+
try {
|
|
68
|
+
appendFileSync(logPath, line, 'utf8');
|
|
69
|
+
// Set restrictive permissions on first write (idempotent)
|
|
70
|
+
try {
|
|
71
|
+
chmodSync(logPath, 0o600);
|
|
72
|
+
} catch {
|
|
73
|
+
// chmod may fail on some filesystems — non-fatal
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Windows fallback: open with 'a' flag
|
|
77
|
+
writeFileSync(logPath, line, { flag: 'a', encoding: 'utf8' });
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Fail-safe: never propagate errors to caller
|
|
81
|
+
console.error('[xp-gate audit] Failed to append entry:', (err as Error).message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the last N entries from the audit log.
|
|
87
|
+
*/
|
|
88
|
+
export function readTailEntries(
|
|
89
|
+
count: number = 20,
|
|
90
|
+
repoRoot: string = process.cwd(),
|
|
91
|
+
): GateAuditEntry[] {
|
|
92
|
+
const logPath = join(repoRoot, AUDIT_DIR, AUDIT_FILE);
|
|
93
|
+
if (!existsSync(logPath)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const content = readFileSync(logPath, 'utf8').trim();
|
|
98
|
+
if (!content) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const allLines = content.split('\n');
|
|
103
|
+
const tailLines = allLines.slice(-count);
|
|
104
|
+
const entries: GateAuditEntry[] = [];
|
|
105
|
+
|
|
106
|
+
for (const line of tailLines) {
|
|
107
|
+
if (line.trim()) {
|
|
108
|
+
try {
|
|
109
|
+
entries.push(JSON.parse(line) as GateAuditEntry);
|
|
110
|
+
} catch {
|
|
111
|
+
// Skip malformed lines
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compute per-gate aggregate statistics.
|
|
121
|
+
*/
|
|
122
|
+
export function computeStats(
|
|
123
|
+
repoRoot: string = process.cwd(),
|
|
124
|
+
): { gate_id: string; pass_pct: string; avg_ms: number; avg_issues: number }[] {
|
|
125
|
+
const logPath = join(repoRoot, AUDIT_DIR, AUDIT_FILE);
|
|
126
|
+
if (!existsSync(logPath)) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const content = readFileSync(logPath, 'utf8').trim();
|
|
131
|
+
if (!content) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Parse all valid entries
|
|
136
|
+
const entries: GateAuditEntry[] = [];
|
|
137
|
+
for (const line of content.split('\n')) {
|
|
138
|
+
if (line.trim()) {
|
|
139
|
+
try {
|
|
140
|
+
entries.push(JSON.parse(line) as GateAuditEntry);
|
|
141
|
+
} catch {
|
|
142
|
+
// Skip malformed
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Aggregate by gate_id
|
|
148
|
+
const buckets = new Map<string, { total: number; passed: number; ms: number; issues: number }>();
|
|
149
|
+
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
const b = buckets.get(e.gate_id) ?? { total: 0, passed: 0, ms: 0, issues: 0 };
|
|
152
|
+
b.total += 1;
|
|
153
|
+
if (e.passed) b.passed += 1;
|
|
154
|
+
b.ms += e.duration_ms;
|
|
155
|
+
b.issues += e.issues_found;
|
|
156
|
+
buckets.set(e.gate_id, b);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const results: { gate_id: string; pass_pct: string; avg_ms: number; avg_issues: number }[] = [];
|
|
160
|
+
|
|
161
|
+
for (const [gate_id, b] of Array.from(buckets.entries())) {
|
|
162
|
+
results.push({
|
|
163
|
+
gate_id,
|
|
164
|
+
pass_pct: (b.total > 0 ? ((b.passed / b.total) * 100).toFixed(1) + '%' : 'N/A'),
|
|
165
|
+
avg_ms: b.total > 0 ? Math.round(b.ms / b.total) : 0,
|
|
166
|
+
avg_issues: b.total > 0 ? parseFloat((b.issues / b.total).toFixed(2)) : 0,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Sort by gate_id for stable output
|
|
171
|
+
results.sort((a, b) => a.gate_id.localeCompare(b.gate_id));
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rotate the audit log if it exceeds MAX_FILE_BYTES.
|
|
177
|
+
* Renames current file to .1, shifts existing archives.
|
|
178
|
+
* Keeps at most MAX_ARCHIVES archived files.
|
|
179
|
+
*/
|
|
180
|
+
export function rotateIfNeeded(logPath: string): void {
|
|
181
|
+
if (!existsSync(logPath)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let stats;
|
|
186
|
+
try {
|
|
187
|
+
stats = statSync(logPath);
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (stats.size < MAX_FILE_BYTES) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Shift archives: .2 -> .3, .1 -> .2
|
|
197
|
+
for (let i = MAX_ARCHIVES; i >= 2; i--) {
|
|
198
|
+
const from = `${logPath}.${i - 1}`;
|
|
199
|
+
const to = `${logPath}.${i}`;
|
|
200
|
+
if (existsSync(from)) {
|
|
201
|
+
try {
|
|
202
|
+
renameSync(from, to);
|
|
203
|
+
} catch {
|
|
204
|
+
// Race condition or permission issue — skip
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Current -> .1
|
|
210
|
+
const firstArchive = `${logPath}.1`;
|
|
211
|
+
if (existsSync(firstArchive)) {
|
|
212
|
+
try {
|
|
213
|
+
renameSync(firstArchive, `${logPath}.2`);
|
|
214
|
+
} catch {
|
|
215
|
+
// Skip on error
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
renameSync(logPath, firstArchive);
|
|
221
|
+
} catch {
|
|
222
|
+
// If rename fails, truncate the current file instead
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(logPath, '', 'utf8');
|
|
225
|
+
} catch {
|
|
226
|
+
console.error('[xp-gate audit] Failed to rotate or truncate log:', logPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── CLI entry point (direct invocation from hooks) ───────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* When called directly via `npx tsx gate-audit.ts record --gate-id ...`,
|
|
235
|
+
* this block handles CLI argument parsing and appends the entry.
|
|
236
|
+
*/
|
|
237
|
+
if (require.main === module) {
|
|
238
|
+
const args = process.argv.slice(2);
|
|
239
|
+
const command = args[0];
|
|
240
|
+
|
|
241
|
+
if (command === 'record') {
|
|
242
|
+
const opts = parseCliOptions(args.slice(1));
|
|
243
|
+
const entry: GateAuditEntry = {
|
|
244
|
+
timestamp: new Date().toISOString(),
|
|
245
|
+
gate_id: opts['gate-id'] || 'unknown',
|
|
246
|
+
gate_name: opts['gate-name'] || 'unknown',
|
|
247
|
+
passed: opts['passed'] === 'true',
|
|
248
|
+
issues_found: parseInt(opts['issues-found'] || '0', 10),
|
|
249
|
+
duration_ms: parseInt(opts['duration-ms'] || '0', 10),
|
|
250
|
+
trigger: (opts['trigger'] as 'commit' | 'push' | 'manual') || 'manual',
|
|
251
|
+
repo_path: process.cwd(),
|
|
252
|
+
commit_hash: getCommitHash(),
|
|
253
|
+
};
|
|
254
|
+
appendAuditEntry(entry);
|
|
255
|
+
} else {
|
|
256
|
+
console.error(`Unknown audit command: ${command}`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseCliOptions(args: string[]): Record<string, string> {
|
|
262
|
+
const opts: Record<string, string> = {};
|
|
263
|
+
for (let i = 0; i < args.length; i++) {
|
|
264
|
+
if (args[i].startsWith('--') && i + 1 < args.length) {
|
|
265
|
+
opts[args[i].slice(2)] = args[i + 1];
|
|
266
|
+
i++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return opts;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getCommitHash(): string {
|
|
273
|
+
try {
|
|
274
|
+
const { execSync } = require('child_process');
|
|
275
|
+
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
276
|
+
} catch {
|
|
277
|
+
return 'unknown';
|
|
278
|
+
}
|
|
279
|
+
}
|
package/lib/ui-detector.ts
CHANGED
|
@@ -204,21 +204,21 @@ function processOutput(input: string, repoRoot: string): void {
|
|
|
204
204
|
.map(parseRenamedFile)
|
|
205
205
|
.filter((f) => f.length > 0);
|
|
206
206
|
const result = collectUiMatches(files, repoRoot);
|
|
207
|
-
|
|
207
|
+
|
|
208
208
|
console.log(JSON.stringify(result, null, 2));
|
|
209
209
|
process.exit(result.isUiSprint ? 0 : 1);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
function runCheckBranch(
|
|
212
|
+
function runCheckBranch(_repoRoot: string): void {
|
|
213
213
|
const result = detectUiSprint('HEAD');
|
|
214
|
-
|
|
214
|
+
|
|
215
215
|
console.log(JSON.stringify(result, null, 2));
|
|
216
216
|
process.exit(result.isUiSprint ? 0 : 1);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
function runDefault(
|
|
219
|
+
function runDefault(_repoRoot: string): void {
|
|
220
220
|
const result = detectUiSprint();
|
|
221
|
-
|
|
221
|
+
|
|
222
222
|
console.log(JSON.stringify(result, null, 2));
|
|
223
223
|
process.exit(result.isUiSprint ? 0 : 1);
|
|
224
224
|
}
|