@boyingliu01/xp-gate 0.6.0 → 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/lib/__tests__/audit-log.test.ts +107 -0
- package/lib/__tests__/gate-audit.test.ts +59 -0
- package/lib/__tests__/ui-detector.test.ts +112 -1
- package/lib/__tests__/ui-review.test.ts +119 -0
- package/lib/gate-audit.ts +26 -42
- package/lib/install-skill.js +1 -15
- package/lib/rollback.js +1 -16
- package/lib/shared-utils.js +25 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
readTailEntries,
|
|
12
12
|
computeStats,
|
|
13
13
|
rotateIfNeeded,
|
|
14
|
+
parseCliOptions,
|
|
15
|
+
getCommitHash,
|
|
14
16
|
type GateAuditEntry,
|
|
15
17
|
} from '../gate-audit';
|
|
16
18
|
|
|
@@ -114,6 +116,28 @@ describe('gate-audit', () => {
|
|
|
114
116
|
const tail = readTailEntries(undefined, repoRoot);
|
|
115
117
|
expect(tail.length).toBe(20);
|
|
116
118
|
});
|
|
119
|
+
|
|
120
|
+
it('should skip malformed JSON lines', () => {
|
|
121
|
+
const repoRoot = join(TEST_DIR, 'malformed-tail');
|
|
122
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
123
|
+
mkdirSync(logDir, { recursive: true });
|
|
124
|
+
writeFileSync(
|
|
125
|
+
join(logDir, 'audit.jsonl'),
|
|
126
|
+
`not-json\n${JSON.stringify(makeEntry({ repo_path: repoRoot, gate_id: 'gate-valid' }))}\n`,
|
|
127
|
+
'utf8',
|
|
128
|
+
);
|
|
129
|
+
const tail = readTailEntries(10, repoRoot);
|
|
130
|
+
expect(tail.length).toBe(1);
|
|
131
|
+
expect(tail[0].gate_id).toBe('gate-valid');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return empty array for empty log file', () => {
|
|
135
|
+
const repoRoot = join(TEST_DIR, 'empty-log');
|
|
136
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
137
|
+
mkdirSync(logDir, { recursive: true });
|
|
138
|
+
writeFileSync(join(logDir, 'audit.jsonl'), '', 'utf8');
|
|
139
|
+
expect(readTailEntries(10, repoRoot)).toEqual([]);
|
|
140
|
+
});
|
|
117
141
|
});
|
|
118
142
|
|
|
119
143
|
describe('computeStats', () => {
|
|
@@ -153,6 +177,41 @@ describe('gate-audit', () => {
|
|
|
153
177
|
const stats = computeStats(repoRoot);
|
|
154
178
|
expect(stats[0].avg_issues).toBe(2.67);
|
|
155
179
|
});
|
|
180
|
+
|
|
181
|
+
it('should return empty array for empty stats log', () => {
|
|
182
|
+
const repoRoot = join(TEST_DIR, 'empty-stats-log');
|
|
183
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
184
|
+
mkdirSync(logDir, { recursive: true });
|
|
185
|
+
writeFileSync(join(logDir, 'audit.jsonl'), '', 'utf8');
|
|
186
|
+
expect(computeStats(repoRoot)).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should skip malformed lines during stats aggregation', () => {
|
|
190
|
+
const repoRoot = join(TEST_DIR, 'malformed-stats');
|
|
191
|
+
const logDir = join(repoRoot, '.xp-gate');
|
|
192
|
+
mkdirSync(logDir, { recursive: true });
|
|
193
|
+
writeFileSync(
|
|
194
|
+
join(logDir, 'audit.jsonl'),
|
|
195
|
+
`${JSON.stringify(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1' }))}\n{bad json}\n`,
|
|
196
|
+
'utf8',
|
|
197
|
+
);
|
|
198
|
+
const stats = computeStats(repoRoot);
|
|
199
|
+
expect(stats).toHaveLength(1);
|
|
200
|
+
expect(stats[0].gate_id).toBe('gate-1');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('CLI helpers', () => {
|
|
205
|
+
it('should parse CLI options and skip dangling flags', () => {
|
|
206
|
+
expect(parseCliOptions(['--gate-id', 'gate-1', '--passed', 'true', '--dangling'])).toEqual({
|
|
207
|
+
'gate-id': 'gate-1',
|
|
208
|
+
passed: 'true',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should return a commit hash string or unknown', () => {
|
|
213
|
+
expect(typeof getCommitHash()).toBe('string');
|
|
214
|
+
});
|
|
156
215
|
});
|
|
157
216
|
|
|
158
217
|
describe('rotateIfNeeded', () => {
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test ui-review
|
|
3
|
+
* @intent Verify UI review file generation helpers
|
|
4
|
+
* @covers UI-REVIEW-001
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
vi.mock('child_process', () => ({
|
|
12
|
+
execSync: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const mockExecSync = vi.mocked(execSync);
|
|
16
|
+
const TEST_DIR = join(process.cwd(), '.ui-review-test');
|
|
17
|
+
|
|
18
|
+
describe('ui-review', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
if (existsSync(TEST_DIR)) {
|
|
22
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (existsSync(TEST_DIR)) {
|
|
29
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should parse file list and renamed paths', async () => {
|
|
34
|
+
const { parseFileList } = await import('../ui-review');
|
|
35
|
+
expect(parseFileList('views/a.html → views/b.html\n\nsrc/auth.ts\n')).toEqual([
|
|
36
|
+
'views/b.html',
|
|
37
|
+
'src/auth.ts',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return changed files from staged and modified diff', async () => {
|
|
42
|
+
mockExecSync.mockReturnValue('src/components/Button.tsx\nviews/index.html\n');
|
|
43
|
+
const { getChangedFilesForReview } = await import('../ui-review');
|
|
44
|
+
expect(getChangedFilesForReview()).toEqual(['src/components/Button.tsx', 'views/index.html']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should fall back to tracked files when no changes are present', async () => {
|
|
48
|
+
mockExecSync
|
|
49
|
+
.mockReturnValueOnce('')
|
|
50
|
+
.mockReturnValueOnce('src/components/Fallback.tsx\n');
|
|
51
|
+
const { getChangedFilesForReview } = await import('../ui-review');
|
|
52
|
+
expect(getChangedFilesForReview()).toEqual(['src/components/Fallback.tsx']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should fall back to find command when git ls-files fails', async () => {
|
|
56
|
+
mockExecSync
|
|
57
|
+
.mockReturnValueOnce('')
|
|
58
|
+
.mockImplementationOnce(() => { throw new Error('not git'); })
|
|
59
|
+
.mockReturnValueOnce('views/index.html\n');
|
|
60
|
+
const { getChangedFilesForReview } = await import('../ui-review');
|
|
61
|
+
expect(getChangedFilesForReview()).toEqual(['views/index.html']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should build an approved review result with 24h expiry', async () => {
|
|
65
|
+
mockExecSync.mockReturnValue('abc123\n');
|
|
66
|
+
const { buildUiReviewResult } = await import('../ui-review');
|
|
67
|
+
const now = new Date('2026-06-02T00:00:00.000Z');
|
|
68
|
+
const result = buildUiReviewResult(['views/index.html'], now);
|
|
69
|
+
expect(result).toMatchObject({
|
|
70
|
+
commit: 'abc123',
|
|
71
|
+
verdict: 'APPROVED',
|
|
72
|
+
design_review: 'APPROVED',
|
|
73
|
+
browser_qa: 'APPROVED',
|
|
74
|
+
ui_changes_detected: ['views/index.html'],
|
|
75
|
+
});
|
|
76
|
+
expect(result.expires).toBe('2026-06-03T00:00:00.000Z');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should run main and exit 0 when no UI changes are detected', async () => {
|
|
80
|
+
mockExecSync.mockReturnValue('src/auth.ts\n');
|
|
81
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
|
82
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
83
|
+
const { main } = await import('../ui-review');
|
|
84
|
+
main();
|
|
85
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
86
|
+
expect(logSpy).toHaveBeenCalledWith('ℹ️ No UI changes detected in staged/modified files.');
|
|
87
|
+
exitSpy.mockRestore();
|
|
88
|
+
logSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should run main and write result when UI changes are detected', async () => {
|
|
92
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(TEST_DIR);
|
|
93
|
+
mockExecSync
|
|
94
|
+
.mockReturnValueOnce('views/index.html\n')
|
|
95
|
+
.mockReturnValueOnce('abc123\n');
|
|
96
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
97
|
+
const { main } = await import('../ui-review');
|
|
98
|
+
main();
|
|
99
|
+
expect(existsSync(join(TEST_DIR, '.ui-gate-result.json'))).toBe(true);
|
|
100
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Generated .ui-gate-result.json'));
|
|
101
|
+
cwdSpy.mockRestore();
|
|
102
|
+
logSpy.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should write .ui-gate-result.json', async () => {
|
|
106
|
+
const { writeUiReviewResult } = await import('../ui-review');
|
|
107
|
+
const result = {
|
|
108
|
+
commit: 'abc123',
|
|
109
|
+
verdict: 'APPROVED',
|
|
110
|
+
expires: '2026-06-03T00:00:00.000Z',
|
|
111
|
+
design_review: 'APPROVED',
|
|
112
|
+
browser_qa: 'APPROVED',
|
|
113
|
+
ui_changes_detected: ['views/index.html'],
|
|
114
|
+
};
|
|
115
|
+
writeUiReviewResult(result, TEST_DIR);
|
|
116
|
+
const parsed = JSON.parse(readFileSync(join(TEST_DIR, '.ui-gate-result.json'), 'utf8'));
|
|
117
|
+
expect(parsed.ui_changes_detected).toEqual(['views/index.html']);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/lib/gate-audit.ts
CHANGED
|
@@ -177,64 +177,44 @@ export function computeStats(
|
|
|
177
177
|
* Renames current file to .1, shifts existing archives.
|
|
178
178
|
* Keeps at most MAX_ARCHIVES archived files.
|
|
179
179
|
*/
|
|
180
|
-
|
|
181
|
-
if (!existsSync(logPath))
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
let stats;
|
|
180
|
+
function shouldRotate(logPath: string): boolean {
|
|
181
|
+
if (!existsSync(logPath)) return false;
|
|
186
182
|
try {
|
|
187
|
-
|
|
183
|
+
return statSync(logPath).size >= MAX_FILE_BYTES;
|
|
188
184
|
} catch {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (stats.size < MAX_FILE_BYTES) {
|
|
193
|
-
return;
|
|
185
|
+
return false;
|
|
194
186
|
}
|
|
187
|
+
}
|
|
195
188
|
|
|
196
|
-
|
|
189
|
+
function shiftOldArchives(logPath: string): void {
|
|
197
190
|
for (let i = MAX_ARCHIVES; i >= 2; i--) {
|
|
198
191
|
const from = `${logPath}.${i - 1}`;
|
|
199
192
|
const to = `${logPath}.${i}`;
|
|
200
193
|
if (existsSync(from)) {
|
|
201
|
-
try {
|
|
202
|
-
renameSync(from, to);
|
|
203
|
-
} catch {
|
|
204
|
-
// Race condition or permission issue — skip
|
|
205
|
-
}
|
|
194
|
+
try { renameSync(from, to); } catch { /* skip */ }
|
|
206
195
|
}
|
|
207
196
|
}
|
|
197
|
+
}
|
|
208
198
|
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
if (existsSync(
|
|
212
|
-
try {
|
|
213
|
-
renameSync(firstArchive, `${logPath}.2`);
|
|
214
|
-
} catch {
|
|
215
|
-
// Skip on error
|
|
216
|
-
}
|
|
199
|
+
function archiveCurrent(logPath: string): void {
|
|
200
|
+
const first = `${logPath}.1`;
|
|
201
|
+
if (existsSync(first)) {
|
|
202
|
+
try { renameSync(first, `${logPath}.2`); } catch { /* skip */ }
|
|
217
203
|
}
|
|
218
|
-
|
|
219
204
|
try {
|
|
220
|
-
renameSync(logPath,
|
|
205
|
+
renameSync(logPath, first);
|
|
221
206
|
} catch {
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
writeFileSync(logPath, '', 'utf8');
|
|
225
|
-
} catch {
|
|
226
|
-
console.error('[xp-gate audit] Failed to rotate or truncate log:', logPath);
|
|
227
|
-
}
|
|
207
|
+
try { writeFileSync(logPath, '', 'utf8'); } catch { /* skip */ }
|
|
228
208
|
}
|
|
229
209
|
}
|
|
230
210
|
|
|
231
|
-
|
|
211
|
+
export function rotateIfNeeded(logPath: string): void {
|
|
212
|
+
if (!shouldRotate(logPath)) return;
|
|
213
|
+
shiftOldArchives(logPath);
|
|
214
|
+
archiveCurrent(logPath);
|
|
215
|
+
}
|
|
232
216
|
|
|
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) {
|
|
217
|
+
function runCli(): void {
|
|
238
218
|
const args = process.argv.slice(2);
|
|
239
219
|
const command = args[0];
|
|
240
220
|
|
|
@@ -258,7 +238,11 @@ if (require.main === module) {
|
|
|
258
238
|
}
|
|
259
239
|
}
|
|
260
240
|
|
|
261
|
-
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
runCli();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function parseCliOptions(args: string[]): Record<string, string> {
|
|
262
246
|
const opts: Record<string, string> = {};
|
|
263
247
|
for (let i = 0; i < args.length; i++) {
|
|
264
248
|
if (args[i].startsWith('--') && i + 1 < args.length) {
|
|
@@ -269,7 +253,7 @@ function parseCliOptions(args: string[]): Record<string, string> {
|
|
|
269
253
|
return opts;
|
|
270
254
|
}
|
|
271
255
|
|
|
272
|
-
function getCommitHash(): string {
|
|
256
|
+
export function getCommitHash(): string {
|
|
273
257
|
try {
|
|
274
258
|
const { execSync } = require('child_process');
|
|
275
259
|
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
package/lib/install-skill.js
CHANGED
|
@@ -144,21 +144,7 @@ async function downloadFile(url, dest, verbose) {
|
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
149
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
150
|
-
|
|
151
|
-
for (const entry of entries) {
|
|
152
|
-
const srcPath = path.join(src, entry.name);
|
|
153
|
-
const destPath = path.join(dest, entry.name);
|
|
154
|
-
|
|
155
|
-
if (entry.isDirectory()) {
|
|
156
|
-
copyDirRecursive(srcPath, destPath);
|
|
157
|
-
} else {
|
|
158
|
-
fs.copyFileSync(srcPath, destPath);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
147
|
+
const { copyDirRecursive } = require('./shared-utils');
|
|
162
148
|
|
|
163
149
|
function ensureConfigDir() {
|
|
164
150
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
package/lib/rollback.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const { copyDirRecursive } = require('./shared-utils');
|
|
4
5
|
|
|
5
6
|
// Cross-platform home directory resolution
|
|
6
7
|
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -51,22 +52,6 @@ async function createBackup(installId, skillName) {
|
|
|
51
52
|
return backupDir;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function copyDirRecursive(src, dest) {
|
|
55
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
56
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
57
|
-
|
|
58
|
-
for (const entry of entries) {
|
|
59
|
-
const srcPath = path.join(src, entry.name);
|
|
60
|
-
const destPath = path.join(dest, entry.name);
|
|
61
|
-
|
|
62
|
-
if (entry.isDirectory()) {
|
|
63
|
-
copyDirRecursive(srcPath, destPath);
|
|
64
|
-
} else {
|
|
65
|
-
fs.copyFileSync(srcPath, destPath);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
55
|
function cleanupBackup(installId) {
|
|
71
56
|
const backupDir = path.join(BACKUP_DIR, installId);
|
|
72
57
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions shared by CLI modules.
|
|
3
|
+
* No module-level state — safe for tests that mock fs/path/os.
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recursively copy a directory.
|
|
9
|
+
* Pure function: only uses fs/path params, no global config.
|
|
10
|
+
*/
|
|
11
|
+
function copyDirRecursive(src, dest) {
|
|
12
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
13
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const srcPath = require('path').join(src, entry.name);
|
|
16
|
+
const destPath = require('path').join(dest, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
copyDirRecursive(srcPath, destPath);
|
|
19
|
+
} else {
|
|
20
|
+
fs.copyFileSync(srcPath, destPath);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { copyDirRecursive };
|