@boyingliu01/xp-gate 0.25.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 (120) hide show
  1. package/adapters/python.sh +53 -3
  2. package/bin/xp-gate.js +161 -28
  3. package/hooks/lib/python-incremental-typecheck.sh +104 -0
  4. package/hooks/lib/typecheck.sh +10 -0
  5. package/hooks/pre-commit +84 -32
  6. package/lib/__tests__/check-alignment-cli.test.mjs +77 -0
  7. package/lib/__tests__/doctor-469-hook-env.test.js +12 -7
  8. package/lib/__tests__/doctor-module-resolution-parity.test.js +15 -6
  9. package/lib/__tests__/doctor-tui-skill-freshness.test.js +16 -4
  10. package/lib/__tests__/test-alignment.test.ts +37 -0
  11. package/lib/__tests__/windows-bash-probe.test.js +73 -0
  12. package/lib/check-alignment-runner.cjs +74 -0
  13. package/lib/detect-deps.js +82 -19
  14. package/lib/gate-runner.js +12 -5
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/lib/sprint-status.js +5 -2
  17. package/lib/test-alignment.ts +136 -12
  18. package/mock-policy/AGENTS.md +3 -3
  19. package/mutation/AGENTS.md +3 -3
  20. package/package.json +1 -1
  21. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  22. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/dsh/README.md +1 -1
  26. package/plugins/dsh/package.json +1 -1
  27. package/plugins/dsh/scripts/prepack.cjs +2 -2
  28. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/dsh/src/skills.test.ts +20 -7
  32. package/plugins/opencode/package.json +1 -1
  33. package/plugins/opencode/scripts/prepack.cjs +0 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  35. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  36. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  37. package/plugins/qoder/plugin.json +1 -1
  38. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  41. package/principles/AGENTS.md +3 -3
  42. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +9 -8
  43. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +3 -4
  44. package/principles/rules/__tests__/clean-code/god-class.test.ts +2 -4
  45. package/principles/rules/__tests__/clean-code/large-file.test.ts +2 -4
  46. package/principles/rules/__tests__/clean-code/long-function.test.ts +2 -4
  47. package/principles/rules/__tests__/clean-code/many-exports.test.ts +2 -3
  48. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +2 -4
  49. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +2 -4
  50. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +4 -4
  51. package/principles/rules/__tests__/solid/dip.test.ts +2 -4
  52. package/principles/rules/__tests__/solid/isp.test.ts +2 -4
  53. package/principles/rules/__tests__/solid/lsp.test.ts +2 -4
  54. package/principles/rules/__tests__/solid/ocp.test.ts +2 -4
  55. package/principles/rules/__tests__/solid/srp.test.ts +2 -4
  56. package/principles/rules/clean-code/code-duplication.ts +12 -14
  57. package/principles/rules/clean-code/deep-nesting.ts +18 -20
  58. package/principles/rules/clean-code/god-class.ts +32 -34
  59. package/principles/rules/clean-code/large-file.ts +14 -16
  60. package/principles/rules/clean-code/long-function.ts +26 -28
  61. package/principles/rules/clean-code/magic-numbers.ts +39 -38
  62. package/principles/rules/clean-code/many-exports.ts +21 -23
  63. package/principles/rules/clean-code/missing-error-handling.ts +13 -15
  64. package/principles/rules/clean-code/too-many-params.ts +15 -17
  65. package/principles/rules/clean-code/unused-imports.ts +13 -15
  66. package/principles/rules/solid/dip.ts +16 -18
  67. package/principles/rules/solid/isp.ts +17 -19
  68. package/principles/rules/solid/lsp.ts +4 -6
  69. package/principles/rules/solid/ocp.ts +17 -19
  70. package/principles/rules/solid/srp.ts +20 -22
  71. package/scripts/__tests__/sync-package-content.test.js +0 -1
  72. package/scripts/sync-package-content.js +0 -1
  73. package/skills/delphi-review/AGENTS.md +3 -3
  74. package/skills/sprint-flow/AGENTS.md +3 -3
  75. package/skills/test-specification-alignment/AGENTS.md +3 -3
  76. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +0 -48
  77. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  78. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  79. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  80. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  81. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  82. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.sh +0 -161
  83. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  84. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  85. package/plugins/dsh/skills/clipboard-vision/SKILL.md +0 -48
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  87. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  88. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  89. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  90. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  91. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.sh +0 -161
  92. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  93. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  94. package/plugins/opencode/skills/clipboard-vision/SKILL.md +0 -48
  95. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  96. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  97. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  98. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  99. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  100. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.sh +0 -161
  101. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  102. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  103. package/plugins/qoder/skills/clipboard-vision/SKILL.md +0 -48
  104. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  105. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  106. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  107. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  108. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  109. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +0 -161
  110. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  111. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
  112. package/skills/clipboard-vision/SKILL.md +0 -48
  113. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
  114. package/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
  115. package/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
  116. package/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
  117. package/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
  118. package/skills/clipboard-vision/describe-clipboard.sh +0 -161
  119. package/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
  120. package/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
