@boyingliu01/xp-gate 0.31.3 → 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 (151) 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 +269 -13
  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-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/lib/doctor-tui.js CHANGED
@@ -117,38 +117,128 @@ function compareSemver(a, b) {
117
117
  return 0;
118
118
  }
119
119
 
120
+ /**
121
+ * Every skills directory `xp-gate init` deploys to, derived from
122
+ * PLATFORM_PROFILES so the platform-to-directory mapping has one source of
123
+ * truth. A hardcoded copy here would drift the moment a platform is added
124
+ * (Delphi R1 MA-01).
125
+ *
126
+ * init deploys to all of these at once, so doctor has to verify all of them —
127
+ * resolving a single "current" platform silently left the others unchecked
128
+ * (#635). `.dsh` is included because the package ships `plugins/dsh` and
129
+ * `sync-package-content.js` treats `dsh` as a first-class platform, even
130
+ * though `detectPlatform()` never returns it.
131
+ *
132
+ * Resolved through getSkillsDirs() at call time: the home directory and the
133
+ * profile list are read lazily, and tests reassign process.env.HOME.
134
+ *
135
+ * @returns {Array<{platform: string, dir: string}>}
136
+ */
137
+ function skillsDeployTargets() {
138
+ const { PLATFORM_PROFILES, getSkillsDirs } = require('./detect-deps.js');
139
+ return Object.keys(PLATFORM_PROFILES).map((platform) => ({
140
+ platform,
141
+ // skillsDirs[0] is the skills directory; [1] is the broader platform dir.
142
+ dir: getSkillsDirs(platform)[0],
143
+ }));
144
+ }
145
+
146
+ /**
147
+ * List the skill names actually present in a skills directory.
148
+ *
149
+ * A skill counts as installed when `<dir>/<name>/SKILL.md` exists — the same
150
+ * shape `install-skill` writes. Returns [] when the directory is missing or
151
+ * unreadable; a missing directory is "not deployed here", not an error.
152
+ *
153
+ * @param {string} dir
154
+ * @returns {string[]}
155
+ */
156
+ function listInstalledSkillNames(dir) {
157
+ let entries;
158
+ try {
159
+ entries = fs.readdirSync(dir, { withFileTypes: true });
160
+ } catch {
161
+ return [];
162
+ }
163
+ return entries
164
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
165
+ .map((e) => e.name)
166
+ .sort();
167
+ }
168
+
120
169
  /**
121
170
  * Check 9: Installed skills vs package-bundled skills (#332).
122
171
  * Uses semver version comparison on SKILL.md frontmatter when available;
123
172
  * falls back to byte-for-byte content comparison for skills without version field.
173
+ *
174
+ * The skill list comes from the filesystem, not from `config.installedSkills`:
175
+ * `xp-gate init` never writes that field, so keying off it made this check
176
+ * report "No skills installed" on a fully deployed machine and return before
177
+ * touching disk (#635). An explicit `config.installedSkills` is still honoured
178
+ * when present, so callers and older tests keep their behaviour.
179
+ *
124
180
  * @param {object} config
125
181
  * @param {Array} checks
126
182
  * @returns {number} issue count
127
183
  */
128
184
  function diagnoseInstalledSkills(config, checks) {
129
- const installedSkills = config.installedSkills || {};
130
- const skillNames = Object.keys(installedSkills);
131
- if (skillNames.length === 0) {
132
- checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'No skills installed' });
133
- return 0;
134
- }
135
-
136
185
  const bundledSkillsDir = path.join(PKG_DIR, 'skills');
137
186
  if (!fs.existsSync(bundledSkillsDir)) {
138
187
  checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'Package skills dir not found' });
139
188
  return 0;
140
189
  }
141
190
 
191
+ const explicit = config && config.installedSkills ? Object.keys(config.installedSkills) : [];
192
+ const targets = skillsDeployTargets();
193
+
194
+ // An explicit `installedSkills` list is a caller saying "check THIS list, for
195
+ // THIS platform" — legacy single-target behaviour, kept so existing callers
196
+ // and their tests retain an unchanged contract (Delphi R1 MA-02).
197
+ //
198
+ // The asymmetry with the default path below is deliberate and load-bearing:
199
+ // the explicit list names no directory, so a platform still has to be
200
+ // inferred, and silently checking a different platform's directory than the
201
+ // caller meant would be worse than checking one. `xp-gate init` never writes
202
+ // this field, so in practice doctor takes the filesystem path below.
142
203
  const platform = detectPlatform();
