@boyingliu01/xp-gate 0.26.0 → 0.27.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 (117) 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-module-resolution-parity.test.js +15 -6
  6. package/lib/__tests__/doctor-tui-skill-freshness.test.js +16 -4
  7. package/lib/__tests__/test-alignment.test.ts +37 -0
  8. package/lib/__tests__/windows-bash-probe.test.js +73 -0
  9. package/lib/check-alignment-runner.cjs +74 -0
  10. package/lib/detect-deps.js +82 -19
  11. package/lib/gate-runner.js +12 -5
  12. package/lib/mattpocock-license-verdict.json +1 -1
  13. package/lib/sprint-status.js +5 -2
  14. package/lib/test-alignment.ts +136 -12
  15. package/mock-policy/AGENTS.md +2 -2
  16. package/mutation/AGENTS.md +2 -2
  17. package/package.json +1 -1
  18. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  19. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  20. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  22. package/plugins/dsh/README.md +1 -1
  23. package/plugins/dsh/package.json +1 -1
  24. package/plugins/dsh/scripts/prepack.cjs +2 -2
  25. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  26. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  27. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  28. package/plugins/dsh/src/skills.test.ts +20 -7
  29. package/plugins/opencode/package.json +1 -1
  30. package/plugins/opencode/scripts/prepack.cjs +0 -1
  31. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  32. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  33. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  34. package/plugins/qoder/plugin.json +1 -1
  35. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  36. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  37. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  38. package/principles/AGENTS.md +2 -2
  39. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +9 -8
  40. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +3 -4
  41. package/principles/rules/__tests__/clean-code/god-class.test.ts +2 -4
  42. package/principles/rules/__tests__/clean-code/large-file.test.ts +2 -4
  43. package/principles/rules/__tests__/clean-code/long-function.test.ts +2 -4
  44. package/principles/rules/__tests__/clean-code/many-exports.test.ts +2 -3
  45. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +2 -4
  46. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +2 -4
  47. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +4 -4
  48. package/principles/rules/__tests__/solid/dip.test.ts +2 -4
  49. package/principles/rules/__tests__/solid/isp.test.ts +2 -4
  50. package/principles/rules/__tests__/solid/lsp.test.ts +2 -4
  51. package/principles/rules/__tests__/solid/ocp.test.ts +2 -4
  52. package/principles/rules/__tests__/solid/srp.test.ts +2 -4
  53. package/principles/rules/clean-code/code-duplication.ts +12 -14
  54. package/principles/rules/clean-code/deep-nesting.ts +18 -20
  55. package/principles/rules/clean-code/god-class.ts +32 -34
  56. package/principles/rules/clean-code/large-file.ts +14 -16
  57. package/principles/rules/clean-code/long-function.ts +26 -28
  58. package/principles/rules/clean-code/magic-numbers.ts +39 -38
  59. package/principles/rules/clean-code/many-exports.ts +21 -23
  60. package/principles/rules/clean-code/missing-error-handling.ts +13 -15
  61. package/principles/rules/clean-code/too-many-params.ts +15 -17
  62. package/principles/rules/clean-code/unused-imports.ts +13 -15
  63. package/principles/rules/solid/dip.ts +16 -18
  64. package/principles/rules/solid/isp.ts +17 -19
  65. package/principles/rules/solid/lsp.ts +4 -6
  66. package/principles/rules/solid/ocp.ts +17 -19
  67. package/principles/rules/solid/srp.ts +20 -22
  68. package/scripts/__tests__/sync-package-content.test.js +0 -1
  69. package/scripts/sync-package-content.js +0 -1
  70. package/skills/delphi-review/AGENTS.md +2 -2
  71. package/skills/sprint-flow/AGENTS.md +2 -2
  72. package/skills/test-specification-alignment/AGENTS.md +2 -2
  73. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +0 -48
  74. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  75. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  76. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  77. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  78. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  79. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.sh +0 -161
  80. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  81. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  82. package/plugins/dsh/skills/clipboard-vision/SKILL.md +0 -48
  83. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  84. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  85. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  87. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  88. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.sh +0 -161
  89. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  90. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  91. package/plugins/opencode/skills/clipboard-vision/SKILL.md +0 -48
  92. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  93. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  94. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  95. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  96. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  97. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.sh +0 -161
  98. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  99. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  100. package/plugins/qoder/skills/clipboard-vision/SKILL.md +0 -48
  101. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  102. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  103. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  104. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  105. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  106. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +0 -161
  107. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  108. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  109. package/skills/clipboard-vision/SKILL.md +0 -48
  110. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  111. package/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  112. package/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  113. package/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  114. package/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  115. package/skills/clipboard-vision/describe-clipboard.sh +0 -161
  116. package/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  117. 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);
@@ -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'));
@@ -325,6 +325,43 @@ test('multiple assertions', () => {
325
325
  expect(result.tests[0].assertions).toBeGreaterThanOrEqual(4);
326
326
  expect(result.totalAssertions).toBeGreaterThanOrEqual(4);
327
327
  });
