@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 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,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
+ }
@@ -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
- // eslint-disable-next-line no-console
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(repoRoot: string): void {
212
+ function runCheckBranch(_repoRoot: string): void {
213
213
  const result = detectUiSprint('HEAD');
214
- // eslint-disable-next-line no-console
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(repoRoot: string): void {
219
+ function runDefault(_repoRoot: string): void {
220
220
  const result = detectUiSprint();
221
- // eslint-disable-next-line no-console
221
+
222
222
  console.log(JSON.stringify(result, null, 2));
223
223
  process.exit(result.isUiSprint ? 0 : 1);
224
224
  }