143
- let userSkillsDir;
144
- if (platform === 'qoder') {
145
- userSkillsDir = path.join(HOME_DIR, '.qoder', 'skills');
146
- } else if (platform === 'claude-code') {
147
- userSkillsDir = path.join(HOME_DIR, '.claude', 'skills');
148
- } else {
149
- userSkillsDir = path.join(HOME_DIR, '.config', 'opencode', 'skills');
204
+ if (explicit.length > 0) {
205
+ const match = targets.find((t) => t.platform === platform) || targets[0];
206
+ return diagnoseSkillsInDir(explicit, match.dir, bundledSkillsDir, checks, null);
150
207
  }
151
208
 
209
+ // No explicit list: verify every directory that actually has skills deployed.
210
+ const deployed = targets.filter((t) => listInstalledSkillNames(t.dir).length > 0);
211
+ if (deployed.length === 0) {
212
+ checks.push({
213
+ name: 'Installed skills',
214
+ status: 'SKIP',
215
+ detail: `No skills deployed in any known location (${targets.map((t) => t.platform).join(', ')})`,
216
+ });
217
+ return 0;
218
+ }
219
+
220
+ let issues = 0;
221
+ for (const target of deployed) {
222
+ const names = listInstalledSkillNames(target.dir);
223
+ issues += diagnoseSkillsInDir(names, target.dir, bundledSkillsDir, checks, target.platform);
224
+ }
225
+ return issues;
226
+ }
227
+
228
+ /**
229
+ * Compare one deployed skills directory against the bundled copy.
230
+ *
231
+ * @param {string[]} skillNames
232
+ * @param {string} userSkillsDir
233
+ * @param {string} bundledSkillsDir
234
+ * @param {Array} checks
235
+ * @param {string|null} platformLabel - prefix for check names, or null for the
236
+ * legacy unprefixed names (explicit-installedSkills callers).
237
+ * @returns {number} issue count
238
+ */
239
+ function diagnoseSkillsInDir(skillNames, userSkillsDir, bundledSkillsDir, checks, platformLabel) {
240
+ const label = (name) => (platformLabel ? `Skill [${platformLabel}]: ${name}` : `Skill: ${name}`);
241
+
152
242
  let issues = 0;
153
243
  for (const name of skillNames) {
154
244
  const bundledSkillMd = path.join(bundledSkillsDir, name, 'SKILL.md');
@@ -156,7 +246,7 @@ function diagnoseInstalledSkills(config, checks) {
156
246
 
157
247
  if (!fs.existsSync(bundledSkillMd)) continue;
158
248
  if (!fs.existsSync(userSkillMd)) {
159
- checks.push({ name: `Skill: ${name}`, status: 'FAIL', detail: 'Installed SKILL.md missing' });
249
+ checks.push({ name: label(name), status: 'FAIL', detail: 'Installed SKILL.md missing' });
160
250
  issues++;
161
251
  continue;
162
252
  }
@@ -174,24 +264,24 @@ function diagnoseInstalledSkills(config, checks) {
174
264
  // Version strings not valid semver — fall back to content comparison
175
265
  if (!sameContentIgnoringLineEndings(bundledContent, userContent)) {
176
266
  checks.push({
177
- name: `Skill: ${name}`,
267
+ name: label(name),
178
268
  status: 'WARN',
179
269
  detail: `Outdated — run xp-gate update-skill --all (installed: ${userVersion}, bundled: ${bundledVersion})`,
180
270
  });
181
271
  issues++;
182
272
  } else {
183
- checks.push({ name: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
273
+ checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
184
274
  }
185
275
  } else if (cmp < 0) {
186
276
  checks.push({
187
- name: `Skill: ${name}`,
277
+ name: label(name),
188
278
  status: 'WARN',
189
279
  detail: `Outdated — run xp-gate update-skill --all (installed: ${userVersion}, bundled: ${bundledVersion})`,
190
280
  });
191
281
  issues++;
192
282
  } else {
193
283
  // cmp >= 0: installed version is same or newer than bundled
194
- checks.push({ name: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
284
+ checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
195
285
  }
196
286
  } else {
197
287
  // No version in at least one file — compare content, but not byte-for-byte:
@@ -206,13 +296,13 @@ function diagnoseInstalledSkills(config, checks) {
206
296
  withoutVersionLine(userContent)
207
297
  )) {
208
298
  checks.push({
209
- name: `Skill: ${name}`,
299
+ name: label(name),
210
300
  status: 'WARN',
211
301
  detail: 'Content differs and version cannot be determined (missing version frontmatter) — compare manually before running update-skill (it pulls from main, which may be older than the installed copy)',
212
302
  });
213
303
  issues++;
214
304
  } else {
215
- checks.push({ name: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
305
+ checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
216
306
  }
217
307
  }
218
308
  }
@@ -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-26T05:44:50.695Z",
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:** 7a6d9971
3
+ **Generated:** 2026-10-28
4
+ **Commit:** 0f58c300
5
5
  **Branch:** main
6
- **Version:** 0.31.3.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,