@boyingliu01/xp-gate 0.26.0 → 0.27.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 (119) hide show
  1. package/bin/xp-gate.js +161 -28
  2. package/hooks/pre-commit +27 -10
  3. package/lib/__tests__/check-alignment-cli.test.mjs +77 -0
  4. package/lib/__tests__/doctor-469-hook-env.test.js +12 -7
  5. package/lib/__tests__/doctor-exec-retry.test.js +152 -0
  6. package/lib/__tests__/doctor-module-resolution-parity.test.js +15 -6
  7. package/lib/__tests__/doctor-tui-skill-freshness.test.js +16 -4
  8. package/lib/__tests__/test-alignment.test.ts +37 -0
  9. package/lib/__tests__/windows-bash-probe.test.js +123 -0
  10. package/lib/check-alignment-runner.cjs +74 -0
  11. package/lib/detect-deps.js +139 -20
  12. package/lib/doctor.js +45 -11
  13. package/lib/gate-runner.js +12 -5
  14. package/lib/mattpocock-license-verdict.json +1 -1
  15. package/lib/sprint-status.js +5 -2
  16. package/lib/test-alignment.ts +136 -12
  17. package/mock-policy/AGENTS.md +2 -2
  18. package/mutation/AGENTS.md +2 -2
  19. package/package.json +1 -1
  20. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  21. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  22. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  23. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  24. package/plugins/dsh/README.md +1 -1
  25. package/plugins/dsh/package.json +1 -1
  26. package/plugins/dsh/scripts/prepack.cjs +2 -2
  27. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  28. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  29. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  30. package/plugins/dsh/src/skills.test.ts +20 -7
  31. package/plugins/opencode/package.json +1 -1
  32. package/plugins/opencode/scripts/prepack.cjs +0 -1
  33. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/qoder/plugin.json +1 -1
  37. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/principles/AGENTS.md +2 -2
  41. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +9 -8
  42. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +3 -4
  43. package/principles/rules/__tests__/clean-code/god-class.test.ts +2 -4
  44. package/principles/rules/__tests__/clean-code/large-file.test.ts +2 -4
  45. package/principles/rules/__tests__/clean-code/long-function.test.ts +2 -4
  46. package/principles/rules/__tests__/clean-code/many-exports.test.ts +2 -3
  47. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +2 -4
  48. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +2 -4
  49. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +4 -4
  50. package/principles/rules/__tests__/solid/dip.test.ts +2 -4
  51. package/principles/rules/__tests__/solid/isp.test.ts +2 -4
  52. package/principles/rules/__tests__/solid/lsp.test.ts +2 -4
  53. package/principles/rules/__tests__/solid/ocp.test.ts +2 -4
  54. package/principles/rules/__tests__/solid/srp.test.ts +2 -4
  55. package/principles/rules/clean-code/code-duplication.ts +12 -14
  56. package/principles/rules/clean-code/deep-nesting.ts +18 -20
  57. package/principles/rules/clean-code/god-class.ts +32 -34
  58. package/principles/rules/clean-code/large-file.ts +14 -16
  59. package/principles/rules/clean-code/long-function.ts +26 -28
  60. package/principles/rules/clean-code/magic-numbers.ts +39 -38
  61. package/principles/rules/clean-code/many-exports.ts +21 -23
  62. package/principles/rules/clean-code/missing-error-handling.ts +13 -15
  63. package/principles/rules/clean-code/too-many-params.ts +15 -17
  64. package/principles/rules/clean-code/unused-imports.ts +13 -15
  65. package/principles/rules/solid/dip.ts +16 -18
  66. package/principles/rules/solid/isp.ts +17 -19
  67. package/principles/rules/solid/lsp.ts +4 -6
  68. package/principles/rules/solid/ocp.ts +17 -19
  69. package/principles/rules/solid/srp.ts +20 -22
  70. package/scripts/__tests__/sync-package-content.test.js +0 -1
  71. package/scripts/sync-package-content.js +0 -1
  72. package/skills/delphi-review/AGENTS.md +2 -2
  73. package/skills/sprint-flow/AGENTS.md +2 -2
  74. package/skills/test-specification-alignment/AGENTS.md +2 -2
  75. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +0 -48
  76. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  77. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  78. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  79. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  80. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  81. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.sh +0 -161
  82. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  83. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  84. package/plugins/dsh/skills/clipboard-vision/SKILL.md +0 -48
  85. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  87. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  88. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  89. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  90. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.sh +0 -161
  91. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  92. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  93. package/plugins/opencode/skills/clipboard-vision/SKILL.md +0 -48
  94. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  95. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  96. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  97. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  98. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  99. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.sh +0 -161
  100. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  101. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  102. package/plugins/qoder/skills/clipboard-vision/SKILL.md +0 -48
  103. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  104. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  105. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  106. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  107. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  108. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +0 -161
  109. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  110. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  111. package/skills/clipboard-vision/SKILL.md +0 -48
  112. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  113. package/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  114. package/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  115. package/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  116. package/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  117. package/skills/clipboard-vision/describe-clipboard.sh +0 -161
  118. package/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  119. package/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
