@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
function shouldRotate(logPath: string): boolean {
|
|
181
|
+
if (!existsSync(logPath)) return false;
|
|
182
|
+
try {
|
|
183
|
+
return statSync(logPath).size >= MAX_FILE_BYTES;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function shiftOldArchives(logPath: string): void {
|
|
190
|
+
for (let i = MAX_ARCHIVES; i >= 2; i--) {
|
|
191
|
+
const from = `${logPath}.${i - 1}`;
|
|
192
|
+
const to = `${logPath}.${i}`;
|
|
193
|
+
if (existsSync(from)) {
|
|
194
|
+
try { renameSync(from, to); } catch { /* skip */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function archiveCurrent(logPath: string): void {
|
|
200
|
+
const first = `${logPath}.1`;
|
|
201
|
+
if (existsSync(first)) {
|
|
202
|
+
try { renameSync(first, `${logPath}.2`); } catch { /* skip */ }
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
renameSync(logPath, first);
|
|
206
|
+
} catch {
|
|
207
|
+
try { writeFileSync(logPath, '', 'utf8'); } catch { /* skip */ }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function rotateIfNeeded(logPath: string): void {
|
|
212
|
+
if (!shouldRotate(logPath)) return;
|
|
213
|
+
shiftOldArchives(logPath);
|
|
214
|
+
archiveCurrent(logPath);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function runCli(): void {
|
|
218
|
+
const args = process.argv.slice(2);
|
|
219
|
+
const command = args[0];
|
|
220
|
+
|
|
221
|
+
if (command === 'record') {
|
|
222
|
+
const opts = parseCliOptions(args.slice(1));
|
|
223
|
+
const entry: GateAuditEntry = {
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
gate_id: opts['gate-id'] || 'unknown',
|
|
226
|
+
gate_name: opts['gate-name'] || 'unknown',
|
|
227
|
+
passed: opts['passed'] === 'true',
|
|
228
|
+
issues_found: parseInt(opts['issues-found'] || '0', 10),
|
|
229
|
+
duration_ms: parseInt(opts['duration-ms'] || '0', 10),
|
|
230
|
+
trigger: (opts['trigger'] as 'commit' | 'push' | 'manual') || 'manual',
|
|
231
|
+
repo_path: process.cwd(),
|
|
232
|
+
commit_hash: getCommitHash(),
|
|
233
|
+
};
|
|
234
|
+
appendAuditEntry(entry);
|
|
235
|
+
} else {
|
|
236
|
+
console.error(`Unknown audit command: ${command}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
runCli();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function parseCliOptions(args: string[]): Record<string, string> {
|
|
246
|
+
const opts: Record<string, string> = {};
|
|
247
|
+
for (let i = 0; i < args.length; i++) {
|
|
248
|
+
if (args[i].startsWith('--') && i + 1 < args.length) {
|
|
249
|
+
opts[args[i].slice(2)] = args[i + 1];
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return opts;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function getCommitHash(): string {
|
|
257
|
+
try {
|
|
258
|
+
const { execSync } = require('child_process');
|
|
259
|
+
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
260
|
+
} catch {
|
|
261
|
+
return 'unknown';
|
|
262
|
+
}
|
|
263
|
+
}
|
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 };
|
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
|
}
|
package/lib/ui-review.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { collectUiMatches, parseRenamedFile } from './ui-detector';
|
|
|
5
5
|
|
|
6
6
|
const RESULT_FILE = '.ui-gate-result.json';
|
|
7
7
|
|
|
8
|
-
interface UiReviewResult {
|
|
8
|
+
export interface UiReviewResult {
|
|
9
9
|
commit: string;
|
|
10
10
|
verdict: string;
|
|
11
11
|
expires: string;
|
|
@@ -14,96 +14,101 @@ interface UiReviewResult {
|
|
|
14
14
|
ui_changes_detected: string[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
function
|
|
18
|
-
// eslint-disable-next-line no-console
|
|
19
|
-
console.log('═══ xp-gate ui-review ═══');
|
|
20
|
-
// eslint-disable-next-line no-console
|
|
21
|
-
console.log('');
|
|
22
|
-
|
|
23
|
-
// Get staged + modified files
|
|
17
|
+
export function getChangedFilesForReview(): string[] {
|
|
24
18
|
let files = '';
|
|
25
19
|
try {
|
|
26
20
|
files = execSync('git diff --cached --name-only && git diff --name-only', { encoding: 'utf8' }).trim();
|
|
27
21
|
} catch {
|
|
28
|
-
// eslint-disable-next-line no-console
|
|
29
22
|
console.log('⚠️ No git repo or no changes. Running in current directory.');
|
|
30
23
|
}
|
|
31
24
|
|
|
32
|
-
const fileList = files
|
|
25
|
+
const fileList = parseFileList(files);
|
|
26
|
+
if (fileList.length > 0) {
|
|
27
|
+
return fileList;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log('No files staged or modified. Checking all tracked files.');
|
|
31
|
+
return getFallbackFileList();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseFileList(files: string): string[] {
|
|
35
|
+
return files
|
|
33
36
|
.split('\n')
|
|
34
37
|
.map(parseRenamedFile)
|
|
35
38
|
.filter(f => f.length > 0);
|
|
39
|
+
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
export function getFallbackFileList(): string[] {
|
|
42
|
+
try {
|
|
43
|
+
return parseFileList(execSync('git ls-files', { encoding: 'utf8' }).trim());
|
|
44
|
+
} catch {
|
|
45
|
+
const output = execSync(
|
|
46
|
+
'find . -maxdepth 3 -type f -name "*.ts" -o -name "*.html" -o -name "*.css" -o -name "*.scss" -o -name "*.tsx" -o -name "*.vue" -o -name "*.svelte" 2>/dev/null | grep -v node_modules | grep -v .git',
|
|
47
|
+
{ encoding: 'utf8' },
|
|
48
|
+
).trim();
|
|
49
|
+
return parseFileList(output);
|
|
46
50
|
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildUiReviewResult(matchedFiles: string[], now: Date = new Date()): UiReviewResult {
|
|
54
|
+
const commit = getCurrentCommit();
|
|
55
|
+
const expires = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
commit,
|
|
59
|
+
verdict: 'APPROVED',
|
|
60
|
+
expires,
|
|
61
|
+
design_review: 'APPROVED',
|
|
62
|
+
browser_qa: 'APPROVED',
|
|
63
|
+
ui_changes_detected: matchedFiles,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeUiReviewResult(result: UiReviewResult, repoRoot: string = process.cwd()): void {
|
|
68
|
+
writeFileSync(join(repoRoot, RESULT_FILE), JSON.stringify(result, null, 2) + '\n', 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getCurrentCommit(): string {
|
|
72
|
+
return execSync('git rev-parse HEAD 2>/dev/null || echo "no-commit"', { encoding: 'utf8' }).trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function main(): void {
|
|
76
|
+
console.log('═══ xp-gate ui-review ═══');
|
|
77
|
+
console.log('');
|
|
47
78
|
|
|
79
|
+
const fileList = getChangedFilesForReview();
|
|
48
80
|
const result = collectUiMatches(fileList);
|
|
49
81
|
|
|
50
82
|
if (!result.isUiSprint) {
|
|
51
|
-
// eslint-disable-next-line no-console
|
|
52
83
|
console.log('ℹ️ No UI changes detected in staged/modified files.');
|
|
53
|
-
// eslint-disable-next-line no-console
|
|
54
84
|
console.log(' Nothing to review. Exiting.');
|
|
55
85
|
process.exit(0);
|
|
56
86
|
}
|
|
57
87
|
|
|
58
|
-
// eslint-disable-next-line no-console
|
|
59
88
|
console.log(`🎨 UI changes detected (${result.matchedFiles.length} files):`);
|
|
60
89
|
result.matchedFiles.forEach(f => console.log(` - ${f}`));
|
|
61
|
-
// eslint-disable-next-line no-console
|
|
62
90
|
console.log('');
|
|
63
91
|
|
|
64
|
-
// eslint-disable-next-line no-console
|
|
65
92
|
console.log('Next steps required before push:');
|
|
66
|
-
// eslint-disable-next-line no-console
|
|
67
93
|
console.log(' 1. Run /design-review in your AI agent session');
|
|
68
|
-
// eslint-disable-next-line no-console
|
|
69
94
|
console.log(' 2. Run /qa or /qa-only in your AI agent session');
|
|
70
|
-
// eslint-disable-next-line no-console
|
|
71
95
|
console.log(' 3. Ensure you have .delphi-config.json configured for Delphi review');
|
|
72
|
-
// eslint-disable-next-line no-console
|
|
73
96
|
console.log('');
|
|
74
97
|
|
|
75
|
-
const
|
|
76
|
-
|
|
98
|
+
const uiResult = buildUiReviewResult(result.matchedFiles);
|
|
99
|
+
writeUiReviewResult(uiResult);
|
|
77
100
|
|
|
78
|
-
const uiResult: UiReviewResult = {
|
|
79
|
-
commit,
|
|
80
|
-
verdict: 'APPROVED',
|
|
81
|
-
expires,
|
|
82
|
-
design_review: 'APPROVED',
|
|
83
|
-
browser_qa: 'APPROVED',
|
|
84
|
-
ui_changes_detected: result.matchedFiles,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
writeFileSync(join(process.cwd(), RESULT_FILE), JSON.stringify(uiResult, null, 2) + '\n', 'utf8');
|
|
88
|
-
|
|
89
|
-
// eslint-disable-next-line no-console
|
|
90
101
|
console.log(`✅ Generated ${RESULT_FILE} with APPROVED verdict (template)`);
|
|
91
|
-
|
|
92
|
-
console.log(`
|
|
93
|
-
// eslint-disable-next-line no-console
|
|
94
|
-
console.log(` Expires: ${expires}`);
|
|
95
|
-
// eslint-disable-next-line no-console
|
|
102
|
+
console.log(` Commit: ${uiResult.commit}`);
|
|
103
|
+
console.log(` Expires: ${uiResult.expires}`);
|
|
96
104
|
console.log('');
|
|
97
|
-
// eslint-disable-next-line no-console
|
|
98
105
|
console.log('⚠️ REVIEW THIS FILE before push:');
|
|
99
|
-
// eslint-disable-next-line no-console
|
|
100
106
|
console.log(' - Ensure design_review and browser_qa are actually APPROVED');
|
|
101
|
-
// eslint-disable-next-line no-console
|
|
102
107
|
console.log(' - Edit verdict to REJECTED if issues found');
|
|
103
|
-
// eslint-disable-next-line no-console
|
|
104
108
|
console.log('');
|
|
105
|
-
// eslint-disable-next-line no-console
|
|
106
109
|
console.log('Then: git push (pre-push will validate this file)');
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
main
|
|
112
|
+
if (require.main === module) {
|
|
113
|
+
main();
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 6 quality gates, Sprint Flow, and Delphi multi-expert review.",
|
|
6
6
|
"author": {
|