@boyingliu01/xp-gate 0.7.1 → 0.8.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.
Files changed (165) hide show
  1. package/lib/__tests__/detect-deps.test.js +45 -12
  2. package/lib/__tests__/init.test.js +42 -11
  3. package/lib/__tests__/ui-detector.test.ts +67 -21
  4. package/lib/detect-deps.js +70 -68
  5. package/lib/ui-detector.ts +17 -11
  6. package/package.json +1 -1
  7. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  8. package/plugins/claude-code/skills/admin-template-guidelines/SKILL.md +904 -0
  9. package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +45 -0
  10. package/plugins/claude-code/skills/delphi-review/AGENTS.md +66 -0
  11. package/plugins/claude-code/skills/delphi-review/INSTALL.md +152 -0
  12. package/plugins/claude-code/skills/delphi-review/SKILL.md +454 -0
  13. package/plugins/claude-code/skills/delphi-review/opencode.json.delphi.example +56 -0
  14. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +486 -0
  15. package/plugins/claude-code/skills/improve-codebase-architecture/SKILL.md +156 -0
  16. package/plugins/claude-code/skills/ralph-loop/SKILL.md +501 -0
  17. package/plugins/claude-code/skills/ralph-loop/references/components/memory.md +55 -0
  18. package/plugins/claude-code/skills/ralph-loop/references/components/middleware.md +54 -0
  19. package/plugins/claude-code/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  20. package/plugins/claude-code/skills/ralph-loop/references/components/system-prompt.md +24 -0
  21. package/plugins/claude-code/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  22. package/plugins/claude-code/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  23. package/plugins/claude-code/skills/ralph-loop/templates/progress-log.md +36 -0
  24. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +68 -0
  25. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1248 -0
  26. package/plugins/claude-code/skills/sprint-flow/references/components/memory.md +87 -0
  27. package/plugins/claude-code/skills/sprint-flow/references/components/middleware.md +72 -0
  28. package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  29. package/plugins/claude-code/skills/sprint-flow/references/components/system-prompt.md +27 -0
  30. package/plugins/claude-code/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  31. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +203 -0
  32. package/plugins/claude-code/skills/sprint-flow/references/phase-0-think.md +115 -0
  33. package/plugins/claude-code/skills/sprint-flow/references/phase-1-plan.md +178 -0
  34. package/plugins/claude-code/skills/sprint-flow/references/phase-2-build.md +198 -0
  35. package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +213 -0
  36. package/plugins/claude-code/skills/sprint-flow/references/phase-4-uat.md +125 -0
  37. package/plugins/claude-code/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  38. package/plugins/claude-code/skills/sprint-flow/references/phase-6-ship.md +193 -0
  39. package/plugins/claude-code/skills/sprint-flow/references/phase-7-land.md +140 -0
  40. package/plugins/claude-code/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  41. package/plugins/claude-code/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +257 -0
  42. package/plugins/claude-code/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  43. package/plugins/claude-code/skills/sprint-flow/templates/auto-estimate-output-template.md +132 -0
  44. package/plugins/claude-code/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  45. package/plugins/claude-code/skills/sprint-flow/templates/pain-document-template.md +115 -0
  46. package/plugins/claude-code/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  47. package/plugins/claude-code/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  48. package/plugins/claude-code/skills/test-driven-development/SKILL.md +71 -0
  49. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +56 -0
  50. package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +702 -0
  51. package/plugins/claude-code/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  52. package/plugins/claude-code/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  53. package/plugins/claude-code/skills/test-specification-alignment/references/specification-format.md +348 -0
  54. package/plugins/claude-code/skills/to-issues/SKILL.md +277 -0
  55. package/plugins/opencode/README.md +38 -0
  56. package/plugins/opencode/index.ts +85 -0
  57. package/plugins/opencode/package.json +18 -0
  58. package/plugins/opencode/skills/admin-template-guidelines/SKILL.md +904 -0
  59. package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +45 -0
  60. package/plugins/opencode/skills/delphi-review/AGENTS.md +66 -0
  61. package/plugins/opencode/skills/delphi-review/INSTALL.md +152 -0
  62. package/plugins/opencode/skills/delphi-review/SKILL.md +454 -0
  63. package/plugins/opencode/skills/delphi-review/opencode.json.delphi.example +56 -0
  64. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +486 -0
  65. package/plugins/opencode/skills/improve-codebase-architecture/SKILL.md +156 -0
  66. package/plugins/opencode/skills/ralph-loop/SKILL.md +501 -0
  67. package/plugins/opencode/skills/ralph-loop/references/components/memory.md +55 -0
  68. package/plugins/opencode/skills/ralph-loop/references/components/middleware.md +54 -0
  69. package/plugins/opencode/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  70. package/plugins/opencode/skills/ralph-loop/references/components/system-prompt.md +24 -0
  71. package/plugins/opencode/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  72. package/plugins/opencode/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  73. package/plugins/opencode/skills/ralph-loop/templates/progress-log.md +36 -0
  74. package/plugins/opencode/skills/sprint-flow/AGENTS.md +68 -0
  75. package/plugins/opencode/skills/sprint-flow/SKILL.md +1248 -0
  76. package/plugins/opencode/skills/sprint-flow/references/components/memory.md +87 -0
  77. package/plugins/opencode/skills/sprint-flow/references/components/middleware.md +72 -0
  78. package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  79. package/plugins/opencode/skills/sprint-flow/references/components/system-prompt.md +27 -0
  80. package/plugins/opencode/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  81. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +203 -0
  82. package/plugins/opencode/skills/sprint-flow/references/phase-0-think.md +115 -0
  83. package/plugins/opencode/skills/sprint-flow/references/phase-1-plan.md +178 -0
  84. package/plugins/opencode/skills/sprint-flow/references/phase-2-build.md +198 -0
  85. package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +213 -0
  86. package/plugins/opencode/skills/sprint-flow/references/phase-4-uat.md +125 -0
  87. package/plugins/opencode/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  88. package/plugins/opencode/skills/sprint-flow/references/phase-6-ship.md +193 -0
  89. package/plugins/opencode/skills/sprint-flow/references/phase-7-land.md +140 -0
  90. package/plugins/opencode/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  91. package/plugins/opencode/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +257 -0
  92. package/plugins/opencode/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  93. package/plugins/opencode/skills/sprint-flow/templates/auto-estimate-output-template.md +132 -0
  94. package/plugins/opencode/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  95. package/plugins/opencode/skills/sprint-flow/templates/pain-document-template.md +115 -0
  96. package/plugins/opencode/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  97. package/plugins/opencode/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  98. package/plugins/opencode/skills/test-driven-development/SKILL.md +71 -0
  99. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +56 -0
  100. package/plugins/opencode/skills/test-specification-alignment/SKILL.md +702 -0
  101. package/plugins/opencode/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  102. package/plugins/opencode/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  103. package/plugins/opencode/skills/test-specification-alignment/references/specification-format.md +348 -0
  104. package/plugins/opencode/skills/to-issues/SKILL.md +277 -0
  105. package/plugins/opencode/tsconfig.json +15 -0
  106. package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +904 -0
  107. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +45 -0
  108. package/plugins/qoder/skills/delphi-review/AGENTS.md +66 -0
  109. package/plugins/qoder/skills/delphi-review/INSTALL.md +152 -0
  110. package/plugins/qoder/skills/delphi-review/SKILL.md +454 -0
  111. package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +56 -0
  112. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +486 -0
  113. package/plugins/qoder/skills/improve-codebase-architecture/SKILL.md +156 -0
  114. package/plugins/qoder/skills/ralph-loop/SKILL.md +501 -0
  115. package/plugins/qoder/skills/ralph-loop/references/components/memory.md +55 -0
  116. package/plugins/qoder/skills/ralph-loop/references/components/middleware.md +54 -0
  117. package/plugins/qoder/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  118. package/plugins/qoder/skills/ralph-loop/references/components/system-prompt.md +24 -0
  119. package/plugins/qoder/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  120. package/plugins/qoder/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  121. package/plugins/qoder/skills/ralph-loop/templates/progress-log.md +36 -0
  122. package/plugins/qoder/skills/sprint-flow/AGENTS.md +68 -0
  123. package/plugins/qoder/skills/sprint-flow/SKILL.md +1248 -0
  124. package/plugins/qoder/skills/sprint-flow/references/components/memory.md +87 -0
  125. package/plugins/qoder/skills/sprint-flow/references/components/middleware.md +72 -0
  126. package/plugins/qoder/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  127. package/plugins/qoder/skills/sprint-flow/references/components/system-prompt.md +27 -0
  128. package/plugins/qoder/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  129. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +203 -0
  130. package/plugins/qoder/skills/sprint-flow/references/phase-0-think.md +115 -0
  131. package/plugins/qoder/skills/sprint-flow/references/phase-1-plan.md +178 -0
  132. package/plugins/qoder/skills/sprint-flow/references/phase-2-build.md +198 -0
  133. package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +213 -0
  134. package/plugins/qoder/skills/sprint-flow/references/phase-4-uat.md +125 -0
  135. package/plugins/qoder/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  136. package/plugins/qoder/skills/sprint-flow/references/phase-6-ship.md +193 -0
  137. package/plugins/qoder/skills/sprint-flow/references/phase-7-land.md +140 -0
  138. package/plugins/qoder/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  139. package/plugins/qoder/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +257 -0
  140. package/plugins/qoder/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  141. package/plugins/qoder/skills/sprint-flow/templates/auto-estimate-output-template.md +132 -0
  142. package/plugins/qoder/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  143. package/plugins/qoder/skills/sprint-flow/templates/pain-document-template.md +115 -0
  144. package/plugins/qoder/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  145. package/plugins/qoder/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  146. package/plugins/qoder/skills/test-driven-development/SKILL.md +71 -0
  147. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +56 -0
  148. package/plugins/qoder/skills/test-specification-alignment/SKILL.md +702 -0
  149. package/plugins/qoder/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  150. package/plugins/qoder/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  151. package/plugins/qoder/skills/test-specification-alignment/references/specification-format.md +348 -0
  152. package/plugins/qoder/skills/to-issues/SKILL.md +277 -0
  153. package/skills/admin-template-guidelines/SKILL.md +904 -0
  154. package/skills/improve-codebase-architecture/SKILL.md +156 -0
  155. package/skills/sprint-flow/SKILL.md +26 -8
  156. package/skills/test-driven-development/SKILL.md +71 -0
  157. package/skills/to-issues/SKILL.md +277 -0
  158. package/skills/delphi-review/evals/evals.json +0 -82
  159. package/skills/ralph-loop/evals/evals.json +0 -311
  160. package/skills/ralph-loop/evolution-history.json +0 -59
  161. package/skills/ralph-loop/evolution-log.md +0 -16
  162. package/skills/sprint-flow/evals/evals.json +0 -130
  163. package/skills/sprint-flow/evolution-history.json +0 -39
  164. package/skills/sprint-flow/evolution-log.md +0 -23
  165. package/skills/test-specification-alignment/evals/evals.json +0 -75