package/bin/xp-gate.js CHANGED
@@ -19,15 +19,127 @@ const { bootstrap } = require('../lib/bootstrap.js');
19
19
  const { updateHooks } = require('../lib/update-hooks.js');
20
20
  const gateRunner = require('../lib/gate-runner.js');
21
21
 
22
+ // #392-#6: a user-supplied path embedded in a shell command string lets cmd.exe
23
+ // interpret metacharacters (& | ^ < > % ! " and newline) and execute injected
24
+ // commands. On POSIX a bare spawn (shell:false) passes argv as discrete,
25
+ // uninterpreted bytes, so nothing is injectable. On win32 a `.cmd` wrapper such
26
+ // as npx.cmd cannot be launched without a shell (CreateProcess raises EINVAL),
27
+ // so we must shell out through cmd.exe -- and we only do so AFTER rejecting any
28
+ // argument that carries a cmd metacharacter. Rejection, not escaping, is the
29
+ // safe defense: a path containing a metachar is refused outright rather than
30
+ // risk a quoting mistake. This guard must stay in front of every launcher.
31
+ // \r joins \n: cmd treats a stray carriage return as a command separator too
32
+ // (Delphi walkthrough R1, MN-01).
33
+ const CMD_METACHAR_RE = /[&|^<>%!"\n\r]/;
34
+
35
+ // The ONLY place a child process may be launched with an engaged shell.
36
+ // Named so a static grep stays readable, and pinned by check-alignment-cli
37
+ // .test.mjs: no other launcher in this file may enable a shell.
38
+ const SHELL_ON_WINDOWS_ONLY = process.platform === 'win32';
39
+
40
+ function firstUnsafeArg(argv) {
41
+ for (const arg of argv) {
42
+ if (typeof arg === 'string' && CMD_METACHAR_RE.test(arg)) return arg;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function quoteForCmdShell(arg) {
48
+ // With an engaged shell node concatenates file+args into one cmd.exe command
49
+ // string WITHOUT quoting (DEP0190), so an argument carrying a space would
50
+ // split into multiple argv elements downstream. Double-quoting is safe here
51
+ // ONLY because every caller has already run firstUnsafeArg: the character "
52
+ // that could break out of the quote is on the rejection list.
53
+ return `"${arg}"`;
54
+ }
55
+
56
+ function spawnNpx(tsxArgs) {
57
+ const { spawnSync } = require('child_process');
58
+ // Reject any user-supplied argument that could be interpreted by cmd.exe.
59
+ // Every element of tsxArgs beyond the fixed tokens (-y tsx <script>) is a
60
+ // user path; a metachar there is refused, not quoted, per #392-#6.
61
+ const unsafe = firstUnsafeArg(tsxArgs);
62
+ if (unsafe) {
63
+ console.error(`check-alignment: refusing path containing a shell metacharacter: ${unsafe}`);
64
+ return { status: 1 };
65
+ }
66
+ const isWin = process.platform === 'win32';
67
+ // The shell engages on win32 only, and is only safe because every user
68
+ // argument above is guaranteed metachar-free; each token additionally gets
69
+ // cmd quoting so spaces survive (see quoteForCmdShell).
70
+ // Guarded IO: a failed spawn must surface as a result, not crash the CLI.
71
+ const argv = ['-y', 'tsx', ...tsxArgs];
72
+ let result;
73
+ try {
74
+ result = spawnSync(isWin ? 'npx.cmd' : 'npx', isWin ? argv.map(quoteForCmdShell) : argv, {
75
+ stdio: 'inherit',
76
+ shell: SHELL_ON_WINDOWS_ONLY,
77
+ });
78
+ } catch (err) {
79
+ return { status: 1, error: err };
80
+ }
81
+ return result || { status: 1 };
82
+ }
83
+
22
84
  function handleUIReview() {
23
- const { execSync } = require('child_process');
24
85
  const uiReviewPath = path.join(__dirname, '..', 'lib', 'ui-review.ts');
86
+ const result = spawnNpx([uiReviewPath]);
87
+ process.exitCode = result.error ? 1 : (result.status === 0 ? 0 : 1);
88
+ }
89
+
90
+ function runAlignmentInProcess(specPath, testDirs, outputPath, jsonMode, runFullAlignment) {
91
+ const { execFileSync } = require('child_process');
92
+ if (!runFullAlignment) {
93
+ // Loaded lazily by the caller inside the tsx guard; kept optional so an
94
+ // in-repo direct call still works.
95
+ ({ runFullAlignment } = require('../lib/test-alignment.ts'));
96
+ }
97
+ let headCommit = 'unknown';
25
98
  try {
26
- execSync(`npx -y tsx "${uiReviewPath}"`, { stdio: 'inherit' });
27
- process.exit(0);
99
+ headCommit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() || 'unknown';
28
100
  } catch (err) {
29
- process.exit(1);
101
+ headCommit = 'unknown';
30
102
  }
103
+ let report;
104
+ try {
105
+ report = runFullAlignment({
106
+ specPath,
107
+ testDirs: testDirs || undefined,
108
+ headCommit,
109
+ outputPath: outputPath || undefined,
110
+ });
111
+ } catch (err) {
112
+ console.error('check-alignment failed:', err instanceof Error ? err.message : String(err));
113
+ return 1;
114
+ }
115
+ if (jsonMode) {
116
+ console.log(JSON.stringify(report, null, 2));
117
+ } else {
118
+ console.log('Status:', report.alignment_status);
119
+ console.log('Score:', report.score);
120
+ console.log('Misaligned:', report.misaligned_tests.length);
121
+ }
122
+ // exitCode-style contract (#392 review): never process.exit() right after a
123
+ // JSON print — buffered stdout must flush. Returning the code and letting the
124
+ // dispatcher end the turn naturally is what keeps --json output complete.
125
+ return report.alignment_status === 'PASS' ? 0 : 1;
126
+ }
127
+
128
+ function runAlignmentViaNpx(runnerPath, specPath, testDirs, outputPath, jsonMode) {
129
+ // User-supplied paths travel as discrete argv elements routed through
130
+ // spawnNpx, which refuses any argument carrying a cmd metacharacter before a
131
+ // shell can see it (#392-#6). The runner script path is a fixed internal path.
132
+ const argv = [runnerPath, '--spec', specPath];
133
+ if (testDirs) argv.push('--tests', testDirs[0]);
134
+ if (outputPath) argv.push('--output', outputPath);
135
+ if (jsonMode) argv.push('--json');
136
+ const result = spawnNpx(argv);
137
+ if (result.error) {
138
+ console.error('check-alignment: unable to load the alignment engine (tsx unavailable).');
139
+ console.error(result.error.message);
140
+ return 1;
141
+ }
142
+ return result.status === 0 ? 0 : 1;
31
143
  }
32
144
 
33
145
  async function runAlignmentCheck(subargs) {
@@ -42,32 +154,43 @@ async function runAlignmentCheck(subargs) {
42
154
 
43
155
  const jsonMode = subargs.includes('--json');
44
156
 
45
- // Load TS module via tsx
46
- const { execSync } = require('child_process');
47
- const alignmentPath = path.join(__dirname, '..', 'lib', 'test-alignment.ts');
157
+ // Injection guard (#392-#6) runs BEFORE any launcher, including the
158
+ // in-process fast path: a metachar path must be refused identically on both
159
+ // platforms (Delphi walkthrough R1, MJ-02 — behavioral, not shape-only).
160
+ for (const value of [specPath, ...(testDirs || []), outputPath].filter(v => v !== undefined)) {
161
+ const unsafe = firstUnsafeArg([value]);
162
+ if (unsafe) {
163
+ console.error(`check-alignment: refusing path containing a shell metacharacter: ${unsafe}`);
164
+ return 1;
165
+ }
166
+ }
167
+
168
+ const runnerPath = path.join(__dirname, '..', 'lib', 'check-alignment-runner.cjs');
48
169
 
170
+ // Primary: run the engine in-process via tsx/cjs (fast path, works in-repo
171
+ // where tsx is a devDependency). Fall back to `npx -y tsx` when tsx is not
172
+ // resolvable, which happens in the published global install (the package
173
+ // ships zero runtime deps per the zero-install contract). npx self-provisions
174
+ // tsx on demand, so the published command stays functional without adding a dep.
175
+ //
176
+ // The try guards ONLY the tsx loader's module-load step. The engine call sits
177
+ // outside it: an engine-level failure is a real bug that must surface, not be
178
+ // silently swallowed and misattributed to "tsx unavailable" by the fallback.
179
+ // Delphi walkthrough R2 CI-02: require('../lib/test-alignment.ts') also needs
180
+ // the loader, so it belongs INSIDE the guard — if the .ts file itself cannot
181
+ // load, the npx fallback re-attempts it under a provisioned tsx instead of
182
+ // crashing with an unhandled exception. Engine EXECUTION stays outside.
183
+ let runFullAlignment;
49
184
  try {
50
- execSync(`npx tsx -e "
51
- import { runFullAlignment } from '${alignmentPath}';
52
- const report = runFullAlignment({
53
- specPath: '${specPath}',
54
- testDirs: ${JSON.stringify(testDirs || null)},
55
- headCommit: require('child_process').execSync('git rev-parse HEAD 2>/dev/null || echo unknown').toString().trim(),
56
- outputPath: ${outputPath ? `'${outputPath}'` : 'undefined'},
57
- });
58
- if (${jsonMode}) {
59
- console.log(JSON.stringify(report, null, 2));
60
- } else {
61
- console.log('Status:', report.alignment_status);
62
- console.log('Score:', report.score);
63
- console.log('Misaligned:', report.misaligned_tests.length);
64
- }
65
- process.exit(report.alignment_status === 'PASS' ? 0 : 1);
66
- "`, { stdio: 'inherit' });
185
+ require('tsx/cjs');
186
+ ({ runFullAlignment } = require('../lib/test-alignment.ts'));
67
187
  } catch (err) {
68
- return 1;
188
+ if (!err || (err.code !== 'MODULE_NOT_FOUND' && !/Cannot find module/.test(String(err && err.message)))) {
189
+ throw err;
190
+ }
191
+ return runAlignmentViaNpx(runnerPath, specPath, testDirs, outputPath, jsonMode);
69
192
  }
70
- return 0;
193
+ return runAlignmentInProcess(specPath, testDirs, outputPath, jsonMode, runFullAlignment);
71
194
  }
72
195
 
73
196
  const COMMANDS = {
@@ -154,7 +277,7 @@ const COMMANDS = {
154
277
  }
155
278
  return doctor(subargs).then(code => process.exit(code));
156
279
  },
157
- usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force]] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks)'
280
+ usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force] | --refresh-env] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks; --refresh-env is read-only so it combines with them)'
158
281
  },
