@boyingliu01/xp-gate 0.31.1 → 0.31.2

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 (85) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +37 -1
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +10 -1
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +10 -1
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/coverage-total.sh +20 -5
  14. package/hooks/lib/gate-5c-alignment.sh +145 -0
  15. package/hooks/lib/jscpd-run.sh +40 -1
  16. package/hooks/lib/native-path.sh +106 -0
  17. package/hooks/lib/prepush-score.py +143 -0
  18. package/hooks/lib/sprint-gate-report.sh +23 -0
  19. package/hooks/lib/toolchain-path.sh +370 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  21. package/hooks/pre-commit +371 -163
  22. package/hooks/pre-push +192 -74
  23. package/hooks/sprint-gate.sh +14 -3
  24. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  25. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  26. package/lib/__tests__/doctor-delphi.test.js +46 -0
  27. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  28. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  30. package/lib/__tests__/doctor.test.js +7 -1
  31. package/lib/__tests__/hook-health-path.test.js +9 -1
  32. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  33. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  34. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  35. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  36. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  37. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  38. package/lib/arch.js +261 -44
  39. package/lib/check-alignment-runner.cjs +23 -0
  40. package/lib/check.js +4 -1
  41. package/lib/doctor.js +194 -49
  42. package/lib/gate-runner.js +265 -58
  43. package/lib/mattpocock-license-verdict.json +1 -1
  44. package/lib/principles.js +228 -6
  45. package/lib/shared-paths.js +53 -19
  46. package/mock-policy/AGENTS.md +3 -3
  47. package/mutation/AGENTS.md +3 -3
  48. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  49. package/package.json +1 -1
  50. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  51. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  53. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/plugins/dsh/package.json +1 -1
  56. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  57. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  58. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  61. package/plugins/dsh/src/delphi-config.ts +151 -9
  62. package/plugins/opencode/package.json +1 -1
  63. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  64. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  65. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  67. package/plugins/qoder/plugin.json +1 -1
  68. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  69. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  72. package/principles/AGENTS.md +3 -3
  73. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  74. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  75. package/principles/adapters/java.ts +127 -11
  76. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  77. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  78. package/principles/rules/solid/lsp.ts +348 -23
  79. package/scripts/__tests__/sync-package-content.test.js +32 -1
  80. package/scripts/sync-package-content.js +6 -1
  81. package/skills/delphi-review/AGENTS.md +3 -3
  82. package/skills/delphi-review/SKILL.md +50 -0
  83. package/skills/sprint-flow/AGENTS.md +3 -3
  84. package/skills/test-specification-alignment/AGENTS.md +3 -3
  85. package/sprint-gate.sh +14 -3
@@ -3,6 +3,159 @@ const fs = require('node:fs');
3
3
  const os = require('node:os');
4
4
  const path = require('node:path');
5
5
 
