@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.
@@ -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
- export function rotateIfNeeded(logPath: string): void {
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
- stats = statSync(logPath);
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
- // Shift archives: .2 -> .3, .1 -> .2
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
- // 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
- }
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, firstArchive);
205
+ renameSync(logPath, first);
221
206
  } 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
- }
207
+ try { writeFileSync(logPath, '', 'utf8'); } catch { /* skip */ }
228
208
  }
229
209
  }
230
210
 
231
- // ── CLI entry point (direct invocation from hooks) ───────────────────────────
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
- function parseCliOptions(args: string[]): Record<string, string> {
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();
@@ -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 };