159
282
  'ui-review': {
160
283
  description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
@@ -188,7 +311,17 @@ const COMMANDS = {
188
311
  },
189
312
  'check-alignment': {
190
313
  description: 'Run test-specification alignment check (deterministic, no LLM)',
191
- run: subargs => runAlignmentCheck(subargs).then(code => process.exit(code)),
314
+ run: subargs => runAlignmentCheck(subargs).then(code => {
315
+ // exitCode, not process.exit(): the in-process --json document must flush
316
+ // its buffered stdout before the process ends (#392 review, MJ-01).
317
+ process.exitCode = code;
318
+ }).catch(err => {
319
+ // A quality gate must fail with a deterministic exit code and a real
320
+ // message — never an unhandled rejection whose code depends on Node's
321
+ // default mode (Delphi walkthrough R2, MJ-02).
322
+ console.error('check-alignment: unexpected failure:', err instanceof Error ? err.message : String(err));
323
+ process.exitCode = 1;
324
+ }),
192
325
  usage: 'xp-gate check-alignment [--spec <path>] [--tests <dir>] [--json] [--output <path>]'
193
326
  },
194
327
  'upgrade': {
package/hooks/pre-commit CHANGED
@@ -179,11 +179,15 @@ run_in_subdir() {
179
179
 
180
180
  if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ]; then
181
181
  # Run command in subdirectory, then return to original directory
182
- pushd "$PROJECT_SUBDIR" > /dev/null 2>&1
183
- eval "$cmd"
184
- local exit_code=$?
185
- popd > /dev/null 2>&1
186
- return $exit_code
182
+ # #392-#10: guard cd/pushd so a missing directory cannot run the gate in
183
+ # the wrong cwd.
184
+ if pushd "$PROJECT_SUBDIR" > /dev/null 2>&1; then
185
+ eval "$cmd"
186
+ local exit_code=$?
187
+ popd > /dev/null 2>&1
188
+ return $exit_code
189
+ fi
190
+ return 1
187
191
  else
188
192
  # Run in current directory
189
193
  eval "$cmd"
@@ -984,10 +988,14 @@ IN_PROJECT_SUBDIR=0
984
988
  _lang_count=$(echo "$PROJECT_LANGS" | wc -w)
985
989
  if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
986
990
  ORIGINAL_DIR=$(pwd)
987
- cd "$PROJECT_SUBDIR"
988
- IN_PROJECT_SUBDIR=1
989
- echo "Working in: $PROJECT_SUBDIR/"
990
- echo ""
991
+ # #392-#10: guard cd so a missing subdir cannot run gates in the wrong cwd.
992
+ if cd "$PROJECT_SUBDIR" 2>/dev/null; then
993
+ IN_PROJECT_SUBDIR=1
994
+ echo "Working in: $PROJECT_SUBDIR/"
995
+ echo ""
996
+ else
997
+ echo "WARN: could not enter $PROJECT_SUBDIR — continuing at repo root" >&2
998
+ fi
991
999
  fi
992
1000
 
993
1001
  # ============================================================================
@@ -1639,7 +1647,16 @@ else
1639
1647
  echo "Running jscpd for Go duplicate code detection..."
1640
1648
  FILES_TO_CHECK=$(echo "$CHANGED_FILES" | grep "\.go$" | head -20)
1641
1649
  if [ -n "$FILES_TO_CHECK" ]; then
1642
- jscpd $FILES_TO_CHECK 2>&1 | head -10
1650
+ # #392-#9: capture jscpd's real exit code via temp file instead of piping
1651
+ # through head (which would swallow the exit status under pipefail analysis).
1652
+ GO_JSCPD_TMP=$(mktemp)
1653
+ jscpd $FILES_TO_CHECK > "$GO_JSCPD_TMP" 2>&1
1654
+ JSCPD_GO_EXIT=$?
1655
+ head -10 "$GO_JSCPD_TMP"
1656
+ rm -f "$GO_JSCPD_TMP"
1657
+ if [ "$JSCPD_GO_EXIT" -ne 0 ]; then
1658
+ echo "⚠️ jscpd (Go) exited with code $JSCPD_GO_EXIT"
1659
+ fi
1643
1660
  fi
1644
1661
  echo "✅ PASSED - Go duplicate code check completed."
1645
1662
  ;;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @test check-alignment CLI
3
+ * @intent Guard the #392-#6 injection fix and the zero-install fallback path:
4
+ * (1) no spawn/exec call in bin/xp-gate.js may enable a shell (which on
5
+ * win32 would let cmd.exe interpret metacharacters embedded in
6
+ * user-supplied paths); (2) the published runner emits a complete,
7
+ * parseable --json document instead of truncating it on process.exit().
8
+ * @covers AC-392-06 (injection), zero-install contract (fallback)
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { readFileSync } from 'fs';
12
+ import { join, dirname } from 'path';
13
+ import { spawnSync } from 'child_process';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const BIN = join(here, '..', '..', 'bin', 'xp-gate.js');
18
+ const RUNNER = join(here, '..', 'check-alignment-runner.cjs');
19
+
20
+ describe('check-alignment CLI injection surface (#392-#6)', () => {
21
+ it('bin/xp-gate.js rejects shell metacharacters before any launch', () => {
22
+ const src = readFileSync(BIN, 'utf8');
23
+ // The injection guard must be in front of every subprocess launch: any
24
+ // argument carrying a cmd.exe metacharacter is refused outright, never
25
+ // escaped, before a shell can see it (#392-#6, Delphi R1-C). This holds on
26
+ // both platforms -- on win32 a .cmd wrapper needs cmd.exe (EINVAL without
27
+ // one), so the metachar guard is the only thing that makes shelling safe.
28
+ expect(src).toMatch(/CMD_METACHAR_RE/);
29
+ expect(src).toMatch(/firstUnsafeArg/);
30
+ // The metachar class must cover the cmd.exe separators/redirection.
31
+ expect(src).toMatch(/CMD_METACHAR_RE\s*=.*[&|^<>%!]/);
32
+ // Any shell a launcher may engage must be gated on the metachar rejection
33
+ // having run first, and never enabled unconditionally on win32. The single
34
+ // sanctioned engagement is the named SHELL_ON_WINDOWS_ONLY constant.
35
+ expect(src).not.toMatch(/shell\s*:\s*(?:true|process\.platform)/);
36
+ expect(src).toMatch(/shell:\s*SHELL_ON_WINDOWS_ONLY/);
37
+ // execSync interpolates argv into a command string via a shell, so it is
38
+ // banned; execFileSync is fine only for hardcoded binaries with non-user
39
+ // argv (git rev-parse HEAD). The launchers below are the user-input path.
40
+ expect(src).not.toMatch(/\bexecSync\s*\(/);
41
+ });
42
+
43
+ it('a metacharacter path is refused end to end before any launch (MJ-02)', () => {
44
+ // Behavioral, not shape: invoke the real CLI with a --spec whose path
45
+ // carries a cmd metacharacter and assert the documented refusal — exit 1
46
+ // plus the "refusing" message, never a spawned engine.
47
+ const evil = join(here, 'evil&echo.spec.yaml');
48
+ const result = spawnSync(process.execPath, [BIN, 'check-alignment', '--spec', evil, '--json'], {
49
+ encoding: 'utf8',
50
+ timeout: 60000,
51
+ });
52
+ expect(result.status).toBe(1);
53
+ expect(`${result.stderr}${result.stdout}`).toMatch(/metacharacter|refus/i);
54
+ }, 90000);
55
+ });
56
+
57
+ describe('check-alignment runner output integrity', () => {
58
+ it('emits a complete parseable JSON document with --json', () => {
59
+ const spec = join(here, 'test-alignment.test.ts');
60
+ const result = spawnSync(process.execPath, [RUNNER, '--spec', spec, '--json'], {
61
+ encoding: 'utf8',
62
+ });
63
+ expect(result.error).toBeUndefined();
64
+ // The whole stdout stream must be one valid JSON document. A process.exit()
65
+ // immediately after console.log would truncate it on some platforms.
66
+ const parsed = JSON.parse(result.stdout);
67
+ expect(parsed).toHaveProperty('alignment_status');
68
+ expect(parsed).toHaveProperty('score');
69
+ expect(parsed).toHaveProperty('head_commit');
70
+ });
71
+
72
+ it('fails fast with exit code 1 when --spec is missing', () => {
73
+ const result = spawnSync(process.execPath, [RUNNER], { encoding: 'utf8' });
74
+ expect(result.status).toBe(1);
75
+ expect(result.stderr).toContain('--spec <path> is required');
76
+ });
77
+ });
@@ -17,11 +17,16 @@ const describeWin = isWindows ? describe : describe.skip;
17
17
  // Walkthrough FC-02: a bare `return` inside it() silently reports PASS on a
18
18
  // runner without bash — a false-green in the very suite that guards against
19
19
  // false greens. Bash-gated tests must SKIP loudly instead.
20
- const { execSync } = require('child_process');
21
- const BASH_AVAILABLE = (() => {
22
- try { execSync('bash --version', { stdio: 'ignore' }); return true; } catch { return false; }
20
+ // On Windows a bare PATH 'bash' may resolve to the WSL System32 stub, whose
21
+ // argv handling drops `-c` positional args — probing through it answers for
22
+ // the wrong environment (the exact #469 failure class). Resolve the same
23
+ // authoritative bash the hooks get: checkBash().
24
+ const { checkBash } = require('../detect-deps.js');
25
+ const BASH_PROBE = (() => {
26
+ const b = checkBash();
27
+ return b.ok ? b.path : null;
23
28
  })();
24
- const itWithBash = BASH_AVAILABLE ? it : it.skip;
29
+ const itWithBash = BASH_PROBE ? it : it.skip;
25
30
 
26
31
  describe('doctor hook-env probing (#469)', () => {
27
32
  function mod() {
@@ -30,7 +35,7 @@ describe('doctor hook-env probing (#469)', () => {
30
35
 
31
36
  itWithBash('AC-469-01: a tool the hook env sees is reported with its hook-env path', async () => {
32
37
  const { probeToolInHookEnv } = mod();
33
- const bashPath = isWindows ? 'bash' : '/bin/sh';
38
+ const bashPath = isWindows ? BASH_PROBE : '/bin/sh';
34
39
  const p = await probeToolInHookEnv(isWindows ? 'node' : 'sh', bashPath);
35
40
  expect(typeof p).toBe('string');
36
41
  expect(p.length).toBeGreaterThan(0);
@@ -39,7 +44,7 @@ describe('doctor hook-env probing (#469)', () => {
39
44
  itWithBash('AC-469-01: a tool absent from the hook env returns null — no fall-through', async () => {
40
45
  const { detectCliToolAsync } = mod();
41
46
  const result = await detectCliToolAsync('definitely-not-a-real-tool-xyz-469', isWindows, {
42
- bashPath: 'bash',
47
+ bashPath: BASH_PROBE,
43
48
  });
44
49
  // With bash present there is NO fall-through: `where` finding nothing is
45
50
  // irrelevant here, the hook env verdict is final.
@@ -56,7 +61,7 @@ describe('doctor hook-env probing (#469)', () => {
56
61
 
57
62
  itWithBash('AC-469-01: an installed tool is visible through the hook-env tier', async () => {
58
63
  const { detectCliToolAsync } = mod();
59
- const result = await detectCliToolAsync('node', true, { bashPath: 'bash' });
64
+ const result = await detectCliToolAsync('node', true, { bashPath: BASH_PROBE });
60
65
  expect(result.available).toBe(true);
61
66
  // The hook-env path is POSIX form on Windows — cmd.exe cannot run it.
62
67
  if (isWindows) expect(result.path.startsWith('/')).toBe(true);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @test REQ-DOCTOR retry-on-empty-stream guard
3
+ * @intent On win32, cmd.exe (exec's shell) can deliver exit 0 with an EMPTY
4
+ * stdout+stderr when many spawns are in flight — doctor read that as
5
+ * "Not found" and red-flagged Node/Git/hooksPath on a healthy machine.
6
+ * execWithTimeout must retry an empty-but-successful probe before
7
+ * reporting it, and must NOT retry real failures or real output.
8
+ * @covers AC-DOCTOR-RETRY-01
9
+ */
10
+ const path = require('path');
11
+ const { describe, it, expect, afterEach } = await import('vitest');
12
+
13
+ const cpPath = require.resolve('child_process');
14
+
15
+ function stubExec(behaviors) {
16
+ // doctor builds its async exec at load time via util.promisify(exec), which
17
+ // binds Node's INTERNAL custom promisify implementation — replacing the plain
18
+ // `exec` property afterwards is invisible to it (measured: stub fired 0-1
19
+ // times while the real cmd.exe child ran). The only patch point that holds
20
+ // across a fresh require is util.promisify itself.
21
+ const util = require('util');
22
+ const originalPromisify = util.promisify;
23
+ const calls = [];
24
+ util.promisify = function (fn) {
25
+ if (fn && fn.name === 'exec') {
26
+ stubWasConsumed = true;
27
+ return (cmd, opts = {}) => {
28
+ const attempt = calls.length;
29
+ calls.push(cmd);
30
+ return new Promise((resolve, reject) => {
31
+ behaviors[attempt]((err, stdout, stderr) => {
32
+ if (err) reject(err);
33
+ else resolve({ stdout, stderr });
34
+ }, attempt);
35
+ });
36
+ };
37
+ }
38
+ return originalPromisify.call(util, fn);
39
+ };
40
+ return {
41
+ calls,
42
+ restore: () => { util.promisify = originalPromisify; },
43
+ };
44
+ }
45
+
46
+ // Node couples exec to promisify via a custom symbol; if a future runtime drops
47
+ // that coupling the name-based stub above silently stops intercepting and the
48
+ // real cmd.exe child runs. Each test flips this to true from inside its own
49
+ // stubbed load (never inherited from an earlier test), so afterEach can fail
50
+ // loudly instead of passing vacuously.
51
+ let stubWasConsumed = false;
52
+
53
+ describe('execWithTimeout empty-stream retry (#525 follow-up)', () => {
54
+ let stub = null;
55
+ afterEach(() => {
56
+ if (stub) { stub.restore(); stub = null; }
57
+ // Fail loudly if the fn.name === 'exec' stub never intercepted this test's
58
+ // doctor load — that would mean Node dropped the exec/promisify custom-
59
+ // symbol coupling and the assertions ran against real cmd.exe children.
60
+ expect(stubWasConsumed, `stub did not intercept the doctor load in this test`).toBe(true);
61
+ stubWasConsumed = false;
62
+ });
63
+
64
+ function freshDoctor() {
65
+ const doctorPath = require.resolve('../doctor.js');
66
+ delete require.cache[doctorPath];
67
+ return require(doctorPath).execWithTimeout;
68
+ }
69
+
70
+ it('AC-DOCTOR-RETRY-01: retries an exit-0 empty-stdout probe and keeps its own timeout', async () => {
71
+ stub = stubExec([
72
+ (cb) => cb(null, '', ''), // the lost-stream failure mode
73
+ (cb) => cb(null, 'v24.19.0\n', ''), // the same probe, stream intact
74
+ ]);
75
+ const execWithTimeout = freshDoctor();
76
+ const result = await execWithTimeout('node --version', { stdio: ['ignore', 'pipe', 'pipe'] });
77
+ expect(stub.calls.length).toBe(2);
78
+ expect(result).toEqual({ stdout: 'v24.19.0', stderr: '' });
79
+ });
80
+
81
+ it('AC-DOCTOR-RETRY-01: returns null after exhausting attempts on a persistently empty probe', async () => {
82
+ stub = stubExec([
83
+ (cb) => cb(null, '', ''),
84
+ (cb) => cb(null, '', ''),
85
+ (cb) => cb(null, '', ''),
86
+ (cb) => cb(null, 'unexpected fourth attempt', ''),
87
+ ]);
88
+ const execWithTimeout = freshDoctor();
89
+ const result = await execWithTimeout('node --version', {});
90
+ // win32: 3 attempts then null; other platforms: single attempt, still null.
91
+ expect(result).toBeNull();
92
+ expect(stub.calls.length).toBe(process.platform === 'win32' ? 3 : 1);
93
+ });
94
+
95
+ it('AC-DOCTOR-RETRY-01: does not retry when the probe produced output', async () => {
96
+ stub = stubExec([(cb) => cb(null, 'git version 2.54.0\n', '')]);
97
+ const execWithTimeout = freshDoctor();
98
+ const result = await execWithTimeout('git --version', {});
99
+ expect(stub.calls.length).toBe(1);
100
+ expect(result.stdout).toContain('git version');
101
+ });
102
+
103
+ it('AC-DOCTOR-RETRY-01: does not retry a failing command', async () => {
104
+ stub = stubExec([(cb) => cb(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), '', '')]);
105
+ const execWithTimeout = freshDoctor();
106
+ const result = await execWithTimeout('definitely-not-a-tool --version', {});
107
+ expect(stub.calls.length).toBe(1);
108
+ expect(result).toBeNull();
109
+ });
110
+
111
+ it('AC-DOCTOR-RETRY-01: attempts:1 opts a side-effecting call out of the retry', async () => {
112
+ stub = stubExec([
113
+ (cb) => cb(null, '', ''),
114
+ (cb) => cb(null, 'unexpected second attempt', ''),
115
+ ]);
116
+ const execWithTimeout = freshDoctor();
117
+ const result = await execWithTimeout('some-mutating-command', { attempts: 1 });
118
+ expect(stub.calls.length).toBe(1);
119
+ expect(result).toBeNull();
120
+ });
121
+
122
+ it('AC-DOCTOR-RETRY-01: the attempts option is consumed, not forwarded to exec', async () => {
123
+ let forwarded = null;
124
+ stub = stubExec([(cb) => { cb(null, 'ok\n', ''); }]);
125
+ // Capture what doctor hands to the promisified exec by re-stubbing with an
126
+ // options recorder: stubExec's behaviors receive only the callback, so
127
+ // assert forwarding through a second fresh module with a capturing wrapper.
128
+ const util = require('util');
129
+ const originalPromisify = util.promisify;
130
+ const execWithTimeout = (() => {
131
+ util.promisify = function (fn) {
132
+ if (fn && fn.name === 'exec') {
133
+ stubWasConsumed = true;
134
+ return (cmd, opts = {}) => {
135
+ forwarded = opts;
136
+ return Promise.resolve({ stdout: 'ok\n', stderr: '' });
137
+ };
138
+ }
139
+ return originalPromisify.call(util, fn);
140
+ };
141
+ const doctorPath = require.resolve('../doctor.js');
142
+ delete require.cache[doctorPath];
143
+ const f = require(doctorPath).execWithTimeout;
144
+ util.promisify = originalPromisify;
145
+ return f;
146
+ })();
147
+ const result = await execWithTimeout('git --version', { attempts: 2, timeout: 999 });
148
+ expect(result.stdout).toBe('ok');
149
+ expect(forwarded.attempts).toBeUndefined();
150
+ expect(forwarded.timeout).toBe(999);
151
+ });
152
+ });
@@ -51,13 +51,22 @@ function matchBlock(source, pattern, label) {
51
51
  }
52
52
 
53
53
  function bashBin() {
54
- const candidate = process.env.XP_GATE_BASH_BIN || 'bash';
55
- const probe = spawnSync(candidate, ['-c', 'echo ok'], { encoding: 'utf8' });
56
- // A missing runner must fail loudly, never read as a passing guard.
57
- if (probe.status !== 0 || probe.error) {
58
- throw new Error(`bash runner unavailable (${candidate}): ${probe.error ? probe.error.message : `exit ${probe.status}`}`);
54
+ if (process.env.XP_GATE_BASH_BIN) {
55
+ const probe = spawnSync(process.env.XP_GATE_BASH_BIN, ['-c', 'echo ok'], { encoding: 'utf8' });
56
+ if (probe.status !== 0 || probe.error) {
57
+ throw new Error(`bash runner unavailable (${process.env.XP_GATE_BASH_BIN}): ${probe.error ? probe.error.message : `exit ${probe.status}`}`);
58
+ }
59
+ return process.env.XP_GATE_BASH_BIN;
60
+ }
61
+ // Resolve the authoritative bash the hooks actually get. On Windows a bare
62
+ // PATH 'bash' may be the WSL System32 stub, whose `-c` scripts receive an
63
+ // empty $1 and resolve nothing — a vacuous resolver run, not a passing one.
64
+ const { checkBash } = require('../detect-deps.js');
65
+ const b = checkBash();
66
+ if (!b.ok) {
67
+ throw new Error(`bash runner unavailable: ${b.message || 'checkBash failed'}`);
59
68
  }
60
- return candidate;
69
+ return b.path;
61
70
  }
62
71
 
63
72
  /**
@@ -32,12 +32,20 @@ describe('doctor-tui skill freshness (#439)', () => {
32
32
  // HOME has no platform marker, so detectPlatform() resolves to opencode.
33
33
  function installSkill(name, content) {
34
34
  const dir = path.join(tmpHome, '.config', 'opencode', 'skills', name);
35
- fs.mkdirSync(dir, { recursive: true });
36
- fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
35
+ try {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
38
+ } catch (err) {
39
+ throw new Error(`failed to install test skill ${name}: ${err && err.message}`);
40
+ }
37
41
  }
38
42
 
39
43
  function readBundled(name) {
40
- return fs.readFileSync(path.join(bundledSkillsDir, name, 'SKILL.md'), 'utf8');
44
+ try {
45
+ return fs.readFileSync(path.join(bundledSkillsDir, name, 'SKILL.md'), 'utf8');
46
+ } catch (err) {
47
+ throw new Error(`failed to read bundled skill ${name}: ${err && err.message}`);
48
+ }
41
49
  }
42
50
 
43
51
  /** Same bytes, opposite line endings — the only difference a fresh install can have. */
@@ -134,7 +142,11 @@ describe('doctor-tui skill freshness (#439)', () => {
134
142
  .readdirSync(bundledSkillsDir, { withFileTypes: true })
135
143
  .filter((d) => d.isDirectory())
136
144
  .map((d) => d.name);
137
- expect(skills.length).toBeGreaterThanOrEqual(13);
145
+ // Sanity floor only: the substantive assertion below is that every bundled
146
+ // skill declares a version. #522 removed clipboard-vision, dropping the
147
+ // bundle from 13 to 12 skills, so the floor tracks the current bundle size
148
+ // instead of a stale magic number (Delphi Round 1, finding A-Critical).
149
+ expect(skills.length).toBeGreaterThanOrEqual(12);
138
150
  const missing = skills.filter((name) => {
139
151
  const md = path.join(bundledSkillsDir, name, 'SKILL.md');
140
152
  return !/^version:\s*\d+\.\d+\.\d+/m.test(fs.readFileSync(md, 'utf8'));