6
+ /**
7
+ * Source-file extensions the gate fragments know how to analyse.
8
+ *
9
+ * Shared by the `--all` scope resolver so a full-tree check and the adapters agree
10
+ * on what a "source file" is. Data and documentation formats are deliberately
11
+ * absent: handing a markdown file to lizard is not a check, it is noise.
12
+ */
13
+ const SOURCE_FILE_EXTENSIONS = [
14
+ 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
15
+ 'py', 'go', 'java', 'kt', 'kts', 'scala',
16
+ 'c', 'cc', 'cpp', 'cxx', 'h', 'hpp',
17
+ 'rs', 'php', 'dart', 'swift', 'm', 'mm',
18
+ 'sh', 'bash', 'ps1', 'psm1', 'rb',
19
+ ];
20
+
21
+ /**
22
+ * Every tracked source file in the repository, one per line.
23
+ *
24
+ * This is what `--all` must hand the gates that read CHANGED_FILES.
25
+ *
26
+ * #589: `check --all` was documented as a full-tree check but still built
27
+ * CHANGED_FILES from `git diff`, so on a clean tree — the normal state after a
28
+ * commit, and the state CI runs in — Gate 3 received an empty list and printed
29
+ *
30
+ * ⏭️ SKIPPED - Complexity (no source files to check)
31
+ *
32
+ * `--all` therefore checked LESS than an ordinary commit, while reading like a
33
+ * pass. Gate 4 does not depend on CHANGED_FILES and worked fine under the same
34
+ * preamble, which is what proves the file SOURCE was the defect.
35
+ *
36
+ * @param {string} [cwd] repository directory
37
+ * @returns {string} newline-separated repo-relative paths, empty on any failure
38
+ */
39
+ function resolveAllModeChangedFiles(cwd = process.cwd()) {
40
+ const pathspecs = SOURCE_FILE_EXTENSIONS.map((ext) => `*.${ext}`);
41
+ try {
42
+ const out = execFileSync('git', ['ls-files', ...pathspecs], {
43
+ cwd,
44
+ encoding: 'utf8',
45
+ stdio: ['ignore', 'pipe', 'ignore'],
46
+ });
47
+ return out.split('\n').filter(Boolean).join('\n');
48
+ } catch {
49
+ // Not a git repo, or git missing: an empty list is honest, and the caller's
50
+ // message distinguishes "empty diff" from "empty tree".
51
+ return '';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Explain an empty CHANGED_FILES honestly, given which scope produced it.
57
+ *
58
+ * The old wording ("no source files to check") described an empty PROJECT when
59
+ * the actual cause was an empty DIFF — and the natural reading of the old message
60
+ * is "this gate passed". #589's reporter worked around it by running lizard by
61
+ * hand, which is the cost of an ambiguous skip.
62
+ *
63
+ * @param {'diff'|'all'} scope which scope was used
64
+ * @param {number} count number of files in the list
65
+ * @returns {string} one line, no trailing newline
66
+ */
67
+ function describeEmptyChangedFiles(scope, count) {
68
+ if (count > 0) {
69
+ return scope === 'all'
70
+ ? `${count} tracked source file(s) in scope (--all: whole tree)`
71
+ : `${count} changed source file(s) in scope`;
72
+ }
73
+ if (scope === 'all') {
74
+ return 'no tracked source files in this repository (--all already scanned the whole tree)';
75
+ }
76
+ // Name the real cause and the action that fixes it. Deliberately does NOT say
77
+ // "no source files": that describes the project, which is not what happened.
78
+ return 'no changed files in this run (0 staged, 0 vs HEAD) — run with `--all` to scan the whole tree';
79
+ }
80
+
81
+ /**
82
+ * Report which scope a gate actually used.
83
+ *
84
+ * A silent switch between diff and tree is the same defect one level up: the
85
+ * reader cannot tell what was inspected. Printed once per standalone run so
86
+ * "full check" cannot quietly mean "diff check".
87
+ *
88
+ * @param {'diff'|'all'} scope
89
+ * @param {number} count
90
+ * @returns {string}
91
+ */
92
+ function describeChangedFilesScope(scope, count) {
93
+ const label = scope === 'all' ? 'all' : 'diff';
94
+ return `scope: ${label} (${count} file(s))`;
95
+ }
96
+
97
+ /**
98
+ * Which bash can run the gate fragments.
99
+ *
100
+ * On Windows, PATH may lead with the WSL System32 stub, which cannot run these
101
+ * hooks (Linux filesystem view, and `-c` scripts receive an empty $1 through it).
102
+ * checkBash probes for an MSYS/MINGW bash that round-trips argv correctly.
103
+ *
104
+ * @returns {string|null} the bash command, or null when only an unusable one exists
105
+ */
106
+ function resolveBashForGates() {
107
+ if (process.platform !== 'win32') return 'bash';
108
+ const { checkBash } = require('./detect-deps.js');
109
+ const bash = checkBash();
110
+ return bash.ok ? bash.path : null;
111
+ }
112
+
113
+ /**
114
+ * The first candidate that exists on disk, or the empty string.
115
+ *
116
+ * The empty string (not a throw) is deliberate: the preamble tolerates a missing
117
+ * helper script and the fragment then fails the same way it does in the commit
118
+ * hook.
119
+ *
120
+ * @param {string[]} candidates paths to try in order
121
+ * @returns {string}
122
+ */
123
+ function firstExisting(candidates) {
124
+ try {
125
+ return candidates.find(candidate => fs.existsSync(candidate)) || '';
126
+ } catch {
127
+ // existsSync only throws on a malformed path; treat that as "none".
128
+ return '';
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The shell lines that populate CHANGED_FILES, chosen by scan scope.
134
+ *
135
+ * Extracted so `runGateAdapter` stays readable: the diff form is three lines of
136
+ * git plumbing and the tree form is a single assignment, and mixing them inline
137
+ * made the wrapper harder to follow than the thing it wraps.
138
+ *
139
+ * @param {boolean} allMode true for `--all` (whole tree)
140
+ * @param {string} allModeFiles pre-resolved file list for all mode
141
+ * @returns {string[]} shell lines
142
+ */
143
+ function buildFileSourceLines(allMode, allModeFiles) {
144
+ if (allMode) {
145
+ return ['#589: --all scans the tracked tree, not the diff', `CHANGED_FILES=${JSON.stringify(allModeFiles)}`];
146
+ }
147
+ // The historical diff form, unchanged: staged first, then anything vs HEAD.
148
+ // Interpolating the literal `${CHANGED_FILES}` brace-by-brace would be torn apart
149
+ // by this file's own template literals, hence the split below.
150
+ const changedFilesParameter = ['$', '{CHANGED_FILES}'].join('');
151
+ return [
152
+ 'CHANGED_FILES="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)"',
153
+ `if [ -z "${changedFilesParameter}" ] && [ -n "$(git rev-parse HEAD 2>/dev/null)" ]; then`,
154
+ ' CHANGED_FILES="$(git diff HEAD --name-only --diff-filter=ACM 2>/dev/null || true)"',
155
+ 'fi',
156
+ ];
157
+ }
158
+
6
159
  /**
7
160
  * Run a gate adapter fragment standalone.
8
161
  *
@@ -16,75 +169,96 @@ const path = require('node:path');
16
169
  * the fragments fail with "command not found". This wrapper reconstructs the
17
170
  * same preamble the pre-commit hook sets up, then *sources* the fragment so it
18
171
  * behaves exactly as it does inside the commit hook.
172
+ *
173
+ * @param {string} scriptPath gate fragment to source
174
+ * @param {string} [cwd] project directory
175
+ * @param {{scope?: 'diff'|'all'}} [opts] `all` resolves CHANGED_FILES from the
176
+ * tracked tree instead of the diff (#589)
19
177
  */
20
- function runGateAdapter(scriptPath, cwd = process.cwd()) {
21
- let bashCommand = 'bash';
22
- if (process.platform === 'win32') {
23
- // PATH may lead with the WSL System32 stub, which cannot run these hooks
24
- // (Linux filesystem view, and `-c` scripts receive an empty $1 through it).
25
- // checkBash probes for an MSYS/MINGW bash that round-trips argv correctly.
26
- const { checkBash } = require('./detect-deps.js');
27
- const bash = checkBash();
28
- if (!bash.ok) {
29
- console.log('⚠️ This gate requires bash to run shell scripts.');
30
- // No WSL suggestion: these hooks need Git Bash specifically — the WSL
31
- // bash is rejected by the probe above (Linux filesystem view, dropped $1).
32
- console.log(' On Windows, install Git for Windows (https://git-scm.com/download/win).');
33
- console.log(' Alternatively, install the required tool directly and run the gate again.');
34
- return;
35
- }
36
- bashCommand = bash.path;
178
+ function runGateAdapter(scriptPath, cwd = process.cwd(), opts = {}) {
179
+ const bashCommand = resolveBashForGates();
180
+ if (!bashCommand) {
181
+ console.log('⚠️ This gate requires bash to run shell scripts.');
182
+ // No WSL suggestion: these hooks need Git Bash specifically — the WSL bash is
183
+ // rejected by the probe (Linux filesystem view, dropped $1).
184
+ console.log(' On Windows, install Git for Windows (https://git-scm.com/download/win).');
185
+ console.log(' Alternatively, install the required tool directly and run the gate again.');
186
+ return;
37
187
  }
38
188
  const scriptDir = path.dirname(scriptPath);
39
- const commonScript = [
189
+ const commonScript = firstExisting([
40
190
  path.join(scriptDir, 'adapter-common.sh'),
41
191
  path.join(scriptDir, '..', 'adapter-common.sh'),
42
192
  path.join(cwd, 'githooks', 'adapter-common.sh'),
43
- ].find(candidate => fs.existsSync(candidate)) || '';
44
- const nowMsScript = [
193
+ ]);
194
+ const nowMsScript = firstExisting([
45
195
  path.join(scriptDir, 'lib', 'now-ms.sh'),
46
196
  path.join(scriptDir, '..', 'lib', 'now-ms.sh'),
47
197
  path.resolve(__dirname, '..', 'hooks', 'lib', 'now-ms.sh'),
48
- ].find(candidate => fs.existsSync(candidate)) || '';
49
- const changedFilesParameter = ['$', '{CHANGED_FILES}'].join('');
198
+ ]);
199
+ // #589: the scope determines the file SOURCE. Under `--all` the gates that read
200
+ // CHANGED_FILES must see the whole tree, not the diff — otherwise a full check
201
+ // scans less than an ordinary commit and reads like a pass.
202
+ const allMode = opts.scope === 'all';
203
+ const fileSourceLines = buildFileSourceLines(allMode, allMode ? resolveAllModeChangedFiles(cwd) : '');
204
+ const wrap = buildPreamble(allMode, fileSourceLines);
50
205
 
51
- // Build the shell preamble the pre-commit hook would otherwise inject before
52
- // sourcing the gate fragment, then source that fragment. Deliberately NOT
53
- // set -u (the pre-commit hook does not use it), so fragments that reference
54
- // a not-yet-defined variable fail the same way inside the commit hook.
55
- const wrap = [
206
+ // execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
207
+ // single argv arg (no shell-metacharacter injection through the path).
208
+ execFileSync(
209
+ bashCommand,
210
+ ['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath],
211
+ { stdio: 'inherit', cwd }
212
+ );
213
+ }
214
+
215
+ /**
216
+ * The shell preamble the pre-commit hook would otherwise inject before sourcing a
217
+ * gate fragment.
218
+ *
219
+ * Deliberately NOT `set -u` (the pre-commit hook does not use it), so fragments
220
+ * that reference a not-yet-defined variable fail the same way inside the commit
221
+ * hook.
222
+ *
223
+ * @param {boolean} allMode whether the run scope is the whole tree
224
+ * @param {string[]} fileSourceLines lines that populate CHANGED_FILES
225
+ * @returns {string} the script to hand bash with -c
226
+ */
227
+ function buildPreamble(allMode, fileSourceLines) {
228
+ return [
56
229
  'if [ -n "$1" ] && [ -f "$1" ]; then source "$1"; fi',
57
230
  'if [ -n "$2" ] && [ -f "$2" ]; then source "$2"; fi',
58
231
  'if ! command -v now_ms >/dev/null 2>&1; then now_ms() { "$4" -e "console.log(Date.now())"; }; fi',
59
232
  'gate_start_ms() { now_ms; }',
60
233
  'record_gate_audit() { :; }',
61
234
  'PROJECT_LANG="$(detect_project_lang 2>/dev/null || echo unknown)"',
62
- 'CHANGED_FILES="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)"',
63
- `if [ -z "${changedFilesParameter}" ] && [ -n "$(git rev-parse HEAD 2>/dev/null)" ]; then`,
64
- ' CHANGED_FILES="$(git diff HEAD --name-only --diff-filter=ACM 2>/dev/null || true)"',
65
- 'fi',
235
+ // #589: fragments need to know which scope produced the file list, so an empty
236
+ // list can say "empty diff" instead of "empty project".
237
+ `GATE_SCOPE=${JSON.stringify(allMode ? 'all' : 'diff')}`,
238
+ ...fileSourceLines,
66
239
  'source "$3"',
67
240
  ].join('\n');
68
-
69
- // execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
70
- // single argv arg (no shell-metacharacter injection through the path).
71
- execFileSync(
72
- bashCommand,
73
- ['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath],
74
- { stdio: 'inherit', cwd }
75
- );
76
241
  }
77
242
 
78
243
  // Gate metadata registry — maps gate IDs to names, descriptions, and how to run them.
79
244
  // Standalone gates (<gate-id>: { run }) are invokable via xp-gate gate-<id>.
80
245
  // Pre-commit-only gates (<gate-id>: { preCommitOnly: true }) run only in git commit context.
81
246
 
247
+ /**
248
+ * Wall-clock budget for one TypeScript gate run.
249
+ *
250
+ * Generous on purpose: `gate-5-runner-error.test.ts` alone takes ~400s on CI, and a
251
+ * gate killed for being slow must be reported as "did not report a verdict", never
252
+ * silently counted as a pass.
253
+ */
254
+ const TS_GATE_TIMEOUT_MS = 120000;
255
+
82
256
  /**
83
257
  * Run a TypeScript gate module via npx tsx.
84
258
  * Finds the .ts entry point, spawns tsx with --run flag.
85
259
  * Falls back to bash script if TypeScript module not found.
86
260
  */
87
- function runTsGate(gateModule, targetPath) {
261
+ function runTsGate(gateModule, targetPath, opts = {}) {
88
262
  const targetCwd = targetPath ? path.resolve(targetPath) : process.cwd();
89
263
  const entry = findTsGateEntry(gateModule);
90
264
  if (!entry) {
@@ -92,7 +266,7 @@ function runTsGate(gateModule, targetPath) {
92
266
  const gateNum = gateModule.replace('gate-', '');
93
267
  const script = resolveGateScript(gateNum, targetCwd);
94
268
  if (script) {
95
- runGateAdapter(script, targetCwd);
269
+ runGateAdapter(script, targetCwd, opts);
96
270
  } else {
97
271
  console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
98
272
  }
@@ -102,14 +276,26 @@ function runTsGate(gateModule, targetPath) {
102
276
  const args = ['-y', 'tsx', entry, '--run'];
103
277
  if (targetPath) args.push('--cwd', targetPath);
104
278
 
105
- const result = spawnSync('npx', args, {
106
- stdio: 'inherit',
107
- shell: process.platform === 'win32',
108
- cwd: targetCwd,
109
- timeout: 120000,
110
- });
111
-
112
- return result.status === null ? 1 : result.status;
279
+ // spawnSync reports a spawn failure (npx missing) on `error`, throws only on a
280
+ // malformed argv, and reports a killed child (timeout, signal) as a null status.
281
+ // All three mean "the gate did not report a verdict", which must not be read as
282
+ // success: a gate that never ran is not a gate that passed.
283
+ try {
284
+ const result = spawnSync('npx', args, {
285
+ stdio: 'inherit',
286
+ shell: process.platform === 'win32',
287
+ cwd: targetCwd,
288
+ timeout: TS_GATE_TIMEOUT_MS,
289
+ });
290
+ if (result.error) {
291
+ console.error(`Gate ${gateModule}: failed to run npx tsx: ${result.error.message}`);
292
+ return 1;
293
+ }
294
+ return result.status === null ? 1 : result.status;
295
+ } catch (err) {
296
+ console.error(`Gate ${gateModule}: could not spawn npx tsx: ${err.message}`);
297
+ return 1;
298
+ }
113
299
  }
114
300
 
115
301
  function findTsGateEntry(gateModule) {
@@ -159,8 +345,8 @@ const GATE_REGISTRY = {
159
345
  name: 'Cyclomatic Complexity',
160
346
  description: 'lizard cyclomatic complexity analysis',
161
347
  aliases: ['complexity', 'ccn', '3'],
162
- run: (targetPath) => {
163
- return runTsGate('gate-3', targetPath);
348
+ run: (targetPath, opts) => {
349
+ return runTsGate('gate-3', targetPath, opts);
164
350
  },
165
351
  },
166
352
  '4': {
@@ -295,6 +481,26 @@ function findConfig(basePath, fileName) {
295
481
  return null;
296
482
  }
297
483
 
484
+ /**
485
+ * The basename of the first `gate-<num>-*.sh` in a directory, or null.
486
+ *
487
+ * A missing directory is the normal case (most projects have no `githooks/gates/`),
488
+ * so it returns null rather than throwing; a read error is also treated as "no
489
+ * match" because the caller falls through to the next candidate either way.
490
+ *
491
+ * @param {string} dir directory to list
492
+ * @param {string} gateNum gate number as a string
493
+ * @returns {string|null}
494
+ */
495
+ function matchGateFileIn(dir, gateNum) {
496
+ try {
497
+ if (!fs.existsSync(dir)) return null;
498
+ return fs.readdirSync(dir).find(f => f.startsWith(`gate-${gateNum}-`) && f.endsWith('.sh')) || null;
499
+ } catch {
500
+ return null;
501
+ }
502
+ }
503
+
298
504
  function resolveGateScript(gateNum, cwd = process.cwd()) {
299
505
  const candidates = [
300
506
  path.join(cwd, 'githooks', 'gates', `gate-${gateNum}-*.sh`),
@@ -305,10 +511,8 @@ function resolveGateScript(gateNum, cwd = process.cwd()) {
305
511
  for (const pattern of candidates) {
306
512
  if (pattern.includes('*')) {
307
513
  const dir = path.dirname(pattern);
308
- if (fs.existsSync(dir)) {
309
- const match = fs.readdirSync(dir).find(f => f.startsWith(`gate-${gateNum}-`) && f.endsWith('.sh'));
310
- if (match) return path.join(dir, match);
311
- }
514
+ const match = matchGateFileIn(dir, gateNum);
515
+ if (match) return path.join(dir, match);
312
516
  } else if (fs.existsSync(pattern)) {
313
517
  return pattern;
314
518
  }
@@ -337,7 +541,7 @@ function getAllGates() {
337
541
  }));
338
542
  }
339
543
 
340
- async function runGate(gateId, targetPath) {
544
+ async function runGate(gateId, targetPath, opts = {}) {
341
545
  const gate = GATE_REGISTRY[String(gateId)];
342
546
  if (!gate) {
343
547
  console.error(`Unknown gate: ${gateId}`);
@@ -356,7 +560,10 @@ async function runGate(gateId, targetPath) {
356
560
  if (gate.run) {
357
561
  console.log(`━━━ Gate ${gateId}: ${gate.name} ━━━`);
358
562
  try {
359
- const result = gate.run(targetPath);
563
+ // #589: pass the scope through so a handler that shells out to a fragment
564
+ // can resolve its file list from the tree under --all. Handlers that ignore
565
+ // the second argument are unaffected.
566
+ const result = gate.run(targetPath, opts);
360
567
  if (result && typeof result.then === 'function') {
361
568
  return await result;
362
569
  }
@@ -378,4 +585,4 @@ async function runGate(gateId, targetPath) {
378
585
  return 1;
379
586
  }
380
587
 
381
- module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript };
588
+ module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-22T08:31:44.285Z",
5
+ "checked_at": "2026-10-25T15:15:20.118Z",
6
6
  "source": "network"
7
7
  }
package/lib/principles.js CHANGED
@@ -2,8 +2,147 @@
2
2
 
3
3
  const { spawnSync } = require('child_process');
4
4
  const fs = require('fs');
5
+ const os = require('os');
5
6
  const path = require('path');
6
7
 
8
+ /**
9
+ * Above this many characters of argv, the file list is handed over by reference
10
+ * (a temp file with `@`) instead of on the command line.
11
+ *
12
+ * #586: on Windows, `shell: true` routes through cmd.exe, whose command line is
13
+ * capped at 8191 characters. Measure from the issue: 135 Java files came to 12944
14
+ * characters before the `npx -y tsx <entry> --files --format json` prefix. That is
15
+ * not an unusually large project — it is a mid-sized one, every time.
16
+ *
17
+ * 6000 leaves room for the executable, the entry path and the flags without
18
+ * reaching the limit on any plausible installation path.
19
+ */
20
+ const ARGV_CHAR_BUDGET = 6000;
21
+
22
+ /**
23
+ * Build the executable + argument list for one principles run.
24
+ *
25
+ * Two things this must guarantee, both from #586:
26
+ *
27
+ * 1. no shell. `shell: true` is what reaches cmd.exe's 8191-character ceiling, and
28
+ * the fix is to not need a shell at all: prefer the project's own tsx run
29
+ * through `process.execPath` (an absolute node.exe — no shim, so no shell).
30
+ * 2. bounded argv. Splitting `files.join(' ')` into many argv elements was
31
+ * explicitly ruled out in the issue and it is right to rule it out: the
32
+ * bottleneck is total characters, not quoting. So a long list is handed over by
33
+ * reference through an `@file` argument, which the engine expands.
34
+ *
35
+ * @param {string[]} files source files to analyse
36
+ * @param {string} format 'json' or 'text'
37
+ * @returns {{command: string, args: string[], useShell: boolean, cleanup: string|null}}
38
+ */
39
+ function buildPrinciplesInvocation(files, format) {
40
+ const entry = findPrinciplesEntry();
41
+ const listChars = files.reduce((n, f) => n + String(f).length + 1, 0);
42
+
43
+ // Preferred shape: node.exe + tsx's own CLI, so no shim and no shell are involved.
44
+ const tsxCli = [findLocalTsxCli(), path.resolve(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs')].find(
45
+ (candidate) => candidate && fs.existsSync(candidate)
46
+ );
47
+
48
+ const command = tsxCli ? process.execPath : 'npx';
49
+ const prefix = tsxCli ? [tsxCli, entry] : ['-y', 'tsx', entry];
50
+
51
+ if (listChars <= ARGV_CHAR_BUDGET) {
52
+ return {
53
+ command,
54
+ args: [...prefix, '--files', files.join(' '), '--format', format],
55
+ useShell: false,
56
+ cleanup: null,
57
+ };
58
+ }
59
+
60
+ // By reference: one short argument instead of thousands of characters.
61
+ return buildByReferenceInvocation(prefix, files, format, command);
62
+ }
63
+
64
+ /**
65
+ * The `@file` form, used when the list is too long to inline.
66
+ *
67
+ * Split out for two reasons: it keeps `buildPrinciplesInvocation` inside the
68
+ * long-function limit, and it gives the temp-file IO a place to fail with a message
69
+ * that names the actual cause. An unwritable temp dir would otherwise surface as an
70
+ * unhandled throw from deep inside a spawn helper.
71
+ *
72
+ * @param {string[]} prefix executable-adjacent arguments (tsx cli + entry)
73
+ * @param {string[]} files source files to analyse
74
+ * @param {string} format 'json' or 'text'
75
+ * @param {string} command executable to run
76
+ * @returns {{command: string, args: string[], useShell: boolean, cleanup: string|null}}
77
+ */
78
+ function buildByReferenceInvocation(prefix, files, format, command) {
79
+ const listFile = path.join(os.tmpdir(), `xp-gate-principles-${process.pid}-${Date.now()}.txt`);
80
+ try {
81
+ fs.writeFileSync(listFile, files.join('\n'));
82
+ } catch (err) {
83
+ // No list file means no run. Throw with the cause named, rather than letting the
84
+ // spawn fail later on a path that never existed.
85
+ throw new Error(`xp-gate principles: cannot write the file list to ${listFile}: ${err.message}`);
86
+ }
87
+ return {
88
+ command,
89
+ // The @ prefix is the engine's own "read the list from this file" form.
90
+ args: [...prefix, '--files', `@${listFile}`, '--format', format],
91
+ useShell: false,
92
+ cleanup: listFile,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * How far up from the working directory to look for a project-local tsx.
98
+ *
99
+ * Enough for a hoisted monorepo `node_modules` (a package three or four levels deep),
100
+ * bounded so a filesystem with a cycle cannot loop.
101
+ */
102
+ const TSDX_SEARCH_DEPTH = 8;
103
+
104
+ /**
105
+ * Path to the project's own tsx CLI, or null.
106
+ *
107
+ * Walks up from the working directory so a monorepo's hoisted node_modules is found.
108
+ *
109
+ * @returns {string|null}
110
+ */
111
+ function findLocalTsxCli() {
112
+ let dir = process.cwd();
113
+ for (let depth = 0; depth < TSDX_SEARCH_DEPTH; depth += 1) {
114
+ const candidate = path.join(dir, 'node_modules', 'tsx', 'dist', 'cli.mjs');
115
+ if (fs.existsSync(candidate)) return candidate;
116
+ const parent = path.dirname(dir);
117
+ if (parent === dir) break;
118
+ dir = parent;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Was this failure the tool not running, or the code having problems?
125
+ *
126
+ * #586's last request, and #596's finding in the shell toolchain: a failure to
127
+ * LAUNCH the checker was reported as "Failed to run principles check", which reads
128
+ * like a principles verdict. It is not one — nothing was analysed, so nothing was
129
+ * found. Collapsing the two sends the reader to fix source code when the problem is
130
+ * the environment.
131
+ *
132
+ * @param {{error?: Error, stdout?: string, stderr?: string, status?: number|null}} result
133
+ * @returns {'TOOLCHAIN'|'FINDING'}
134
+ */
135
+ function classifyPrinciplesFailure(result) {
136
+ const error = result && result.error;
137
+ if (error && ['E2BIG', 'ENAMETOOLONG', 'ENOENT'].includes(error.code)) return 'TOOLCHAIN';
138
+ const text = `${(result && result.stdout) || ''} ${(result && result.stderr) || ''} ${(error && error.message) || ''}`;
139
+ if (/command line is too long|argument list too long|ENAMETOOLONG|E2BIG/i.test(text)) return 'TOOLCHAIN';
140
+ // Everything else is a genuine verdict from a run that happened. Do NOT widen this
141
+ // to "any non-zero exit": that would make Gate 4 unable to block on a real
142
+ // violation, which is worse than the defect being fixed.
143
+ return 'FINDING';
144
+ }
145
+
7
146
  // Resolves repo root for the installed xp-gate npm package, or the development
8
147
  // checkout when running from source. Walks up looking for either:
9
148
  // - src/principles/index.ts (this repo / dev mode)
@@ -145,12 +284,95 @@ function principles(args) {
145
284
  return Promise.resolve(1);
146
285
  }
147
286
 
148
- const result = spawnSync('npx', ['-y', 'tsx', entry, '--files', files.join(' '), '--format', format], {
149
- stdio: 'inherit',
150
- shell: process.platform === 'win32',
151
- });
287
+ // #586: build before spawning so the Windows command-line limit is a planning
288
+ // concern, not a runtime explosion. See buildPrinciplesInvocation().
289
+ return Promise.resolve(runPrinciplesPlan(buildPrinciplesInvocation(files, format)));
290
+ }
291
+
292
+ /**
293
+ * Execute a plan and return the exit code.
294
+ *
295
+ * Split from `principles` so that function stays inside the long-function limit, and
296
+ * so the launch-failure handling lives in one place with a comment saying what it is
297
+ * worth. `spawnSync` normally reports failures via `result.error`; a throw here means
298
+ * the call itself was malformed. Either way nothing was analysed, which is the fact
299
+ * worth stating rather than letting a stack trace imply the code was judged.
300
+ *
301
+ * @param {{command: string, args: string[], useShell: boolean, cleanup: string|null}} plan
302
+ * @returns {number} process exit code
303
+ */
304
+ function runPrinciplesPlan(plan) {
305
+ let result;
306
+ try {
307
+ result = spawnSync(plan.command, plan.args, {
308
+ stdio: 'inherit',
309
+ // Never a shell: `shell: true` is what reached cmd.exe's 8191-character ceiling.
310
+ shell: plan.useShell,
311
+ });
312
+ } catch (err) {
313
+ removeListFile(plan.cleanup);
314
+ console.error(`[xp-gate principles] TOOLCHAIN: could not launch the checker: ${err.message}`);
315
+ console.error(' This is an environment problem, not a principles violation - nothing was analysed.');
316
+ return 1;
317
+ }
318
+
319
+ removeListFile(plan.cleanup);
320
+ return reportPrinciplesResult(result);
321
+ }
322
+
323
+ /**
324
+ * Delete the by-reference list file, if one was written.
325
+ *
326
+ * Best effort: a leftover temp file is not worth failing a gate over, but swallowing
327
+ * the error silently is how temp directories fill up unnoticed, so it is named.
328
+ *
329
+ * @param {string|null} listFile path to remove, or null
330
+ */
331
+ function removeListFile(listFile) {
332
+ if (!listFile) return;
333
+ try {
334
+ fs.unlinkSync(listFile);
335
+ } catch (err) {
336
+ // Expected when the child already consumed/removed it; only surprising otherwise.
337
+ if (err.code !== 'ENOENT') {
338
+ console.error(`[xp-gate principles] could not remove ${listFile}: ${err.message}`);
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Turn a spawn result into the exit code to return, printing which kind of failure it
345
+ * was when there is one.
346
+ *
347
+ * Split out of `principles` to keep that function inside the long-function limit, and
348
+ * because this is the one place that decides whether the caller is looking at a code
349
+ * verdict or an environment problem — worth being able to point at directly.
350
+ *
351
+ * @param {{error?: Error, status?: number|null}} result spawnSync result
352
+ * @returns {number} process exit code
353
+ */
354
+ function reportPrinciplesResult(result) {
355
+ if (!result.error && result.status !== null) return result.status;
152
356
 
153
- return Promise.resolve(result.status === null ? 1 : result.status);
357
+ // #586's last request: say which kind of failure this is. "Failed to run principles
358
+ // check" read like a principles verdict on a run where nothing was analysed. Same
359
+ // confusion #596 fixed in the shell toolchain.
360
+ const kind = classifyPrinciplesFailure(result);
361
+ const detail = result.error ? result.error.message : 'no exit status';
362
+ if (kind === 'TOOLCHAIN') {
363
+ console.error(`[xp-gate principles] TOOLCHAIN: the checker could not be run: ${detail}`);
364
+ console.error(' This is an environment problem, not a principles violation - nothing was analysed.');
365
+ } else {
366
+ console.error(`[xp-gate principles] checker failed: ${detail}`);
367
+ }
368
+ return 1;
154
369
  }
155
370
 
156
- module.exports = { principles, expandTargets, collectSourceFiles };
371
+ module.exports = {
372
+ principles,
373
+ expandTargets,
374
+ collectSourceFiles,
375
+ buildPrinciplesInvocation,
376
+ classifyPrinciplesFailure,
377
+ ARGV_CHAR_BUDGET,
378
+ };