@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.
@@ -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
+ }
@@ -144,21 +144,7 @@ async function downloadFile(url, dest, verbose) {
144
144
  });
145
145
  }
146
146
 
147
- function copyDirRecursive(src, dest) {
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 };
@@ -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
  }
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 main(): void {
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
- if (fileList.length === 0) {
38
- // eslint-disable-next-line no-console
39
- console.log('No files staged or modified. Checking all tracked files.');
40
- try {
41
- files = execSync('git ls-files', { encoding: 'utf8' }).trim();
42
- } catch {
43
- files = execSync('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', { encoding: 'utf8' }).trim();
44
- }
45
- fileList.push(...files.split('\n').filter(f => f.length > 0));
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 commit = execSync('git rev-parse HEAD 2>/dev/null || echo "no-commit"', { encoding: 'utf8' }).trim();
76
- const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
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
- // eslint-disable-next-line no-console
92
- console.log(` Commit: ${commit}`);
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": "@boyingliu01/xp-gate",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.5.3",
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": {