@boyingliu01/xp-gate 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/bin/xp-gate.js +4 -9
  2. package/lib/__tests__/audit-log.test.ts +107 -0
  3. package/lib/__tests__/detect-deps.test.js +212 -42
  4. package/lib/__tests__/doctor.test.js +1 -0
  5. package/lib/__tests__/gate-audit.test.ts +59 -0
  6. package/lib/__tests__/init.test.js +47 -1
  7. package/lib/__tests__/install-skill.test.js +1 -45
  8. package/lib/__tests__/ui-detector.test.ts +186 -26
  9. package/lib/__tests__/ui-review.test.ts +119 -0
  10. package/lib/__tests__/uninstall.test.js +1 -0
  11. package/lib/detect-deps.js +180 -37
  12. package/lib/doctor.js +7 -9
  13. package/lib/gate-audit.ts +26 -42
  14. package/lib/init.js +42 -13
  15. package/lib/install-skill.js +20 -48
  16. package/lib/rollback.js +1 -16
  17. package/lib/shared-paths.js +30 -0
  18. package/lib/shared-utils.js +25 -0
  19. package/lib/ui-detector.ts +20 -14
  20. package/lib/ui-review.ts +58 -53
  21. package/lib/uninstall.js +8 -9
  22. package/package.json +1 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/admin-template-guidelines/SKILL.md +904 -0
  25. package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +45 -0
  26. package/plugins/claude-code/skills/delphi-review/AGENTS.md +66 -0
  27. package/plugins/claude-code/skills/delphi-review/INSTALL.md +152 -0
  28. package/plugins/claude-code/skills/delphi-review/SKILL.md +454 -0
  29. package/plugins/claude-code/skills/delphi-review/opencode.json.delphi.example +56 -0
  30. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +486 -0
  31. package/plugins/claude-code/skills/improve-codebase-architecture/SKILL.md +156 -0
  32. package/plugins/claude-code/skills/ralph-loop/SKILL.md +501 -0
  33. package/plugins/claude-code/skills/ralph-loop/references/components/memory.md +55 -0
  34. package/plugins/claude-code/skills/ralph-loop/references/components/middleware.md +54 -0
  35. package/plugins/claude-code/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  36. package/plugins/claude-code/skills/ralph-loop/references/components/system-prompt.md +24 -0
  37. package/plugins/claude-code/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  38. package/plugins/claude-code/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  39. package/plugins/claude-code/skills/ralph-loop/templates/progress-log.md +36 -0
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +68 -0
  41. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1248 -0
  42. package/plugins/claude-code/skills/sprint-flow/references/components/memory.md +87 -0
  43. package/plugins/claude-code/skills/sprint-flow/references/components/middleware.md +72 -0
  44. package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  45. package/plugins/claude-code/skills/sprint-flow/references/components/system-prompt.md +27 -0
  46. package/plugins/claude-code/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  47. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +203 -0
  48. package/plugins/claude-code/skills/sprint-flow/references/phase-0-think.md +115 -0
  49. package/plugins/claude-code/skills/sprint-flow/references/phase-1-plan.md +178 -0
  50. package/plugins/claude-code/skills/sprint-flow/references/phase-2-build.md +198 -0
  51. package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +213 -0
  52. package/plugins/claude-code/skills/sprint-flow/references/phase-4-uat.md +125 -0
  53. package/plugins/claude-code/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  54. package/plugins/claude-code/skills/sprint-flow/references/phase-6-ship.md +193 -0
  55. package/plugins/claude-code/skills/sprint-flow/references/phase-7-land.md +140 -0
  56. package/plugins/claude-code/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  57. package/plugins/claude-code/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +257 -0
  58. package/plugins/claude-code/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  59. package/plugins/claude-code/skills/sprint-flow/templates/auto-estimate-output-template.md +132 -0
  60. package/plugins/claude-code/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  61. package/plugins/claude-code/skills/sprint-flow/templates/pain-document-template.md +115 -0
  62. package/plugins/claude-code/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  63. package/plugins/claude-code/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  64. package/plugins/claude-code/skills/test-driven-development/SKILL.md +71 -0
  65. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +56 -0
  66. package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +702 -0
  67. package/plugins/claude-code/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  68. package/plugins/claude-code/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  69. package/plugins/claude-code/skills/test-specification-alignment/references/specification-format.md +348 -0
  70. package/plugins/claude-code/skills/to-issues/SKILL.md +277 -0
  71. package/plugins/opencode/README.md +38 -0
  72. package/plugins/opencode/index.ts +85 -0
  73. package/plugins/opencode/package.json +18 -0
  74. package/plugins/opencode/skills/admin-template-guidelines/SKILL.md +904 -0
  75. package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +45 -0
  76. package/plugins/opencode/skills/delphi-review/AGENTS.md +66 -0
  77. package/plugins/opencode/skills/delphi-review/INSTALL.md +152 -0
  78. package/plugins/opencode/skills/delphi-review/SKILL.md +454 -0
  79. package/plugins/opencode/skills/delphi-review/opencode.json.delphi.example +56 -0
  80. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +486 -0
  81. package/plugins/opencode/skills/improve-codebase-architecture/SKILL.md +156 -0
  82. package/plugins/opencode/skills/ralph-loop/SKILL.md +501 -0
  83. package/plugins/opencode/skills/ralph-loop/references/components/memory.md +55 -0
  84. package/plugins/opencode/skills/ralph-loop/references/components/middleware.md +54 -0
  85. package/plugins/opencode/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  86. package/plugins/opencode/skills/ralph-loop/references/components/system-prompt.md +24 -0
  87. package/plugins/opencode/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  88. package/plugins/opencode/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  89. package/plugins/opencode/skills/ralph-loop/templates/progress-log.md +36 -0
  90. package/plugins/opencode/skills/sprint-flow/AGENTS.md +68 -0
  91. package/plugins/opencode/skills/sprint-flow/SKILL.md +1248 -0
  92. package/plugins/opencode/skills/sprint-flow/references/components/memory.md +87 -0
  93. package/plugins/opencode/skills/sprint-flow/references/components/middleware.md +72 -0
  94. package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  95. package/plugins/opencode/skills/sprint-flow/references/components/system-prompt.md +27 -0
  96. package/plugins/opencode/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  97. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +203 -0
  98. package/plugins/opencode/skills/sprint-flow/references/phase-0-think.md +115 -0
  99. package/plugins/opencode/skills/sprint-flow/references/phase-1-plan.md +178 -0
  100. package/plugins/opencode/skills/sprint-flow/references/phase-2-build.md +198 -0
  101. package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +213 -0
  102. package/plugins/opencode/skills/sprint-flow/references/phase-4-uat.md +125 -0
  103. package/plugins/opencode/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  104. package/plugins/opencode/skills/sprint-flow/references/phase-6-ship.md +193 -0
  105. package/plugins/opencode/skills/sprint-flow/references/phase-7-land.md +140 -0
  106. package/plugins/opencode/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  107. package/plugins/opencode/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +257 -0
  108. package/plugins/opencode/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  109. package/plugins/opencode/skills/sprint-flow/templates/auto-estimate-output-template.md +132 -0
  110. package/plugins/opencode/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  111. package/plugins/opencode/skills/sprint-flow/templates/pain-document-template.md +115 -0
  112. package/plugins/opencode/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  113. package/plugins/opencode/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  114. package/plugins/opencode/skills/test-driven-development/SKILL.md +71 -0
  115. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +56 -0
  116. package/plugins/opencode/skills/test-specification-alignment/SKILL.md +702 -0
  117. package/plugins/opencode/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  118. package/plugins/opencode/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  119. package/plugins/opencode/skills/test-specification-alignment/references/specification-format.md +348 -0
  120. package/plugins/opencode/skills/to-issues/SKILL.md +277 -0
  121. package/plugins/opencode/tsconfig.json +15 -0
  122. package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +904 -0
  123. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +45 -0
  124. package/plugins/qoder/skills/delphi-review/AGENTS.md +66 -0
  125. package/plugins/qoder/skills/delphi-review/INSTALL.md +152 -0
  126. package/plugins/qoder/skills/delphi-review/SKILL.md +454 -0
  127. package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +56 -0
  128. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +486 -0
  129. package/plugins/qoder/skills/improve-codebase-architecture/SKILL.md +156 -0
  130. package/plugins/qoder/skills/ralph-loop/SKILL.md +501 -0
  131. package/plugins/qoder/skills/ralph-loop/references/components/memory.md +55 -0
  132. package/plugins/qoder/skills/ralph-loop/references/components/middleware.md +54 -0
  133. package/plugins/qoder/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  134. package/plugins/qoder/skills/ralph-loop/references/components/system-prompt.md +24 -0
  135. package/plugins/qoder/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  136. package/plugins/qoder/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  137. package/plugins/qoder/skills/ralph-loop/templates/progress-log.md +36 -0
  138. package/plugins/qoder/skills/sprint-flow/AGENTS.md +68 -0
  139. package/plugins/qoder/skills/sprint-flow/SKILL.md +1248 -0
  140. package/plugins/qoder/skills/sprint-flow/references/components/memory.md +87 -0
  141. package/plugins/qoder/skills/sprint-flow/references/components/middleware.md +72 -0
  142. package/plugins/qoder/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  143. package/plugins/qoder/skills/sprint-flow/references/components/system-prompt.md +27 -0
  144. package/plugins/qoder/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  145. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +203 -0
  146. package/plugins/qoder/skills/sprint-flow/references/phase-0-think.md +115 -0
  147. package/plugins/qoder/skills/sprint-flow/references/phase-1-plan.md +178 -0
  148. package/plugins/qoder/skills/sprint-flow/references/phase-2-build.md +198 -0
  149. package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +213 -0
  150. package/plugins/qoder/skills/sprint-flow/references/phase-4-uat.md +125 -0
  151. package/plugins/qoder/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  152. package/plugins/qoder/skills/sprint-flow/references/phase-6-ship.md +193 -0
  153. package/plugins/qoder/skills/sprint-flow/references/phase-7-land.md +140 -0
  154. package/plugins/qoder/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  155. package/plugins/qoder/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +257 -0
  156. package/plugins/qoder/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  157. package/plugins/qoder/skills/sprint-flow/templates/auto-estimate-output-template.md +132 -0
  158. package/plugins/qoder/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  159. package/plugins/qoder/skills/sprint-flow/templates/pain-document-template.md +115 -0
  160. package/plugins/qoder/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  161. package/plugins/qoder/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  162. package/plugins/qoder/skills/test-driven-development/SKILL.md +71 -0
  163. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +56 -0
  164. package/plugins/qoder/skills/test-specification-alignment/SKILL.md +702 -0
  165. package/plugins/qoder/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  166. package/plugins/qoder/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  167. package/plugins/qoder/skills/test-specification-alignment/references/specification-format.md +348 -0
  168. package/plugins/qoder/skills/to-issues/SKILL.md +277 -0
  169. package/skills/admin-template-guidelines/SKILL.md +904 -0
  170. package/skills/delphi-review/SKILL.md +112 -77
  171. package/skills/improve-codebase-architecture/SKILL.md +156 -0
  172. package/skills/ralph-loop/SKILL.md +165 -34
  173. package/skills/sprint-flow/SKILL.md +448 -107
  174. package/skills/sprint-flow/references/components/skill-invocations.md +1 -1
  175. package/skills/sprint-flow/references/components/tool-descriptions.md +1 -1
  176. package/skills/sprint-flow/references/force-levels.md +203 -0
  177. package/skills/sprint-flow/references/phase-1-plan.md +4 -4
  178. package/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +20 -1
  179. package/skills/sprint-flow/templates/auto-estimate-output-template.md +7 -5
  180. package/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  181. package/skills/test-driven-development/SKILL.md +71 -0
  182. package/skills/test-specification-alignment/SKILL.md +98 -24
  183. package/skills/to-issues/SKILL.md +277 -0
  184. package/plugins/qoder/AGENTS.md +0 -93
  185. package/plugins/qoder/README.md +0 -87
  186. package/plugins/qoder/widgets/quality-report.html +0 -163
  187. package/plugins/qoder/widgets/sprint-dashboard.html +0 -172
  188. package/skills/delphi-review/evals/evals.json +0 -82
  189. package/skills/delphi-review/references/qoder-multi-model.md +0 -191
  190. package/skills/ralph-loop/evals/evals.json +0 -311
  191. package/skills/ralph-loop/evolution-history.json +0 -59
  192. package/skills/ralph-loop/evolution-log.md +0 -16
  193. package/skills/sprint-flow/evals/evals.json +0 -130
  194. package/skills/sprint-flow/evolution-history.json +0 -39
  195. package/skills/sprint-flow/evolution-log.md +0 -23
  196. package/skills/sprint-flow/references/qoder-adaptation.md +0 -173
  197. package/skills/test-specification-alignment/evals/evals.json +0 -75
