@boyingliu01/xp-gate 0.31.4 → 0.32.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 (144) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +149 -2
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -129,20 +129,63 @@ function firstExisting(candidates) {
129
129
  }
130
130
  }
131
131
 
132
+ /**
133
+ * Write the `--all` file list to a temporary file and return its path.
134
+ *
135
+ * The list is one line per tracked file (thousands on a real repository), so it
136
+ * cannot travel as a `bash -c` argument: on Windows the whole script is a single
137
+ * command-line string capped at 32,767 characters, and exceeding it surfaced as
138
+ * `spawnSync bash ENAMETOOLONG` — Gate 3 could not run at all under `--all`.
139
+ *
140
+ * Inlining was also semantically broken: `JSON.stringify` escapes the separators
141
+ * to a literal `\n`, and bash does not interpret escape sequences inside double
142
+ * quotes, so the gates received one long non-existent path instead of a list.
143
+ *
144
+ * @param {string} contents newline-separated file list
145
+ * @returns {string} path to the temp file
146
+ */
147
+ function writeAllModeFileList(contents) {
148
+ const listFile = path.join(os.tmpdir(), `xp-gate-all-files-${process.pid}-${Date.now()}.txt`);
149
+ try {
150
+ fs.writeFileSync(listFile, contents, 'utf8');
151
+ } catch (err) {
152
+ throw new Error(`xp-gate: cannot write the --all file list to ${listFile}: ${err.message}`);
153
+ }
154
+ return listFile;
155
+ }
156
+
157
+ /**
158
+ * Remove the temporary file list, if one was written.
159
+ *
160
+ * Best effort: a leftover file in the temp directory is not worth failing a gate
161
+ * over, but a failure other than ENOENT is named so it does not go unnoticed.
162
+ *
163
+ * @param {string} listFile path to remove, or the empty string
164
+ */
165
+ function removeFileList(listFile) {
166
+ if (!listFile) return;
167
+ try {
168
+ fs.unlinkSync(listFile);
169
+ } catch (err) {
170
+ if (err.code !== 'ENOENT') {
171
+ console.error(`[xp-gate] could not remove ${listFile}: ${err.message}`);
172
+ }
173
+ }
174
+ }
175
+
132
176
  /**
133
177
  * The shell lines that populate CHANGED_FILES, chosen by scan scope.
134
178
  *
135
179
  * 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.
180
+ * git plumbing and the tree form is a single read, and mixing them inline made
181
+ * the wrapper harder to follow than the thing it wraps.
138
182
  *
139
183
  * @param {boolean} allMode true for `--all` (whole tree)
140
- * @param {string} allModeFiles pre-resolved file list for all mode
141
184
  * @returns {string[]} shell lines
142
185
  */