@@ -342,20 +342,14 @@ describe('detect-deps', () => {
342
342
 
343
343
  it('creates skills directory if it does not exist', async () => {
344
344
  const { autoInstallDeps } = require('../detect-deps');
345
- // Mock execSync to avoid actual git clone
346
- const { execSync } = require('child_process');
347
- vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
348
- // Simulate successful clone by creating the target dir
349
- const match = cmd.match(/"([^"]+)"$/);
350
- if (match) {
351
- fs.mkdirSync(match[1], { recursive: true });
352
- fs.writeFileSync(path.join(match[1], 'package.json'), JSON.stringify({ version: '2.0.0' }));
353
- }
354
- return '';
345
+ vi.spyOn(require('child_process'), 'spawnSync').mockImplementation((cmd, args) => {
346
+ const destPath = args[args.length - 1];
347
+ fs.mkdirSync(destPath, { recursive: true });
348
+ fs.writeFileSync(path.join(destPath, 'package.json'), JSON.stringify({ version: '2.0.0' }));
349
+ return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
355
350
  });
356
351
 
357
352
  const result = await autoInstallDeps();
358
- // Should have attempted to install
359
353
  expect(result.ok).toBe(true);
360
354
  expect(result.installed).toContain('superpowers');
361
355
  expect(result.installed).toContain('gstack');
@@ -365,7 +359,7 @@ describe('detect-deps', () => {
365
359
 
366
360
  it('returns errors when git clone fails', async () => {
367
361
  const { autoInstallDeps } = require('../detect-deps');
368
- vi.spyOn(require('child_process'), 'execSync').mockImplementation(() => {
362
+ vi.spyOn(require('child_process'), 'spawnSync').mockImplementation(() => {
369
363
  throw new Error('git not found');
370
364
  });
371
365
 
@@ -376,5 +370,44 @@ describe('detect-deps', () => {
376
370
 
377
371
  vi.restoreAllMocks();
378
372
  });
373
+
374
+ it('returns errors when git clone exits non-zero (Issue #155)', async () => {
375
+ const { autoInstallDeps } = require('../detect-deps');
376
+ vi.spyOn(require('child_process'), 'spawnSync').mockImplementation(() => ({
377
+ status: 128,
378
+ stdout: Buffer.from(''),
379
+ stderr: Buffer.from('fatal: repository not found'),
380
+ }));
381
+
382
+ const result = await autoInstallDeps();
383
+ expect(result.ok).toBe(false);
384
+ expect(result.errors.length).toBeGreaterThan(0);
385
+ expect(result.errors[0].message).toMatch(/repository not found|git clone failed|exit/i);
386
+
387
+ vi.restoreAllMocks();
388
+ });
389
+
390
+ it('passes repoUrl as argv[] argument, never via shell string (Issue #155)', async () => {
391
+ const { autoInstallDeps } = require('../detect-deps');
392
+ const spy = vi.spyOn(require('child_process'), 'spawnSync').mockImplementation((cmd, args) => {
393
+ const destPath = args[args.length - 1];
394
+ fs.mkdirSync(destPath, { recursive: true });
395
+ fs.writeFileSync(path.join(destPath, 'package.json'), JSON.stringify({ version: '2.0.0' }));
396
+ return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
397
+ });
398
+
399
+ await autoInstallDeps();
400
+
401
+ expect(spy).toHaveBeenCalled();
402
+ for (const call of spy.mock.calls) {
403
+ const [cmd, args, opts] = call;
404
+ expect(cmd).toBe('git');
405
+ expect(Array.isArray(args)).toBe(true);
406
+ expect(args[0]).toBe('clone');
407
+ expect(opts && opts.shell).not.toBe(true);
408
+ }
409
+
410
+ vi.restoreAllMocks();
411
+ });
379
412
  });
380
413
  });
@@ -28,6 +28,16 @@ describe('init', () => {
28
28
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
29
29
  warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
30
30
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
31
+ vi.spyOn(childProcess, 'spawnSync').mockImplementation((cmd, args) => {
32
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
33
+ return {
34
+ status: 1,
35
+ stdout: Buffer.from(''),
36
+ stderr: Buffer.from('git clone disabled in test (default stub)'),
37
+ };
38
+ }
39
+ return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
40
+ });
31
41
  });
32
42
 
33
43
  afterEach(() => {
@@ -56,7 +66,21 @@ describe('init', () => {
56
66
  });
57
67
  }
58
68
 
69
+ function stubSpawnGitClone(status = 0, stderr = '') {
70
+ vi.spyOn(childProcess, 'spawnSync').mockImplementation((cmd, args) => {
71
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
72
+ return {
73
+ status,
74
+ stdout: Buffer.from(''),
75
+ stderr: Buffer.from(stderr),
76
+ };
77
+ }
78
+ return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
79
+ });
80
+ }
81
+
59
82
  function mockExecSuccess() {
83
+ stubSpawnGitClone(1, 'git clone disabled in test');
60
84
  execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
61
85
  if (cmd === 'git rev-parse --git-dir') {
62
86
  return path.join(tmpProject, '.git') + '\n';
@@ -69,6 +93,7 @@ describe('init', () => {
69
93
  }
70
94
 
71
95
  function mockExecGitDirOnly() {
96
+ stubSpawnGitClone(1, 'git clone disabled in test');
72
97
  execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
73
98
  if (cmd === 'git rev-parse --git-dir') {
74
99
  return path.join(tmpProject, '.git') + '\n';
@@ -81,6 +106,7 @@ describe('init', () => {
81
106
  }
82
107
 
83
108
  function mockExecFail() {
109
+ stubSpawnGitClone(1, 'git clone disabled in test');
84
110
  execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation(() => {
85
111
  throw new Error('Not a git repo');
86
112
  });
@@ -103,12 +129,15 @@ describe('init', () => {
103
129
  });
104
130
 
105
131
  it('init([]) with missing superpowers warns Missing dependencies', async () => {
106
- // Mock execSync to make auto-install fail immediately
107
- vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
108
- if (typeof cmd === 'string' && cmd.includes('git clone')) {
109
- throw new Error('git clone not available in test');
132
+ vi.spyOn(childProcess, 'spawnSync').mockImplementation((cmd, args) => {
133
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
134
+ return {
135
+ status: 1,
136
+ stdout: Buffer.from(''),
137
+ stderr: Buffer.from('git clone not available in test'),
138
+ };
110
139
  }
111
- return '';
140
+ return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
112
141
  });
113
142
  const { init } = require('../init');
114
143
  const result = await init([]);
@@ -118,14 +147,16 @@ describe('init', () => {
118
147
  });
119
148
 
120
149
  it('init([]) with versionMismatch warns version detail', async () => {
121
- // Mock execSync to make auto-install fail immediately
122
- vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
123
- if (typeof cmd === 'string' && cmd.includes('git clone')) {
124
- throw new Error('git clone not available in test');
150
+ vi.spyOn(childProcess, 'spawnSync').mockImplementation((cmd, args) => {
151
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
152
+ return {
153
+ status: 1,
154
+ stdout: Buffer.from(''),
155
+ stderr: Buffer.from('git clone not available in test'),
156
+ };
125
157
  }
126
- return '';
158
+ return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
127
159
  });
128
- // superpowers too old, gstack good
129
160
  const sp = path.join(skillsDir(), 'superpowers');
130
161
  fs.mkdirSync(sp, { recursive: true });
131
162
  fs.writeFileSync(path.join(sp, 'package.json'), JSON.stringify({ version: '0.0.1' }));
@@ -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,28 +68,28 @@ 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);
@@ -80,7 +97,7 @@ describe('ui-detector', () => {
80
97
  });
81
98
 
82
99
  it('should return true for renamed UI files', async () => {
83
- mockExecSync.mockReturnValue('views/a.html → views/b.html\n');
100
+ mockSpawnReturn('views/a.html → views/b.html\n');
84
101
  const { detectUiSprint } = await import('../ui-detector');
85
102
  const result = detectUiSprint();
86
103
  expect(result.isUiSprint).toBe(true);
@@ -88,24 +105,25 @@ describe('ui-detector', () => {
88
105
  });
89
106
 
90
107
  it('should return false for pure documentation changes', async () => {
91
- mockExecSync.mockReturnValue('docs/README.md\n');
108
+ mockSpawnReturn('docs/README.md\n');
92
109
  const { detectUiSprint } = await import('../ui-detector');
93
110
  const result = detectUiSprint();
94
111
  expect(result.isUiSprint).toBe(false);
95
112
  });
96
113
 
97
114
  it('should use main as default base branch', async () => {
98
- mockExecSync.mockReturnValue('');
115
+ mockSpawnReturn('');
99
116
  const { detectUiSprint } = await import('../ui-detector');
100
117
  detectUiSprint();
101
- expect(mockExecSync).toHaveBeenCalledWith(
102
- expect.stringContaining('git diff --name-only main..HEAD'),
118
+ expect(mockSpawnSync).toHaveBeenCalledWith(
119
+ 'git',
120
+ ['diff', '--name-only', 'main..HEAD'],
103
121
  expect.any(Object)
104
122
  );
105
123
  });
106
124
 
107
125
  it('should handle git command failure gracefully', async () => {
108
- mockExecSync.mockImplementation(() => {
126
+ mockSpawnSync.mockImplementation(() => {
109
127
  throw new Error('git not available');
110
128
  });
111
129
  const { detectUiSprint } = await import('../ui-detector');
@@ -113,8 +131,36 @@ describe('ui-detector', () => {
113
131
  expect(result.isUiSprint).toBe(false);
114
132
  });
115
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
+
116
162
  it('should handle multiple UI files across different types', async () => {
117
- 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');
118
164
  const { detectUiSprint } = await import('../ui-detector');
119
165
  const result = detectUiSprint();
120
166
  expect(result.isUiSprint).toBe(true);
@@ -124,21 +170,21 @@ describe('ui-detector', () => {
124
170
 
125
171
  describe('getChangedFiles', () => {
126
172
  it('parses git diff output into file array', async () => {
127
- mockExecSync.mockReturnValue('src/a.ts\nsrc/b.ts\n');
173
+ mockSpawnReturn('src/a.ts\nsrc/b.ts\n');
128
174
  const { getChangedFiles } = await import('../ui-detector');
129
175
  const files = getChangedFiles('main');
130
176
  expect(files).toEqual(['src/a.ts', 'src/b.ts']);
131
177
  });
132
178
 
133
179
  it('returns empty array for empty diff', async () => {
134
- mockExecSync.mockReturnValue('');
180
+ mockSpawnReturn('');
135
181
  const { getChangedFiles } = await import('../ui-detector');
136
182
  const files = getChangedFiles('main');
137
183
  expect(files).toEqual([]);
138
184
  });
139
185
 
140
186
  it('filters out empty lines', async () => {
141
- mockExecSync.mockReturnValue('src/a.ts\n\nsrc/b.ts\n');
187
+ mockSpawnReturn('src/a.ts\n\nsrc/b.ts\n');
142
188
  const { getChangedFiles } = await import('../ui-detector');
143
189
  const files = getChangedFiles('main');
144
190
  expect(files).toEqual(['src/a.ts', 'src/b.ts']);
@@ -179,89 +179,91 @@ async function checkDeps(platform = 'opencode') {
179
179
  * @param {string} [platform='opencode'] - AI agent platform
180
180
  * @returns {Promise<{ok: boolean, installed?: string[], errors?: Array<{name: string, message: string}>}>}
181
181
  */
182
- async function autoInstallDeps(platform = 'opencode') {
183
- const skillsDirs = getSkillsDirs(platform);
184
- const targetDir = skillsDirs[0]; // Install to the primary skills directory
182
+ function runGitClone(repoUrl, destPath) {
183
+ const cp = require('child_process');
184
+ const result = cp.spawnSync(
185
+ 'git',
186
+ ['clone', '--depth', '1', repoUrl, destPath],
187
+ { stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000, shell: false }
188
+ );
189
+ if (result.status === 0) return;
190
+ const stderr = String(result.stderr ?? '').trim();
191
+ throw new Error(stderr || `git clone exited with status ${result.status}`);
192
+ }
185
193
 
186
- if (!fs.existsSync(targetDir)) {
187
- try {
188
- fs.mkdirSync(targetDir, { recursive: true });
189
- } catch (e) {
190
- return {
191
- ok: false,
192
- errors: [{ name: 'mkdir', message: `Cannot create skills directory: ${e.message}` }]
193
- };
194
- }
194
+ function ensureTargetDir(targetDir) {
195
+ if (fs.existsSync(targetDir)) return null;
196
+ try {
197
+ fs.mkdirSync(targetDir, { recursive: true });
198
+ return null;
199
+ } catch (e) {
200
+ return { name: 'mkdir', message: `Cannot create skills directory: ${e.message}` };
195
201
  }
202
+ }
196
203
 
197
- const check = await checkDeps(platform);
198
- if (check.ok) {
199
- return { ok: true, installed: [] };
200
- }
204
+ function versionMismatchError(versionMismatch) {
205
+ return {
206
+ name: versionMismatch.name,
207
+ message: `version mismatch: need ${versionMismatch.required}, found ${versionMismatch.found} (auto-install cannot upgrade)`,
208
+ };
209
+ }
201
210
 
202
- const errors = [];
203
- const installed = [];
211
+ function depExistsIn(skillsDirs, depName) {
212
+ for (const baseDir of skillsDirs) {
213
+ if (fs.existsSync(path.join(baseDir, depName))) return true;
214
+ }
215
+ return false;
216
+ }
204
217
 
205
- // Check each dep: missing → install, version mismatch → can't auto-fix
206
- const depCheck = await checkDeps(platform);
207
- if (depCheck.ok) {
208
- return { ok: true, installed: [] };
218
+ function safeRemove(destPath) {
219
+ try {
220
+ if (fs.existsSync(destPath)) {
221
+ fs.rmSync(destPath, { recursive: true, force: true });
222
+ }
223
+ } catch {
224
+ // best-effort cleanup of partial clone
209
225
  }
226
+ }
210
227
 
211
- // If the issue is version mismatch, auto-install can't help
212
- if (depCheck.versionMismatch) {
213
- return {
214
- ok: false,
215
- installed: [],
216
- errors: [{
217
- name: depCheck.versionMismatch.name,
218
- message: `version mismatch: need ${depCheck.versionMismatch.required}, found ${depCheck.versionMismatch.found} (auto-install cannot upgrade)`
219
- }]
220
- };
228
+ function installOneDep(dep, targetDir) {
229
+ const destPath = path.join(targetDir, dep.name);
230
+ const repoUrl = `https://github.com/${dep.repo}.git`;
231
+ try {
232
+ console.log(` ${dep.name}: not found → installing from ${repoUrl} ...`);
233
+ runGitClone(repoUrl, destPath);
234
+ console.log(` ${dep.name}: OK`);
235
+ return { ok: true };
236
+ } catch (e) {
237
+ const message = e.message || 'git clone failed';
238
+ console.warn(` ${dep.name}: FAILED (${message})`);
239
+ safeRemove(destPath);
240
+ return { ok: false, error: { name: dep.name, message } };
221
241
  }
242
+ }
222
243
 
223
- for (const dep of REQUIRED_DEPS) {
224
- // Re-check if this specific dep is missing
225
- let exists = false;
226
- for (const baseDir of skillsDirs) {
227
- if (fs.existsSync(path.join(baseDir, dep.name))) {
228
- exists = true;
229
- break;
230
- }
231
- }
232
- if (exists) continue;
244
+ async function autoInstallDeps(platform = 'opencode') {
245
+ const skillsDirs = getSkillsDirs(platform);
246
+ const targetDir = skillsDirs[0];
233
247
 
234
- const destPath = path.join(targetDir, dep.name);
235
- const repoUrl = `https://github.com/${dep.repo}.git`;
248
+ const mkdirErr = ensureTargetDir(targetDir);
249
+ if (mkdirErr) return { ok: false, errors: [mkdirErr] };
236
250
 
237
- try {
238
- console.log(` ${dep.name}: not found installing from ${repoUrl} ...`);
239
- const cp = require('child_process');
240
- cp.execSync(`git clone --depth 1 "${repoUrl}" "${destPath}"`, {
241
- encoding: 'utf8',
242
- stdio: ['ignore', 'pipe', 'pipe'],
243
- timeout: 120000, // 2 minute timeout
244
- });
245
- installed.push(dep.name);
246
- console.log(` ${dep.name}: OK`);
247
- } catch (e) {
248
- errors.push({ name: dep.name, message: e.message || 'git clone failed' });
249
- console.warn(` ${dep.name}: FAILED (${e.message || 'unknown error'})`);
250
- // Clean up partial clone
251
- try {
252
- if (fs.existsSync(destPath)) {
253
- fs.rmSync(destPath, { recursive: true, force: true });
254
- }
255
- } catch {
256
- // Ignore cleanup failures
257
- }
258
- }
251
+ const depCheck = await checkDeps(platform);
252
+ if (depCheck.ok) return { ok: true, installed: [] };
253
+ if (depCheck.versionMismatch) {
254
+ return { ok: false, installed: [], errors: [versionMismatchError(depCheck.versionMismatch)] };
259
255
  }
260
256
 
261
- if (errors.length > 0) {
262
- return { ok: false, installed, errors };
257
+ const errors = [];
258
+ const installed = [];
259
+ for (const dep of REQUIRED_DEPS) {
260
+ if (depExistsIn(skillsDirs, dep.name)) continue;
261
+ const result = installOneDep(dep, targetDir);
262
+ if (result.ok) installed.push(dep.name);
263
+ else errors.push(result.error);
263
264
  }
264
265
 
266
+ if (errors.length > 0) return { ok: false, installed, errors };
265
267
  return { ok: true, installed };
266
268
  }
267
269
 
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { spawnSync } from 'child_process';
2
2
  import { readFileSync, existsSync } from 'fs';
3
3
  import { join } from 'path';
4
4
 
@@ -75,6 +75,19 @@ function getFullExclusions(repoRoot?: string): string[] {
75
75
  return [...UI_GATE_DEFAULT_EXCLUSIONS, ...loadUiGateIgnore(repoRoot)];
76
76
  }
77
77
 
78
+ function runGitDiff(baseBranch: string): string {
79
+ const result = spawnSync(
80
+ 'git',
81
+ ['diff', '--name-only', `${baseBranch}..HEAD`],
82
+ { stdio: ['pipe', 'pipe', 'pipe'], shell: false, encoding: 'utf8' }
83
+ );
84
+ if (result.status !== 0) {
85
+ const stderr = String(result.stderr ?? '').trim();
86
+ throw new Error(stderr || `git diff exited with status ${result.status}`);
87
+ }
88
+ return String(result.stdout ?? '').trim();
89
+ }
90
+
78
91
  export function detectUiSprint(baseBranch: string = 'main'): UiDetectionResult {
79
92
  try {
80
93
  const files = getChangedFiles(baseBranch);
@@ -88,18 +101,11 @@ export function detectUiSprint(baseBranch: string = 'main'): UiDetectionResult {
88
101
  }
89
102
 
90
103
  export function getChangedFiles(baseBranch: string): string[] {
91
- const diffOutput = execSync(`git diff --name-only ${baseBranch}..HEAD`, {
92
- encoding: 'utf8',
93
- stdio: ['pipe', 'pipe', 'pipe'],
94
- }).trim();
95
-
96
- if (diffOutput === '') {
97
- return [];
98
- }
99
-
104
+ const diffOutput = runGitDiff(baseBranch);
105
+ if (diffOutput === '') return [];
100
106
  return diffOutput
101
107
  .split('\n')
102
- .filter((f) => f.length > 0)
108
+ .filter((f: string) => f.length > 0)
103
109
  .map(parseRenamedFile);
104
110
  }
105
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.7.1",
3
+ "version": "0.8.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.7.1",
3
+ "version": "0.8.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": {