@@ -69,6 +69,7 @@ describe('install-skill', () => {
69
69
  vi.resetModules();
70
70
  delete require.cache[require.resolve('../install-skill')];
71
71
  delete require.cache[require.resolve('../detect-deps')];
72
+ delete require.cache[require.resolve('../shared-paths')];
72
73
  delete require.cache[require.resolve('../download-skill')];
73
74
  delete require.cache[require.resolve('../rollback')];
74
75
  vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -323,49 +324,4 @@ describe('install-skill', () => {
323
324
  expect(console.warn).not.toHaveBeenCalled();
324
325
  expect(console.error).toHaveBeenCalledWith('Error: Failed to download test-spec');
325
326
  });
326
-
327
- // --- Qoder platform tests ---
328
-
329
- function qoderSkillsDir() {
330
- return path.join(tmpHome, '.qoder', 'skills');
331
- }
332
-
333
- it('qoder platform skips dep check (installs without superpowers/gstack)', async () => {
334
- // Do NOT call setupValidDeps() — no superpowers/gstack present
335
- mockHttpsGet({ statusCode: 200, body: '# Sprint Flow Qoder' });
336
-
337
- const { installSkill } = require('../install-skill');
338
- const result = await installSkill('sprint-flow', { platform: 'qoder' });
339
-
340
- expect(result).toBe(0);
341
- expect(console.log).toHaveBeenCalledWith('✓ sprint-flow installed');
342
- });
343
-
344
- it('qoder platform installs to ~/.qoder/skills/ instead of ~/.config/opencode/skills/', async () => {
345
- mockHttpsGet({ statusCode: 200, body: '# Qoder Skill' });
346
-
347
- const { installSkill } = require('../install-skill');
348
- const result = await installSkill('sprint-flow', { platform: 'qoder' });
349
-
350
- expect(result).toBe(0);
351
- // Verify installed in Qoder directory
352
- const qoderFile = path.join(qoderSkillsDir(), 'sprint-flow', 'SKILL.md');
353
- expect(fs.existsSync(qoderFile)).toBe(true);
354
- expect(fs.readFileSync(qoderFile, 'utf8')).toContain('Qoder Skill');
355
-
356
- // Verify NOT installed in opencode directory
357
- const opencodeFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
358
- expect(fs.existsSync(opencodeFile)).toBe(false);
359
- });
360
-
361
- it('default platform still requires deps and installs to opencode dir', async () => {
362
- // Without setupValidDeps, default platform should fail on dep check
363
- const { installSkill } = require('../install-skill');
364
- const result = await installSkill('sprint-flow');
365
-
366
- expect(result).toBe(1);
367
- expect(console.error).toHaveBeenCalledWith(
368
- expect.stringContaining('superpowers is required')
369
- );
370
- });
371
327
  });