328
+
329
+ it('AC-FIXTURE-ASSERT: should count assertions per-test, not per-file (#392)', async () => {
330
+ // Regression for #392-#1: assertion counting must be scoped to the test
331
+ // body, otherwise totalAssertions is inflated by fileCount x perFileCount.
332
+ const testTs = `
333
+ /**
334
+ * @test REQ-MULTI-001 First test
335
+ * @intent Assertion counting is per-test
336
+ * @covers AC-FIXTURE-MULTI-01
337
+ */
338
+ test('first test', () => {
339
+ expect(a()).toBe(1);
340
+ expect(b()).toBe(2);
341
+ });
342
+
343
+ /**
344
+ * @test REQ-MULTI-002 Second test
345
+ * @intent Assertion counting is per-test
346
+ * @covers AC-FIXTURE-MULTI-02
347
+ */
348
+ test('second test', () => {
349
+ expect(c()).toBe(3);
350
+ });
351
+ `;
352
+ const testFile = writeTempFile('multi-assert.test.ts', testTs);
353
+ const result = parseTestFiles([testFile]);
354
+
355
+ expect(result.tests).toHaveLength(2);
356
+ // countAssertions counts matching *patterns*, so `expect(x).toBe(y)` yields
357
+ // 2 (one `expect(` + one `toBe(`). First test body has 2 such assertions
358
+ // => 4; second has 1 => 2. The key regression: each test counts ONLY its own
359
+ // body (total 6), never the whole file (3 expect + 3 toBe = 6 per test,
360
+ // which would inflate totalAssertions to 12 under the old per-file bug).
361
+ expect(result.tests[0].assertions).toBe(4);
362
+ expect(result.tests[1].assertions).toBe(2);
363
+ expect(result.totalAssertions).toBe(6);
364
+ });
328
365
  });
329
366
 
330
367
  // ============================================================================
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @test REQ-windows-bash probe behavior
3
+ * @intent resolveWindowsBashCandidates must accept only a bash that round-trips
4
+ * `-c` positional argv AND reports MINGW/MSYS/CYGWIN via uname — the WSL
5
+ * System32 stub (Linux uname, dropped $1) is rejected even when PATH
6
+ * leads with it. Behavior is exercised through an injected fake spawner,
7
+ * so no real interpreter configuration is assumed on the host (#469
8
+ * follow-up, Delphi walkthrough R1 MN-06).
9
+ * @covers AC-WINBASH-01
10
+ */
11
+ const { describe, it, expect } = await import('vitest');
12
+ const { resolveWindowsBashCandidates } = require('../detect-deps.js');
13
+
14
+ // The candidate loop reads `where bash` from the host; tests inject both the
15
+ // discovery runner and the probe spawner, so results are deterministic on any
16
+ // machine regardless of what interpreter configuration it happens to have.
17
+ function fakeWhere(whereOutput) {
18
+ return (cmd) => {
19
+ if (/^where bash/.test(cmd)) {
20
+ if (whereOutput instanceof Error) throw whereOutput;
21
+ return whereOutput || '';
22
+ }
23
+ throw new Error(`unexpected execSync: ${cmd}`);
24
+ };
25
+ }
26
+
27
+ function fakeSpawn(behaviors) {
28
+ const probed = [];
29
+ const fn = (candidate) => {
30
+ probed.push(candidate);
31
+ const b = behaviors[candidate];
32
+ if (b instanceof Error) throw b;
33
+ // Unknown candidates model a missing file: no stdout at all.
34
+ return { stdout: b !== undefined ? b : '' };
35
+ };
36
+ fn.probed = probed;
37
+ return fn;
38
+ }
39
+
40
+ describe('resolveWindowsBashCandidates (win32 authoritative bash)', () => {
41
+ it('AC-WINBASH-01: rejects a WSL stub whose $1 is empty and uname is Linux', () => {
42
+ const spawn = fakeSpawn({ 'C:\\Windows\\System32\\bash.exe': '[ ]\nLinux\n' });
43
+ // No MSYS candidate exists in this world (hardcoded fallbacks report no
44
+ // stdout), so the resolver must find nothing rather than settle for the stub.
45
+ expect(resolveWindowsBashCandidates(spawn, fakeWhere('C:\\Windows\\System32\\bash.exe\r\n'))).toBeNull();
46
+ });
47
+
48
+ it('AC-WINBASH-01: accepts the first candidate with argv round-trip + MINGW uname', () => {
49
+ const spawn = fakeSpawn({
50
+ 'C:\\a\\bash.exe': '[]\nLinux\n', // drops $1 → reject
51
+ 'C:\\b\\bash.exe': '[XPGATE_ARGV_PROBE]\nMINGW64_NT-10.0\n', // accept
52
+ });
53
+ const result = resolveWindowsBashCandidates(spawn, fakeWhere('C:\\a\\bash.exe\r\nC:\\b\\bash.exe\r\n'));
54
+ expect(result).toBe('C:\\b\\bash.exe');
55
+ expect(spawn.probed.slice(0, 2)).toEqual(['C:\\a\\bash.exe', 'C:\\b\\bash.exe']);
56
+ });
57
+
58
+ it('AC-WINBASH-01: a Linux uname with intact argv still fails the platform test', () => {
59
+ // A real WSL bash answers [XPGATE_ARGV_PROBE] correctly under some
60
+ // invocations but its filesystem view cannot run these hooks — uname wins.
61
+ const spawn = fakeSpawn({ 'C:\\wsl\\bash.exe': '[XPGATE_ARGV_PROBE]\nLinux\n' });
62
+ expect(resolveWindowsBashCandidates(spawn, fakeWhere('C:\\wsl\\bash.exe\r\n'))).toBeNull();
63
+ });
64
+
65
+ it('AC-WINBASH-01: a throwing candidate is skipped, not fatal', () => {
66
+ const spawn = fakeSpawn({
67
+ 'C:\\gone\\bash.exe': new Error('ENOENT'),
68
+ 'C:\\msys64\\usr\\bin\\bash.exe': '[XPGATE_ARGV_PROBE]\nMSYS_NT-10.0\n',
69
+ });
70
+ const result = resolveWindowsBashCandidates(spawn, fakeWhere(new Error('where unavailable')));
71
+ expect(result).toBe('C:\\msys64\\usr\\bin\\bash.exe');
72
+ });
73
+ });