143
- function buildFileSourceLines(allMode, allModeFiles) {
186
+ function buildFileSourceLines(allMode) {
144
187
  if (allMode) {
145
- return ['#589: --all scans the tracked tree, not the diff', `CHANGED_FILES=${JSON.stringify(allModeFiles)}`];
188
+ return ['#589: --all scans the tracked tree, not the diff ($5 = file list)', 'CHANGED_FILES="$(cat "$5" 2>/dev/null || true)"'];
146
189
  }
147
190
  // The historical diff form, unchanged: staged first, then anything vs HEAD.
148
191
  // Interpolating the literal `${CHANGED_FILES}` brace-by-brace would be torn apart
@@ -200,16 +243,20 @@ function runGateAdapter(scriptPath, cwd = process.cwd(), opts = {}) {
200
243
  // CHANGED_FILES must see the whole tree, not the diff — otherwise a full check
201
244
  // scans less than an ordinary commit and reads like a pass.
202
245
  const allMode = opts.scope === 'all';
203
- const fileSourceLines = buildFileSourceLines(allMode, allMode ? resolveAllModeChangedFiles(cwd) : '');
204
- const wrap = buildPreamble(allMode, fileSourceLines);
205
-
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
- );
246
+ const listFile = allMode ? writeAllModeFileList(resolveAllModeChangedFiles(cwd)) : '';
247
+ try {
248
+ const wrap = buildPreamble(allMode, buildFileSourceLines(allMode));
249
+
250
+ // execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
251
+ // single argv arg (no shell-metacharacter injection through the path).
252
+ execFileSync(
253
+ bashCommand,
254
+ ['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath, listFile],
255
+ { stdio: 'inherit', cwd }
256
+ );
257
+ } finally {
258
+ removeFileList(listFile);
259
+ }
213
260
  }
214
261
 
215
262
  /**
@@ -253,6 +300,16 @@ function buildPreamble(allMode, fileSourceLines) {
253
300
  */
254
301
  const TS_GATE_TIMEOUT_MS = 120000;
255
302
 
303
+ /**
304
+ * #618: sentinel for "this gate ran nothing at all" -- no TypeScript module and
305
+ * no bash fallback. It must never be conflated with 0 ("ran, found nothing").
306
+ * Returned up through runGate() so the --all summary counts it as skipped
307
+ * rather than passed: three advertised gates were analysing nothing and sitting
308
+ * inside the passed count, which is the same defect class as a vacuous pass
309
+ * elsewhere -- a dimension nobody measured, scored as if measured.
310
+ */
311
+ const GATE_NOT_RUN = 'NOT_RUN';
312
+
256
313
  /**
257
314
  * Run a TypeScript gate module via npx tsx.
258
315
  * Finds the .ts entry point, spawns tsx with --run flag.
@@ -266,11 +323,13 @@ function runTsGate(gateModule, targetPath, opts = {}) {
266
323
  const gateNum = gateModule.replace('gate-', '');
267
324
  const script = resolveGateScript(gateNum, targetCwd);
268
325
  if (script) {
269
- runGateAdapter(script, targetCwd, opts);
270
- } else {
271
- console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
326
+ return runGateAdapter(script, targetCwd, opts);
272
327
  }
273
- return 0;
328
+ // #618: nothing ran. Before this, the function fell through to `return 0`
329
+ // and the --all summary counted the gate as passed.
330
+ console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
331
+ console.log(`⏭️ SKIPPED - Gate ${gateModule} did not run (no module, no bash fallback)`);
332
+ return GATE_NOT_RUN;
274
333
  }
275
334
 
276
335
  const args = ['-y', 'tsx', entry, '--run'];
@@ -484,9 +543,8 @@ function findConfig(basePath, fileName) {
484
543
  /**
485
544
  * The basename of the first `gate-<num>-*.sh` in a directory, or null.
486
545
  *
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.
546
+ * A missing directory is the normal case -- most projects have no
547
+ * `githooks/gates/` -- so it returns null rather than throwing.
490
548
  *
491
549
  * @param {string} dir directory to list
492
550
  * @param {string} gateNum gate number as a string
@@ -501,22 +559,37 @@ function matchGateFileIn(dir, gateNum) {
501
559
  }
502
560
  }
503
561
 
562
+ /**
563
+ * Locate the bash script that implements a gate, for the TS-less fallback path.
564
+ *
565
+ * Candidate order:
566
+ * 1. `<cwd>/githooks/gates/gate-<n>-*.sh` -- a consumer's OWN gate scripts. The
567
+ * hyphenated name is what lets a project keep several variants of one gate
568
+ * (`gate-9-public-fallback.sh`), which the flat `gate-<n>.sh` name cannot
569
+ * express. `xp-gate init` does not create this directory and the npm package
570
+ * does not ship it, so in a stock project it simply never matches. It is
571
+ * still a supported extension point, pinned by the REQ-standalone-gates-05
572
+ * and -06 tests in lib/__tests__/gate-runner.test.js.
573
+ * 2. `<cwd>/githooks/gate-<n>.sh` -- the flat copy `xp-gate init` installs.
574
+ * 3. `~/.config/xp-gate/adapters/gate-<n>.sh` -- the global install.
575
+ *
576
+ * NOTE: this repository's own `githooks/gates/` was a different thing entirely --
577
+ * five drifted copies of the canonical gates that only ever matched inside this
578
+ * checkout, shadowing `githooks/gate-N.sh` (36 lines apart for gate-9, still
579
+ * carrying the GATE_10_STATUS artifact from #532). Those were deleted; the
580
+ * lookup path above was not, because consumer projects legitimately use it.
581
+ *
582
+ * @param {string} gateNum gate number as a string
583
+ * @param {string} cwd project root to search
584
+ * @returns {string|null}
585
+ */
504
586
  function resolveGateScript(gateNum, cwd = process.cwd()) {
505
- const candidates = [
506
- path.join(cwd, 'githooks', 'gates', `gate-${gateNum}-*.sh`),
507
- path.join(cwd, 'githooks', `gate-${gateNum}.sh`),
508
- ];
587
+ const gatesDir = path.join(cwd, 'githooks', 'gates');
588
+ const flexible = matchGateFileIn(gatesDir, gateNum);
589
+ if (flexible) return path.join(gatesDir, flexible);
509
590
 
510
- // Try glob patterns
511
- for (const pattern of candidates) {
512
- if (pattern.includes('*')) {
513
- const dir = path.dirname(pattern);
514
- const match = matchGateFileIn(dir, gateNum);
515
- if (match) return path.join(dir, match);
516
- } else if (fs.existsSync(pattern)) {
517
- return pattern;
518
- }
519
- }
591
+ const projectScript = path.join(cwd, 'githooks', `gate-${gateNum}.sh`);
592
+ if (fs.existsSync(projectScript)) return projectScript;
520
593
 
521
594
  // Check global install
522
595
  const globalDir = path.join(os.homedir(), '.config', 'xp-gate', 'adapters');
@@ -573,6 +646,7 @@ async function runGate(gateId, targetPath, opts = {}) {
573
646
  // failing gate from a passing one. Handlers that return nothing at all
574
647
  // (gate 2, whose execFileSync throws on a non-zero exit instead) are
575
648
  // still treated as pass, because their failure signal is the throw below.
649
+ if (result === GATE_NOT_RUN) return GATE_NOT_RUN;
576
650
  return typeof result === 'number' ? result : 0;
577
651
  } catch (err) {
578
652
  // Covers a synchronous throw AND a rejection from the awaited handler.
@@ -585,4 +659,4 @@ async function runGate(gateId, targetPath, opts = {}) {
585
659
  return 1;
586
660
  }
587
661
 
588
- module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
662
+ module.exports = { GATE_REGISTRY, GATE_NOT_RUN, 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-26T09:56:14.218Z",
5
+ "checked_at": "2026-10-28T17:43:58.740Z",
6
6
  "source": "network"
7
7
  }
package/lib/principles.js CHANGED
@@ -44,21 +44,41 @@ function buildPrinciplesInvocation(files, format) {
44
44
  const tsxCli = [findLocalTsxCli(), path.resolve(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs')].find(
45
45
  (candidate) => candidate && fs.existsSync(candidate)
46
46
  );
47
+ // Second-best shape: run npm's own npx entry with node.exe. A bare `npx` cannot be
48
+ // spawned without a shell on Windows (CreateProcess only resolves `.exe`, and the
49
+ // installed shim is `npx.cmd`), which surfaced as spawnSync ENOENT (#<new>).
50
+ const npxCli = tsxCli ? null : findNpxCli();
47
51
 
48
- const command = tsxCli ? process.execPath : 'npx';
49
- const prefix = tsxCli ? [tsxCli, entry] : ['-y', 'tsx', entry];
52
+ let command;
53
+ let prefix;
54
+ let useShell;
55
+ if (tsxCli) {
56
+ command = process.execPath;
57
+ prefix = [tsxCli, entry];
58
+ useShell = false;
59
+ } else if (npxCli) {
60
+ command = process.execPath;
61
+ prefix = [npxCli, '-y', 'tsx', entry];
62
+ useShell = false;
63
+ } else {
64
+ // Last resort: a bare npx on PATH. Only a shell can resolve it on Windows, so the
65
+ // flag has to travel with the plan instead of being hard-coded false.
66
+ command = 'npx';
67
+ prefix = ['-y', 'tsx', entry];
68
+ useShell = process.platform === 'win32';
69
+ }
50
70
 
51
71
  if (listChars <= ARGV_CHAR_BUDGET) {
52
72
  return {
53
73
  command,
54
74
  args: [...prefix, '--files', files.join(' '), '--format', format],
55
- useShell: false,
75
+ useShell,
56
76
  cleanup: null,
57
77
  };
58
78
  }
59
79
 
60
80
  // By reference: one short argument instead of thousands of characters.
61
- return buildByReferenceInvocation(prefix, files, format, command);
81
+ return buildByReferenceInvocation(prefix, files, format, command, useShell);
62
82
  }
63
83
 
64
84
  /**
@@ -73,9 +93,10 @@ function buildPrinciplesInvocation(files, format) {
73
93
  * @param {string[]} files source files to analyse
74
94
  * @param {string} format 'json' or 'text'
75
95
  * @param {string} command executable to run
96
+ * @param {boolean} useShell whether the spawn needs a shell to resolve the command
76
97
  * @returns {{command: string, args: string[], useShell: boolean, cleanup: string|null}}
77
98
  */
78
- function buildByReferenceInvocation(prefix, files, format, command) {
99
+ function buildByReferenceInvocation(prefix, files, format, command, useShell) {
79
100
  const listFile = path.join(os.tmpdir(), `xp-gate-principles-${process.pid}-${Date.now()}.txt`);
80
101
  try {
81
102
  fs.writeFileSync(listFile, files.join('\n'));
@@ -88,7 +109,7 @@ function buildByReferenceInvocation(prefix, files, format, command) {
88
109
  command,
89
110
  // The @ prefix is the engine's own "read the list from this file" form.
90
111
  args: [...prefix, '--files', `@${listFile}`, '--format', format],
91
- useShell: false,
112
+ useShell,
92
113
  cleanup: listFile,
93
114
  };
94
115
  }
@@ -120,6 +141,27 @@ function findLocalTsxCli() {
120
141
  return null;
121
142
  }
122
143
 
144
+ /**
145
+ * Path to npm's own npx CLI entry (`npx-cli.js`), or null.
146
+ *
147
+ * `spawnSync('npx', ...)` without a shell works on POSIX (npx is a symlinked JS
148
+ * script) but fails on Windows with ENOENT: CreateProcess resolves only `.exe`
149
+ * names, and the installed entry point is `npx.cmd`. Running npm's JS entry with
150
+ * the current node binary is equivalent and platform-independent.
151
+ *
152
+ * @returns {string|null}
153
+ */
154
+ function findNpxCli() {
155
+ const execDir = path.dirname(process.execPath);
156
+ const candidates = [
157
+ // Windows/self-contained Node: <nodeDir>/node_modules/npm/bin/npx-cli.js
158
+ path.join(execDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
159
+ // POSIX layout: <prefix>/bin/node → <prefix>/lib/node_modules/npm/bin/npx-cli.js
160
+ path.resolve(execDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'),
161
+ ];
162
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
163
+ }
164
+
123
165
  /**
124
166
  * Was this failure the tool not running, or the code having problems?
125
167
  *
@@ -57,6 +57,14 @@ function copyAdapters(srcDir, destDir) {
57
57
  if (fs.existsSync(adapterCommon)) {
58
58
  fs.copyFileSync(adapterCommon, path.join(destDir, 'adapter-common.sh'));
59
59
  }
60
+ // Shared shell helpers the fragments `source` at runtime (ccn-ratchet.sh and
61
+ // friends). Without this, gate-3.sh resolves $GATE_DIR/lib/ccn-ratchet.sh to a
62
+ // path that no installer ever created, and `.` failing there silently disabled
63
+ // the complexity ratchet: a CCN regression was graded PASS.
64
+ const libSrc = path.join(srcDir, 'hooks', 'lib');
65
+ if (fs.existsSync(libSrc)) {
66
+ copyDirRecursive(libSrc, path.join(destDir, 'lib'));
67
+ }
60
68
  if (fs.existsSync(adaptersDir)) {
61
69
  fs.readdirSync(adaptersDir).forEach(f => {
62
70
  if (f.endsWith('.sh')) {
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-28
4
+ **Commit:** 0f58c300
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -86,10 +86,28 @@ interface ValidateImportInput {
86
86
  engine: MockDecisionEngine;
87
87
  layer: ReturnType<typeof detectTestLayer>;
88
88
  severity: 'warning' | 'error';
89
+ lenientUnit: boolean;
90
+ }
91
+
92
+ /**
93
+ * Is this file exempt from the strategy check?
94
+ *
95
+ * A lenient unit layer only RECOMMENDS mocking every dependency. Reporting a test
96
+ * that injects a hand-written fake instead would flag tests that are already
97
+ * isolated, which is how a gate teaches people to ignore it.
98
+ */
99
+ function isLenientUnitFile(
100
+ lenientUnit: boolean,
101
+ layer: ReturnType<typeof detectTestLayer>,
102
+ ): boolean {
103
+ if (!lenientUnit) return false;
104
+ return layer === 'unit' || layer === 'unknown';
89
105
  }
90
106
 
91
107
  function validateImport(input: ValidateImportInput): MockPolicyViolation[] {
92
- const { testFile, content, importPath, engine, layer, severity } = input;
108
+ const { testFile, content, importPath, engine, layer, severity, lenientUnit } = input;
109
+ if (isLenientUnitFile(lenientUnit, layer)) return [];
110
+
93
111
  const decision = engine.decide(importPath, layer);
94
112
  const actualStrategy = detectMockUsage(content, importPath);
95
113
  const violations: MockPolicyViolation[] = [];
@@ -117,9 +135,22 @@ async function validateFile(
117
135
  engine: MockDecisionEngine,
118
136
  projectRoot: string,
119
137
  severity: 'warning' | 'error',
138
+ lenientUnit: boolean,
120
139
  ): Promise<MockPolicyViolation[]> {
121
140
  const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
122
- const content = await readFile(fullPath, 'utf-8');
141
+ // A changed-file list naming a file that cannot be read is broken input, and
142
+ // gate-m3.test.ts pins the rejection. Reporting "no violations" for a file nobody
143
+ // could read is the one failure a gate must never have, so the error is caught
144
+ // only to add the path and then rethrown.
145
+ let content: string;
146
+ try {
147
+ content = await readFile(fullPath, 'utf-8');
148
+ } catch (error) {
149
+ // String(error) rather than (error as Error).message: a thrower is free to
150
+ // reject with a non-Error, and that cast would render the cause as
151
+ // "undefined" — losing the only diagnostic this rethrow exists to add.
152
+ throw new Error(`cannot read test file ${testFile}: ${String(error)}`);
153
+ }
123
154
  const layer = detectTestLayer(testFile);
124
155
  const imports = collectImports(content);
125
156
 
@@ -130,9 +161,25 @@ async function validateFile(
130
161
  engine,
131
162
  layer,
132
163
  severity,
164
+ lenientUnit,
133
165
  }));
134
166
  }
135
167
 
168
+ /** Every import mentioned across the given test files, deduplicated. */
169
+ async function collectAllImports(testFiles: string[], projectRoot: string): Promise<string[]> {
170
+ const all: string[] = [];
171
+ for (const testFile of testFiles) {
172
+ try {
173
+ const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
174
+ const content = await readFile(fullPath, 'utf-8');
175
+ all.push(...collectImports(content));
176
+ } catch {
177
+ // Unreadable file — the per-file pass reports nothing for it either.
178
+ }
179
+ }
180
+ return [...new Set(all)];
181
+ }
182
+
136
183
  export async function runGateM3(
137
184
  changedFiles: string[],
138
185
  projectRoot: string = process.cwd(),
@@ -151,38 +198,25 @@ export async function runGateM3(
151
198
  const config = await loadMockPolicyConfig(projectRoot);
152
199
  const severity: 'warning' | 'error' = config.severity;
153
200
 
154
- // Collect all imports from all test files
155
- const allImports: string[] = [];
156
- for (const testFile of testFiles) {
157
- try {
158
- const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
159
- const content = await readFile(fullPath, 'utf-8');
160
- allImports.push(...collectImports(content));
161
- } catch {
162
- // File not readable — skip
163
- }
164
- }
165
-
166
201
  const scope = await scanProjectScope({
167
202
  projectRoot,
168
- imports: [...new Set(allImports)],
203
+ imports: await collectAllImports(testFiles, projectRoot),
169
204
  boundary: config.projectBoundary,
170
205
  });
171
206
 
172
207
  const engine = new MockDecisionEngine(scope, config, projectRoot);
173
- const allViolations: MockPolicyViolation[] = [];
208
+ // Resolved once from config: a lenient unit layer is advisory, so its files skip
209
+ // strategy comparison entirely.
210
+ const lenientUnit = config.layers.unit.mockPolicy === 'lenient';
174
211
 
212
+ const allViolations: MockPolicyViolation[] = [];
175
213
  let integrationCount = 0;
176
214
  for (const testFile of testFiles) {
177
- const layer = detectTestLayer(testFile);
178
- if (layer === 'integration') integrationCount++;
179
-
180
- const violations = await validateFile(testFile, engine, projectRoot, severity);
181
- allViolations.push(...violations);
215
+ if (detectTestLayer(testFile) === 'integration') integrationCount++;
216
+ allViolations.push(...await validateFile(testFile, engine, projectRoot, severity, lenientUnit));
182
217
  }
183
218
 
184
- const hasBlockableViolations = allViolations.some(v => v.severity === 'error');
185
- const blocked = severity === 'error' && hasBlockableViolations;
219
+ const blocked = severity === 'error' && allViolations.some(v => v.severity === 'error');
186
220
 
187
221
  return {
188
222
  exitCode: blocked ? 1 : 0,
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-28
4
+ **Commit:** 0f58c300
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @test REQ-630
3
+ * @intent A Gradle build file alone is not enough: PITest is launched through
4
+ * `./gradlew` or a `gradle` on PATH. The pre-existing isAvailable tests
5
+ * mocked EVERY existsSync to true and mocked the gradle launcher to
6
+ * succeed, so "build.gradle configures pitest but no launcher can be
7
+ * run" was never exercised, and deleting the launcher guard left the
8
+ * suite green. On a machine with a pom-free Gradle project and no gradle
9
+ * installed, isAvailable() would claim the runner works, and Gate M
10
+ * would fail later with a confusing "gradle not found".
11
+ *
12
+ * These cases live here rather than in pitest-runner.test.ts because
13
+ * that file carries pre-existing warnings; editing it would trigger the
14
+ * Boy Scout "<=5 warnings must clear to zero" rule for unrelated code.
15
+ * @covers AC-630-01, AC-630-02
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
19
+ import { PitestRunner } from '../runners/pitest-runner';
20
+
21
+ // `fs` is mocked module-wide; each case sets the answers it needs.
22
+ vi.mock('fs', () => ({
23
+ existsSync: vi.fn(),
24
+ readFileSync: vi.fn(),
25
+ readdirSync: vi.fn(),
26
+ }));
27
+ // `execSync` decides whether a launcher runs; `spawn` is unused by isAvailable
28
+ // but is imported by the runner module.
29
+ vi.mock('child_process', () => ({ execSync: vi.fn(), spawn: vi.fn() }));
30
+
31
+ describe('PitestRunner.isAvailable — Gradle launcher requirement', () => {
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ it('AC-630-01: no ./gradlew and gradle not runnable -> unavailable despite a pitest build file', async () => {
37
+ const { existsSync, readFileSync } = await import('fs');
38
+ const { execSync } = await import('child_process');
39
+ // Maven throws; the gradle launcher also throws (not installed).
40
+ vi.mocked(execSync).mockImplementation(() => {
41
+ throw new Error('not found');
42
+ });
43
+ // build.gradle exists and configures pitest; ./gradlew does NOT exist.
44
+ vi.mocked(existsSync).mockImplementation((p: unknown) =>
45
+ String(p).endsWith('build.gradle'),
46
+ );
47
+ vi.mocked(readFileSync).mockReturnValue(
48
+ 'plugins { id "info.solidsoft.pitest" version "1.15.0" }',
49
+ );
50
+
51
+ const runner = new PitestRunner();
52
+ expect(await runner.isAvailable()).toBe(false);
53
+ });
54
+
55
+ it('AC-630-02: ./gradlew present -> the gradle launcher is not required on PATH', async () => {
56
+ const { existsSync, readFileSync } = await import('fs');
57
+ const { execSync } = await import('child_process');
58
+ // Every launcher probe throws, but ./gradlew exists, so a wrapper-based
59
+ // build must still count as available.
60
+ vi.mocked(execSync).mockImplementation(() => {
61
+ throw new Error('not found');
62
+ });
63
+ vi.mocked(existsSync).mockImplementation((p: unknown) => {
64
+ const s = String(p);
65
+ return s.includes('gradlew') || s.endsWith('build.gradle.kts');
66
+ });
67
+ vi.mocked(readFileSync).mockReturnValue(
68
+ 'plugins { id("info.solidsoft.gradle.pitest") version "1.15.0" }',
69
+ );
70
+
71
+ const runner = new PitestRunner();
72
+ expect(await runner.isAvailable()).toBe(true);
73
+ });
74
+ });
@@ -31,7 +31,10 @@ describe('test-path filter parity between pre-push and gate-m (#480)', () => {
31
31
  it('both sides exclude declaration files and spec-named tests', () => {
32
32
  expect(prePushFilter).toContain(String.raw`\.d\.ts$`);
33
33
  expect(prePushFilter).toContain(String.raw`\.spec\.`);
34
- expect(gateM).toContain("endsWith('.d.ts')");
35
- expect(gateM).toContain("includes('.spec.')");
34
+ // gate-m states each exclusion as an entry in EXCLUDED_PATH_PATTERNS rather
35
+ // than as an endsWith/includes call, so the parity assertion follows the
36
+ // regex. What it pins is unchanged: the TS side still excludes both.
37
+ expect(gateM).toContain(String.raw`pattern: /\.d\.ts$/`);
38
+ expect(gateM).toContain(String.raw`pattern: /\.spec\./`);
36
39
  });
37
40
  });
@@ -80,39 +80,45 @@ function parseTimeout(options: GateMOptions, args: string[], i: number): void {
80
80
 
81
81
  // ── File filtering: excludes tests, declarations, adapters ──
82
82
  // Delegate per-language filtering to a helper.
83
-
83
+ //
84
+ // Every exclusion is a "does this normalized path look like X?" question, so
85
+ // they are stated as data rather than as a chain of early returns. Keeping the
86
+ // list declarative is what makes the rules reviewable: a new convention is one
87
+ // line, and the reasons stay attached to the pattern they justify.
84
88
  export function filterSourceFiles(files: string[]): string[] {
85
89
  return files.filter(file => {
86
- const ext = path.extname(file);
87
90
  // One separator view for every pattern below: the path arrives from the
88
91
  // adapter layer, which on Windows yields backslash-delimited relative paths.
89
92
  const normalized = file.replace(/\\/g, '/');
90
- // Skip test files
91
- if (normalized.includes('.test.') || normalized.includes('_test.')) return false;
92
- // Skip spec-named test files: the pre-push filter already excludes them, and
93
- // a mutant inside a *.spec.ts is scored against a production budget (#480).
94
- if (normalized.includes('.spec.')) return false;
95
- // Skip test-tree directories: helpers/fixtures/factories there are test
96
- // infrastructure judged by the suites that run them, not by mutation
97
- // scoring budgeted for production code (#480)
98
- if (/(^|\/)(tests?|__tests__)\//.test(normalized)) return false;
99
- // Skip Java/Kotlin test files: FooTest.java, FooSpec.kt, etc. Case-sensitive
100
- // "Test"/"Spec"/"IT" suffix matching is what the Surefire/Failsafe conventions
101
- // actually produce -- `[A-Z]Test\.only` caught MyTest.java but not FooTest.java,
102
- // so most test classes stayed in the mutation candidate list.
103
- if (/Test\.(java|kt|kts)$/.test(normalized)) return false;
104
- if (/Tests\.(java|kt|kts)$/.test(normalized)) return false;
105
- if (/(IT|Spec)\.(java|kt|kts)$/.test(normalized)) return false;
106
- // Skip declaration files
107
- if (normalized.endsWith('.d.ts')) return false;
108
- // Skip adapter files
109
- if (normalized.includes('/adapters/')) return false;
93
+ if (isExcludedPath(normalized)) return false;
110
94
  // Skip files with no registered runner
111
- const runner = resolveRunner(ext);
112
- return runner !== undefined;
95
+ return resolveRunner(path.extname(file)) !== undefined;
113
96
  });
114
97
  }
115
98
 
99
+ const EXCLUDED_PATH_PATTERNS: readonly { pattern: RegExp; reason: string }[] = [
100
+ { pattern: /\.test\.|_test\./, reason: 'test files' },
101
+ // Spec-named test files: the pre-push filter already excludes them, and a
102
+ // mutant inside a *.spec.ts is scored against a production budget (#480).
103
+ { pattern: /\.spec\./, reason: 'spec files' },
104
+ // Test-tree directories: helpers/fixtures/factories there are test
105
+ // infrastructure judged by the suites that run them, not by mutation
106
+ // scoring budgeted for production code (#480)
107
+ { pattern: /(^|\/)(tests?|__tests__)\//, reason: 'test trees' },
108
+ // Java/Kotlin test files: FooTest.java, FooSpec.kt, etc. Case-sensitive
109
+ // "Test"/"Spec"/"IT" suffix matching is what the Surefire/Failsafe
110
+ // conventions actually produce -- `[A-Z]Test\.only` caught MyTest.java but
111
+ // not FooTest.java, so most test classes stayed in the candidate list.
112
+ { pattern: /(Test|Tests|IT|Spec)\.(java|kt|kts)$/, reason: 'JVM test classes' },
113
+ { pattern: /\.d\.ts$/, reason: 'declaration files' },
114
+ { pattern: /\/adapters\//, reason: 'adapter files' },
115
+ ];
116
+
117
+ /** True when `normalized` matches any exclusion rule. */
118
+ function isExcludedPath(normalized: string): boolean {
119
+ return EXCLUDED_PATH_PATTERNS.some(({ pattern }) => pattern.test(normalized));
120
+ }
121
+
116
122
  async function fileExists(filePath: string): Promise<boolean> {
117
123
  try {
118
124
  await fs.access(filePath);