@@ -91,12 +91,62 @@ run_static_analysis() {
91
91
  return 0
92
92
  fi
93
93
  fi
94
- # 逐目标调用并聚合退出码(口径见函数头注释)。
95
- require_tool mypy "mypy" || return 1
94
+ # #534: engine selection. mypy is ALWAYS the authoritative engine (determinism —
95
+ # ty is an alpha, ~70% spec conformance, config-incompatible). ty is used ONLY
96
+ # as an explicit opt-in accelerator when XP_GATE_TY=1 AND `ty` is installed:
97
+ # - XP_GATE_TY=1 + ty present -> `ty check <target>` (ty's CLI is `ty check`,
98
+ # not `ty`; exit 1 = type errors -> BLOCK, exit 2 = config/CLI error -> SKIP)
99
+ # - XP_GATE_TY=1 + ty missing -> SKIP (return 0), never BLOCK: the opt-in
100
+ # path must not route through require_tool mypy and false-block a repo that
101
+ # opted into ty but lacks mypy (Delphi R1 Critical).
102
+ # - XP_GATE_TY unset -> pure mypy (authoritative), unchanged.
103
+ # Scope semantics (.xp-gate-mypy-scope / src/ scripts/) are engine-independent
104
+ # and preserved verbatim below.
105
+ local _engine=mypy
106
+ if [ "${XP_GATE_TY:-}" = "1" ]; then
107
+ if command -v ty >/dev/null 2>&1; then
108
+ _engine=ty
109
+ echo " ⚙ engine: ty (Rust, explicit opt-in via XP_GATE_TY=1) — mypy remains the default"
110
+ elif command -v mypy >/dev/null 2>&1; then
111
+ # ty opted in but missing; mypy present -> fall back to the authoritative
112
+ # engine with a warning (never silently drop the type gate, #534 R2 arch).
113
+ echo " ⚠ ty requested (XP_GATE_TY=1) but not installed — falling back to mypy"
114
+ else
115
+ echo " ⚠ ty opted in via XP_GATE_TY=1 but neither ty nor mypy is installed — SKIPPING type check (no BLOCK)"
116
+ echo " install: pip install mypy, or pip install ty (alpha)"
117
+ return 0
118
+ fi
119
+ fi
120
+ # require_tool mypy only on the authoritative (mypy) engine path: when the
121
+ # user opted into ty, a missing mypy must NOT false-BLOCK (Delphi R1 Critical).
122
+ if [ "$_engine" = "mypy" ]; then
123
+ require_tool mypy "mypy" || return 1
124
+ fi
96
125
  local _mypy_rc=0
97
126
  local _mypy_t
98
127
  for _mypy_t in "${MYPY_TARGETS[@]}"; do
99
- mypy "$_mypy_t" || _mypy_rc=1
128
+ if [ "$_engine" = "ty" ]; then
129
+ # ty's CLI is `ty check <path>`, not `ty <path>`. Bare `ty`/`ty check .`
130
+ # without targets would recurse the whole tree (== the anti `mypy .`
131
+ # guard #501/#513): targets are always explicit here. Capture ty's own
132
+ # exit code and diagnostics via a temp file (never discard them): a BLOCK
133
+ # with no reason is undebuggable, and a pipe `| head` would lose the code.
134
+ local _ty_rc=0
135
+ local _ty_tmp
136
+ _ty_tmp="$(mktemp)"
137
+ ty check "$_mypy_t" > "$_ty_tmp" 2>&1 || _ty_rc=$?
138
+ if [ "$_ty_rc" -eq 2 ]; then
139
+ echo " ⚠ ty exited $_ty_rc on target '$_mypy_t' (config/CLI error) — SKIPPING, not blocking"
140
+ head -30 "$_ty_tmp"
141
+ elif [ "$_ty_rc" -ne 0 ]; then
142
+ echo " ty report for '$_mypy_t' (exit $_ty_rc):"
143
+ head -30 "$_ty_tmp"
144
+ _mypy_rc=1
145
+ fi
146
+ rm -f "$_ty_tmp"
147
+ else
148
+ mypy "$_mypy_t" || _mypy_rc=1
149
+ fi
100
150
  done
101
151
  return $_mypy_rc
102
152
  }
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': {
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Incremental Python type-checking helpers for the pre-commit Python branch
4
+ # (#534). This is the FAST staged-only pass: pre-commit runs it on just the
5
+ # .py/.pyi files that changed, so a large repo doesn't pay full-inference CPU
6
+ # on every commit. It complements the FULL mypy pass (githooks/adapters/
7
+ # python.sh run_static_analysis, reachable via the adapter CLI / CI), which is
8
+ # where cross-module blind spots get caught.
9
+ #
10
+ # Sourced by githooks/pre-commit (the python) case. Requires CHANGED_FILES
11
+ # (space/newline separated, repo-root relative) to be in scope, matching the
12
+ # rest of the inline branch.
13
+ #
14
+ # Shellcheck: functions are the public API used by pre-commit; SC1091 sources
15
+ # are intentionally not resolved here (adapter-common not needed).
16
+
17
+ # Extract the staged .py/.pyi files from CHANGED_FILES, one per line.
18
+ # Excludes vendored/ignored trees (.venv, venv, node_modules) that a hook
19
+ # should never type-check. Handles whitespace in paths (echo + grep would
20
+ # split them).
21
+ python_staged_py_files() {
22
+ local f
23
+ while IFS= read -r f || [ -n "$f" ]; do
24
+ case "$f" in
25
+ .venv/*|venv/*|node_modules/*) continue ;;
26
+ *.py|*.pyi) printf '%s\n' "$f" ;;
27
+ esac
28
+ done <<< "$CHANGED_FILES"
29
+ }
30
+
31
+ # Run incremental mypy over the given staged files, returning mypy's REAL exit
32
+ # code (never a pipe stage's). Adds --follow-imports=silent so that a change to
33
+ # A.py that references an unchanged B.py does not surface B's own pre-existing
34
+ # diagnostics — only caller-level signature errors are reported. Callers must
35
+ # pass the file list as arguments (already filtered to .py/.pyi).
36
+ #
37
+ # Scope discipline (same .xp-gate-mypy-scope contract as python.sh): a staged
38
+ # file OUTSIDE the declared mypy scope is skipped with a note rather than type-
39
+ # checked, keeping the incremental pass consistent with the full pass. Files in
40
+ # scope default to "under src/ or scripts/" when no scope file exists.
41
+ python_typecheck_incremental() {
42
+ [ "$#" -gt 0 ] || return 0
43
+
44
+ # Resolve the declared scope (identical precedence to run_static_analysis):
45
+ # .xp-gate-mypy-scope (one target per line) wins; else src/ and scripts/
46
+ # are the implicit default. Any staged .py outside this surface is skipped.
47
+ local -a SCOPE_DIRS=()
48
+ local _declared=0 line target
49
+ if [ -f .xp-gate-mypy-scope ]; then
50
+ while IFS= read -r line || [ -n "$line" ]; do
51
+ line="${line%%#*}"
52
+ line="${line#"${line%%[![:space:]]*}"}"
53
+ line="${line%"${line##*[![:space:]]}"}"
54
+ [ -z "$line" ] && continue
55
+ _declared=1
56
+ # Normalize trailing slashes (`src/` -> `src`) so the `$target/*` prefix
57
+ # match below never sees a doubled slash (`src//a.py` would never match).
58
+ line="${line%/}"
59
+ SCOPE_DIRS+=("$line")
60
+ done < .xp-gate-mypy-scope
61
+ else
62
+ [ -d src ] && SCOPE_DIRS+=(src)
63
+ [ -d scripts ] && SCOPE_DIRS+=(scripts)
64
+ fi
65
+
66
+ # If a scope file is declared but yields nothing, every staged .py is out of
67
+ # scope -> SKIP (exit 0), mirroring the full pass.
68
+ local _in_scope=false
69
+ local -a TYPECHECK_FILES=()
70
+ local f
71
+ if [ "${#SCOPE_DIRS[@]}" -gt 0 ]; then
72
+ for f in "$@"; do
73
+ _in_scope=false
74
+ for target in "${SCOPE_DIRS[@]}"; do
75
+ case "$f" in
76
+ "$target"/*) _in_scope=true ;;
77
+ esac
78
+ [ "$_in_scope" = true ] && break
79
+ done
80
+ if [ "$_in_scope" = true ]; then
81
+ TYPECHECK_FILES+=("$f")
82
+ else
83
+ echo " ℹ️ mypy incremental: '$f' is outside declared scope — skipped"
84
+ fi
85
+ done
86
+ fi
87
+
88
+ [ "${#TYPECHECK_FILES[@]}" -gt 0 ] || return 0
89
+
90
+ # Capture mypy's exit code directly. A pipeline like
91
+ # mypy ... | head -20 | tail -10; rc=$?
92
+ # loses mypy's real status (the pipe stage's rc is used instead), which made
93
+ # the gate pass silently on real type errors. Redirect to a temp file, take
94
+ # mypy's own exit code, then print a truncated view.
95
+ local tmp
96
+ tmp="$(mktemp)"
97
+ echo "Running mypy type checking (incremental)..."
98
+ mypy --follow-imports=silent --ignore-missing-imports "${TYPECHECK_FILES[@]}" > "$tmp" 2>&1
99
+ local _mypy_rc=$?
100
+ head -20 "$tmp"
101
+ tail -10 "$tmp"
102
+ rm -f "$tmp"
103
+ return "$_mypy_rc"
104
+ }
@@ -3,6 +3,16 @@
3
3
  # Gate 1 TypeScript static analysis — the single place that decides HOW a project
4
4
  # is type-checked (#436).
5
5
  #
6
+ # NOT incremental (deliberately, #534): TypeScript type-checking cannot be
7
+ # reduced to "staged files only". `tsc` has no single-file check mode — it must
8
+ # resolve the whole module graph to report a file's real type errors, so
9
+ # type-checking only the changed files would silently miss "changing B.ts breaks
10
+ # an unchanged caller in A.ts". Pre-commit therefore runs the project's full
11
+ # typecheck (`npm run typecheck` / `npx tsc --noEmit`) every commit. This is the
12
+ # CORRECTNESS-preserving exception to xp-gate's incremental rule: lint (ESLint /
13
+ # Biome) IS incremental (staged files / --staged), but type-check stays full
14
+ # because incremental type-checking would be wrong, not just slower.
15
+ #
6
16
  # Before this file, `githooks/pre-commit` and `githooks/adapters/typescript.sh` each
7
17
  # hardcoded `npx tsc --noEmit`. Plain tsc cannot resolve `.vue` single-file
8
18
  # components, so every `import X from './Foo.vue'` became a TS2307 and a Vue project
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"
@@ -235,6 +239,11 @@ source "${AUDIT_SCRIPT_DIR}/lib/coverage-total.sh"
235
239
  # unit-testable without executing this hook.
236
240
  source "${AUDIT_SCRIPT_DIR}/lib/python-test-selection.sh"
237
241
 
242
+ # Issue #534: incremental Python type-check for the pre-commit python branch —
243
+ # staged-only mypy (fast) that complements python.sh's CI-aligned full pass.
244
+ # Kept in a library so the exit-code capture and scope handling are unit-testable.
245
+ source "${AUDIT_SCRIPT_DIR}/lib/python-incremental-typecheck.sh"
246
+
238
247
  # Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
239
248
  _MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
240
249
 
@@ -979,10 +988,14 @@ IN_PROJECT_SUBDIR=0
979
988
  _lang_count=$(echo "$PROJECT_LANGS" | wc -w)
980
989
  if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
981
990
  ORIGINAL_DIR=$(pwd)
982
- cd "$PROJECT_SUBDIR"
983
- IN_PROJECT_SUBDIR=1
984
- echo "Working in: $PROJECT_SUBDIR/"
985
- 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
986
999
  fi
987
1000
 
988
1001
  # ============================================================================
@@ -1308,24 +1321,38 @@ else
1308
1321
  fi
1309
1322
  fi
1310
1323
 
1311
- # Optional: Type checking with mypy
1324
+ # Optional: Type checking with mypy — INCREMENTAL (fast, staged-only).
1325
+ #
1326
+ # Responsibility boundary (#534):
1327
+ # * This branch (python case) runs mypy ONLY over the staged .py/.pyi
1328
+ # files via python_typecheck_incremental — seconds, not a full inference
1329
+ # pass. Uses --follow-imports=silent so a change to A.py that references
1330
+ # an unchanged B.py does NOT surface B's pre-existing diagnostics; only
1331
+ # caller-level signature errors still block. Its exit code is captured
1332
+ # directly (temp file), which is deterministic and does not depend on
1333
+ # set -o pipefail (the old `... | head | tail; rc=$?` worked only under
1334
+ # pipefail and could drop mypy's status when the pattern is copied into
1335
+ # a non-pipefail context).
1336
+ # * The FULL mypy pass lives OUTSIDE this hook: run_static_analysis()
1337
+ # (githooks/adapters/python.sh) is reachable via the adapter CLI and the
1338
+ # general adapter route, and CI installs mypy (quality-gates.yml) for
1339
+ # typed projects. NOTE: the pre-commit python case is fully inline and
1340
+ # never routes to run_static_analysis — so the incremental blind spot
1341
+ # (cross-module signature break on an unchanged file) is the accepted
1342
+ # trade-off here, backstopped by full mypy wherever that surface runs.
1343
+ # Both paths coexist: this one is the fast pre-commit pass.
1312
1344
  if command -v mypy &> /dev/null; then
1345
+ # Collect staged .py/.pyi via the helper (whitespace-safe, bash-3
1346
+ # compatible — macOS default bash is 3.2, so avoid mapfile).
1313
1347
  MYPY_ARGS=()
1314
1348
  while IFS= read -r staged_file; do
1315
- case "$staged_file" in
1316
- *.py|*.pyi) MYPY_ARGS+=("$staged_file") ;;
1317
- esac
1318
- done <<< "$CHANGED_FILES"
1319
- if [ "${#MYPY_ARGS[@]}" -eq 0 ]; then
1320
- MYPY_EXIT=0
1321
- else
1322
- echo "Running mypy type checking (optional)..."
1323
- mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
1324
- MYPY_EXIT=$?
1325
- fi
1349
+ MYPY_ARGS+=("$staged_file")
1350
+ done < <(python_staged_py_files)
1351
+ python_typecheck_incremental "${MYPY_ARGS[@]}"
1352
+ MYPY_EXIT=$?
1326
1353
  if [ "$MYPY_EXIT" -ne 0 ]; then
1327
1354
  echo ""
1328
- echo "❌ BLOCKED - MYPI TYPE ERRORS detected"
1355
+ echo "❌ BLOCKED - MYPY TYPE ERRORS detected"
1329
1356
  echo "Fix the type errors above before committing."
1330
1357
  exit 1
1331
1358
  fi
@@ -1342,16 +1369,32 @@ else
1342
1369
  echo "ℹ️ golangci-lint not available — skipping Go linting"
1343
1370
  echo "⏭️ SKIPPED - Code quality (golangci-lint not available)"
1344
1371
  else
1345
- echo "Running golangci-lint (comprehensive static analysis)..."
1346
- golangci-lint run 2>&1 | head -30
1347
- GOLANGCI_EXIT=$?
1348
- if [ "$GOLANGCI_EXIT" -ne 0 ]; then
1349
- echo ""
1350
- echo "❌ BLOCKED - GOLANGCI-LINT ERRORS detected"
1351
- echo "Fix the lint errors above before committing."
1352
- exit 1
1372
+ # #534: run golangci-lint over ONLY the staged .go files (incremental),
1373
+ # not the whole repo. `golangci-lint run` with no args scans every package
1374
+ # and dominates pre-commit time on large Go repos. golangci-lint accepts
1375
+ # explicit file targets. No staged .go files -> SKIP (nothing to lint).
1376
+ GO_FILES=$(echo "$CHANGED_FILES" | grep '\.go$' || true)
1377
+ if [ -z "$GO_FILES" ]; then
1378
+ echo "✅ PASSED - No staged Go files to lint."
1379
+ else
1380
+ echo "Running golangci-lint (incremental, staged .go)..."
1381
+ # #534: capture golangci-lint's REAL exit code via a temp file, not a
1382
+ # pipe stage's (`... | head -30; rc=$?` would take head's status; it only
1383
+ # works because of set -o pipefail, which is fragile and inconsistent with
1384
+ # the python incremental helper's temp-file pattern).
1385
+ GO_TMP=$(mktemp)
1386
+ golangci-lint run $GO_FILES > "$GO_TMP" 2>&1
1387
+ GOLANGCI_EXIT=$?
1388
+ head -30 "$GO_TMP"
1389
+ rm -f "$GO_TMP"
1390
+ if [ "$GOLANGCI_EXIT" -ne 0 ]; then
1391
+ echo ""
1392
+ echo "❌ BLOCKED - GOLANGCI-LINT ERRORS detected"
1393
+ echo "Fix the lint errors above before committing."
1394
+ exit 1
1395
+ fi
1396
+ echo "✅ PASSED - golangci-lint."
1353
1397
  fi
1354
- echo "✅ PASSED - golangci-lint."
1355
1398
  fi
1356
1399
  ;;
1357
1400
 
@@ -1604,7 +1647,16 @@ else
1604
1647
  echo "Running jscpd for Go duplicate code detection..."
1605
1648
  FILES_TO_CHECK=$(echo "$CHANGED_FILES" | grep "\.go$" | head -20)
1606
1649
  if [ -n "$FILES_TO_CHECK" ]; then
1607
- 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
1608
1660
  fi
1609
1661
  echo "✅ PASSED - Go duplicate code check completed."
1610
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
+ });