@@ -1,15 +1,32 @@
1
1
  /**
2
2
  * @test ui-detector
3
3
  * @intent Verify detectUiSprint correctly identifies UI file changes
4
+ * @covers Issue #155 — getChangedFiles must use spawnSync (array args) to prevent
5
+ * shell-meta injection via baseBranch parameter
4
6
  */
5
7
  import { describe, it, expect, vi, beforeEach } from 'vitest';
6
- import { execSync } from 'child_process';
8
+ import { spawnSync } from 'child_process';
7
9
 
8
10
  vi.mock('child_process', () => ({
9
- execSync: vi.fn(),
11
+ spawnSync: vi.fn(),
12
+ // Keep execSync as a vi.fn so any unintentional callers fail loudly
13
+ execSync: vi.fn(() => {
14
+ throw new Error('execSync must not be used: command injection risk (Issue #155)');
15
+ }),
10
16
  }));
11
17
 
12
- const mockExecSync = vi.mocked(execSync);
18
+ const mockSpawnSync = vi.mocked(spawnSync);
19
+
20
+ function mockSpawnReturn(stdout: string) {
21
+ mockSpawnSync.mockReturnValue({
22
+ pid: 1,
23
+ stdout: Buffer.from(stdout),
24
+ stderr: Buffer.from(''),
25
+ status: 0,
26
+ signal: null,
27
+ output: [null, stdout, ''],
28
+ } as unknown as ReturnType<typeof spawnSync>);
29
+ }
13
30
 
14
31
  describe('ui-detector', () => {
15
32
  beforeEach(() => {
@@ -18,7 +35,7 @@ describe('ui-detector', () => {
18
35
 
19
36
  describe('detectUiSprint', () => {
20
37
  it('should return false for empty diff', async () => {
21
- mockExecSync.mockReturnValue('');
38
+ mockSpawnReturn('');
22
39
  const { detectUiSprint } = await import('../ui-detector');
23
40
  const result = detectUiSprint();
24
41
  expect(result.isUiSprint).toBe(false);
@@ -27,14 +44,14 @@ describe('ui-detector', () => {
27
44
  });
28
45
 
29
46
  it('should return false for pure backend changes', async () => {
30
- mockExecSync.mockReturnValue('src/auth.ts\nsrc/db.ts\n');
47
+ mockSpawnReturn('src/auth.ts\nsrc/db.ts\n');
31
48
  const { detectUiSprint } = await import('../ui-detector');
32
49
  const result = detectUiSprint('main');
33
50
  expect(result.isUiSprint).toBe(false);
34
51
  });
35
52
 
36
53
  it('should return true for template files in view directories', async () => {
37
- mockExecSync.mockReturnValue('views/index.njk\n');
54
+ mockSpawnReturn('views/index.njk\n');
38
55
  const { detectUiSprint } = await import('../ui-detector');
39
56
  const result = detectUiSprint();
40
57
  expect(result.isUiSprint).toBe(true);
@@ -43,7 +60,7 @@ describe('ui-detector', () => {
43
60
  });
44
61
 
45
62
  it('should return true for component files in view directories', async () => {
46
- mockExecSync.mockReturnValue('src/components/Button.tsx\n');
63
+ mockSpawnReturn('src/components/Button.tsx\n');
47
64
  const { detectUiSprint } = await import('../ui-detector');
48
65
  const result = detectUiSprint();
49
66
  expect(result.isUiSprint).toBe(true);
@@ -51,43 +68,36 @@ describe('ui-detector', () => {
51
68
  });
52
69
 
53
70
  it('should return false for component files NOT in view directories', async () => {
54
- mockExecSync.mockReturnValue('src/hooks/useAuth.tsx\n');
71
+ mockSpawnReturn('src/hooks/useAuth.tsx\n');
55
72
  const { detectUiSprint } = await import('../ui-detector');
56
73
  const result = detectUiSprint();
57
74
  expect(result.isUiSprint).toBe(false);
58
75
  });
59
76
 
60
77
  it('should return true for style files in view directories', async () => {
61
- mockExecSync.mockReturnValue('views/styles/main.css\n');
78
+ mockSpawnReturn('views/styles/main.css\n');
62
79
  const { detectUiSprint } = await import('../ui-detector');
63
80
  const result = detectUiSprint();
64
81
  expect(result.isUiSprint).toBe(true);
65
82
  });
66
83
 
67
84
  it('should return false for style files NOT in view directories', async () => {
68
- mockExecSync.mockReturnValue('src/index.css\n');
85
+ mockSpawnReturn('src/index.css\n');
69
86
  const { detectUiSprint } = await import('../ui-detector');
70
87
  const result = detectUiSprint();
71
88
  expect(result.isUiSprint).toBe(false);
72
89
  });
73
90
 
74
91
  it('should return true for mixed changes (backend + UI)', async () => {
75
- mockExecSync.mockReturnValue('src/auth.ts\nviews/login.html\n');
92
+ mockSpawnReturn('src/auth.ts\nviews/login.html\n');
76
93
  const { detectUiSprint } = await import('../ui-detector');
77
94
  const result = detectUiSprint();
78
95
  expect(result.isUiSprint).toBe(true);
79
96
  expect(result.matchedFiles).toContain('views/login.html');
80
97
  });
81
98
 
82
- it('should return true for deleted UI files', async () => {
83
- mockExecSync.mockReturnValue('views/old.html\n');
84
- const { detectUiSprint } = await import('../ui-detector');
85
- const result = detectUiSprint();
86
- expect(result.isUiSprint).toBe(true);
87
- });
88
-
89
- it('should handle renamed files correctly', async () => {
90
- mockExecSync.mockReturnValue('views/a.html → views/b.html\n');
99
+ it('should return true for renamed UI files', async () => {
100
+ mockSpawnReturn('views/a.html → views/b.html\n');
91
101
  const { detectUiSprint } = await import('../ui-detector');
92
102
  const result = detectUiSprint();
93
103
  expect(result.isUiSprint).toBe(true);
@@ -95,24 +105,25 @@ describe('ui-detector', () => {
95
105
  });
96
106
 
97
107
  it('should return false for pure documentation changes', async () => {
98
- mockExecSync.mockReturnValue('docs/README.md\n');
108
+ mockSpawnReturn('docs/README.md\n');
99
109
  const { detectUiSprint } = await import('../ui-detector');
100
110
  const result = detectUiSprint();
101
111
  expect(result.isUiSprint).toBe(false);
102
112
  });
103
113
 
104
114
  it('should use main as default base branch', async () => {
105
- mockExecSync.mockReturnValue('');
115
+ mockSpawnReturn('');
106
116
  const { detectUiSprint } = await import('../ui-detector');
107
117
  detectUiSprint();
108
- expect(mockExecSync).toHaveBeenCalledWith(
109
- expect.stringContaining('git diff --name-only main..HEAD'),
118
+ expect(mockSpawnSync).toHaveBeenCalledWith(
119
+ 'git',
120
+ ['diff', '--name-only', 'main..HEAD'],
110
121
  expect.any(Object)
111
122
  );
112
123
  });
113
124
 
114
125
  it('should handle git command failure gracefully', async () => {
115
- mockExecSync.mockImplementation(() => {
126
+ mockSpawnSync.mockImplementation(() => {
116
127
  throw new Error('git not available');
117
128
  });
118
129
  const { detectUiSprint } = await import('../ui-detector');
@@ -120,8 +131,36 @@ describe('ui-detector', () => {
120
131
  expect(result.isUiSprint).toBe(false);
121
132
  });
122
133
 
134
+ it('should treat non-zero git exit status as failure (Issue #155)', async () => {
135
+ mockSpawnSync.mockReturnValue({
136
+ pid: 1,
137
+ stdout: Buffer.from(''),
138
+ stderr: Buffer.from('fatal: bad revision'),
139
+ status: 128,
140
+ signal: null,
141
+ output: [null, '', 'fatal: bad revision'],
142
+ } as unknown as ReturnType<typeof spawnSync>);
143
+ const { detectUiSprint } = await import('../ui-detector');
144
+ const result = detectUiSprint('main');
145
+ expect(result.isUiSprint).toBe(false);
146
+ });
147
+
148
+ it('should not interpret shell metacharacters in baseBranch (Issue #155)', async () => {
149
+ mockSpawnReturn('');
150
+ const { getChangedFiles } = await import('../ui-detector');
151
+ const malicious = 'main; touch /tmp/xp-gate-pwned';
152
+ getChangedFiles(malicious);
153
+ expect(mockSpawnSync).toHaveBeenCalledWith(
154
+ 'git',
155
+ ['diff', '--name-only', `${malicious}..HEAD`],
156
+ expect.objectContaining({ shell: false })
157
+ );
158
+ const callArgs = mockSpawnSync.mock.calls[0];
159
+ expect(Array.isArray(callArgs[1])).toBe(true);
160
+ });
161
+
123
162
  it('should handle multiple UI files across different types', async () => {
124
- mockExecSync.mockReturnValue('views/index.njk\nsrc/components/Button.tsx\nsrc/auth.ts\n');
163
+ mockSpawnReturn('views/index.njk\nsrc/components/Button.tsx\nsrc/auth.ts\n');
125
164
  const { detectUiSprint } = await import('../ui-detector');
126
165
  const result = detectUiSprint();
127
166
  expect(result.isUiSprint).toBe(true);
@@ -129,6 +168,29 @@ describe('ui-detector', () => {
129
168
  });
130
169
  });
131
170
 
171
+ describe('getChangedFiles', () => {
172
+ it('parses git diff output into file array', async () => {
173
+ mockSpawnReturn('src/a.ts\nsrc/b.ts\n');
174
+ const { getChangedFiles } = await import('../ui-detector');
175
+ const files = getChangedFiles('main');
176
+ expect(files).toEqual(['src/a.ts', 'src/b.ts']);
177
+ });
178
+
179
+ it('returns empty array for empty diff', async () => {
180
+ mockSpawnReturn('');
181
+ const { getChangedFiles } = await import('../ui-detector');
182
+ const files = getChangedFiles('main');
183
+ expect(files).toEqual([]);
184
+ });
185
+
186
+ it('filters out empty lines', async () => {
187
+ mockSpawnReturn('src/a.ts\n\nsrc/b.ts\n');
188
+ const { getChangedFiles } = await import('../ui-detector');
189
+ const files = getChangedFiles('main');
190
+ expect(files).toEqual(['src/a.ts', 'src/b.ts']);
191
+ });
192
+ });
193
+
132
194
  describe('parseRenamedFile', () => {
133
195
  it('should extract new path from renamed file', async () => {
134
196
  const { parseRenamedFile } = await import('../ui-detector');
@@ -197,4 +259,102 @@ describe('ui-detector', () => {
197
259
  expect(rules).toContain('style-.css');
198
260
  });
199
261
  });
262
+
263
+ describe('collectUiMatches', () => {
264
+ it('should detect UI files in components directory', async () => {
265
+ const { collectUiMatches } = await import('../ui-detector');
266
+ const result = collectUiMatches(['src/components/Button.tsx', 'src/utils/helper.ts']);
267
+ expect(result.isUiSprint).toBe(true);
268
+ expect(result.matchedFiles).toEqual(['src/components/Button.tsx']);
269
+ expect(result.matchedRules.length).toBeGreaterThan(0);
270
+ });
271
+
272
+ it('should exclude __tests__ directory files', async () => {
273
+ const { collectUiMatches } = await import('../ui-detector');
274
+ const result = collectUiMatches(['src/__tests__/Button.test.tsx']);
275
+ expect(result.isUiSprint).toBe(false);
276
+ });
277
+
278
+ it('excludes files under src/coverage/', async () => {
279
+ const { collectUiMatches } = await import('../ui-detector');
280
+ const result = collectUiMatches(['src/coverage/components/Coverage.tsx']);
281
+ expect(result.isUiSprint).toBe(false);
282
+ });
283
+
284
+ it('should handle empty file list', async () => {
285
+ const { collectUiMatches } = await import('../ui-detector');
286
+ const result = collectUiMatches([]);
287
+ expect(result.isUiSprint).toBe(false);
288
+ expect(result.matchedFiles).toEqual([]);
289
+ });
290
+
291
+ it('should collect multiple UI files', async () => {
292
+ const { collectUiMatches } = await import('../ui-detector');
293
+ const result = collectUiMatches([
294
+ 'src/components/Header.tsx',
295
+ 'src/utils/api.ts',
296
+ 'views/styles/app.css',
297
+ ]);
298
+ expect(result.isUiSprint).toBe(true);
299
+ expect(result.matchedFiles).toHaveLength(2);
300
+ });
301
+
302
+ it('should aggregate unique rules across files', async () => {
303
+ const { collectUiMatches } = await import('../ui-detector');
304
+ const result = collectUiMatches([
305
+ 'src/components/Button.tsx',
306
+ 'src/components/Card.tsx',
307
+ ]);
308
+ expect(result.isUiSprint).toBe(true);
309
+ expect(result.matchedFiles.length).toBeGreaterThan(0);
310
+ });
311
+
312
+ it('should respect .ui-gate-ignore patterns', async () => {
313
+ const { collectUiMatches } = await import('../ui-detector');
314
+ const withoutIgnore = collectUiMatches(['legacy/components/Old.tsx']);
315
+ expect(withoutIgnore.isUiSprint).toBe(true);
316
+ });
317
+ });
318
+
319
+ describe('isExcluded and loadUiGateIgnore', () => {
320
+ it('excludes paths matching **/coverage/** with leading directory', async () => {
321
+ const { isExcluded } = await import('../ui-detector');
322
+ expect(isExcluded('src/coverage/report.html', ['**/coverage/**'])).toBe(true);
323
+ });
324
+
325
+ it('does not match non-excluded paths', async () => {
326
+ const { isExcluded } = await import('../ui-detector');
327
+ expect(isExcluded('src/app.ts', ['**/coverage/**'])).toBe(false);
328
+ });
329
+
330
+ it('returns empty array when .ui-gate-ignore does not exist', async () => {
331
+ const { loadUiGateIgnore } = await import('../ui-detector');
332
+ expect(loadUiGateIgnore('/tmp')).toEqual([]);
333
+ });
334
+
335
+ it('excludes paths matching **/node_modules/** with leading directory', async () => {
336
+ const { isExcluded } = await import('../ui-detector');
337
+ expect(isExcluded('lib/node_modules/pkg/index.js', ['**/node_modules/**'])).toBe(true);
338
+ });
339
+
340
+ it('excludes paths matching **/dist/** with leading directory', async () => {
341
+ const { isExcluded } = await import('../ui-detector');
342
+ expect(isExcluded('src/dist/bundle.js', ['**/dist/**'])).toBe(true);
343
+ });
344
+
345
+ it('excludes paths matching **/build/** with leading directory', async () => {
346
+ const { isExcluded } = await import('../ui-detector');
347
+ expect(isExcluded('src/build/output.js', ['**/build/**'])).toBe(true);
348
+ });
349
+
350
+ it('excludes paths matching **/__tests__/**', async () => {
351
+ const { isExcluded } = await import('../ui-detector');
352
+ expect(isExcluded('src/__tests__/Button.test.tsx', ['**/__tests__/**'])).toBe(true);
353
+ });
354
+
355
+ it('excludes paths matching **/*.test.*', async () => {
356
+ const { isExcluded } = await import('../ui-detector');
357
+ expect(isExcluded('src/Button.test.tsx', ['**/*.test.*'])).toBe(true);
358
+ });
359
+ });
200
360
  });
@@ -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
+ });
@@ -27,6 +27,7 @@ describe('uninstall', () => {
27
27
  delete require.cache[require.resolve('../uninstall')];
28
28
  delete require.cache[require.resolve('../init')];
29
29
  delete require.cache[require.resolve('../detect-deps.js')];
30
+ delete require.cache[require.resolve('../shared-paths')];
30
31
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
31
32
  warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
32
33
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});