@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
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const os = require('node:os');
6
7
 
7
8
  const REQUIRED_ROLES = ['architecture', 'technical', 'feasibility'];
8
9
  const VALIDITY_MS = 60 * 60 * 1000;
@@ -45,25 +46,31 @@ function parseTimestamp(value, field) {
45
46
  : null;
46
47
  if (!match) fail(`${field} must be a canonical UTC ISO-8601 timestamp.`);
47
48
 
48
- const [, yearText, monthText, dayText, hourText, minuteText, secondText, fractionText] = match;
49
- const year = Number(yearText);
50
- const month = Number(monthText);
51
- const day = Number(dayText);
52
- const hour = Number(hourText);
53
- const minute = Number(minuteText);
54
- const second = Number(secondText);
55
- const milliseconds = Number((fractionText || '').padEnd(3, '0') || 0);
49
+ // Groups 1..6 are year, month, day, hour, minute, second; group 7 is the
50
+ // optional millisecond fraction. Keeping them as arrays means the "build the
51
+ // date" and "verify round-trip" steps share one ordered definition instead of
52
+ // repeating seven hand-numbered comparisons.
53
+ const parts = match.slice(1, 7).map(Number);
54
+ const [year, month, day, hour, minute, second] = parts;
55
+ const milliseconds = Number((match[7] || '').padEnd(3, '0') || 0);
56
56
  const date = new Date(0);
57
57
  date.setUTCFullYear(year, month - 1, day);
58
58
  date.setUTCHours(hour, minute, second, milliseconds);
59
59
 
60
- if (date.getUTCFullYear() !== year
61
- || date.getUTCMonth() !== month - 1
62
- || date.getUTCDate() !== day
63
- || date.getUTCHours() !== hour
64
- || date.getUTCMinutes() !== minute
65
- || date.getUTCSeconds() !== second
66
- || date.getUTCMilliseconds() !== milliseconds) {
60
+ // A date silently rolls over ("2026-02-30" becomes March 2), so the only way
61
+ // to know the input was a real calendar instant is to read it back and compare
62
+ // every field.
63
+ const expected = [...parts, milliseconds];
64
+ const readBack = [
65
+ date.getUTCFullYear(),
66
+ date.getUTCMonth() + 1,
67
+ date.getUTCDate(),
68
+ date.getUTCHours(),
69
+ date.getUTCMinutes(),
70
+ date.getUTCSeconds(),
71
+ date.getUTCMilliseconds(),
72
+ ];
73
+ if (!readBack.every((actual, i) => actual === expected[i])) {
67
74
  fail(`${field} must contain a valid UTC calendar date and time.`);
68
75
  }
69
76
  return date.getTime();
@@ -142,6 +149,19 @@ function resolveBindingSeats(config) {
142
149
  return seatsFromExperts(profiles[active].experts);
143
150
  }
144
151
 
152
+ /**
153
+ * Machine-wide Delphi config path: `$XDG_CONFIG_HOME/xp-gate/` or
154
+ * `~/.config/xp-gate/`. Mirrors `defaultGlobalConfigPath()` in
155
+ * `plugins/dsh/src/delphi-config.ts` so the gate and the tool that produces the
156
+ * evidence agree on where seats come from. XDG_CONFIG_HOME is honoured because
157
+ * the plugin honours it — a gate reading a different file than the reviewer
158
+ * used would bind against seats nobody actually ran.
159
+ */
160
+ function globalConfigPath(env = process.env) {
161
+ const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
162
+ return path.join(base, 'xp-gate', DELPHI_CONFIG_FILE);
163
+ }
164
+
145
165
  /**
146
166
  * Read the binding seats for the project rooted at `projectRoot`.
147
167
  *
@@ -152,23 +172,88 @@ function resolveBindingSeats(config) {
152
172
  * fixture to xp-gate's seats. The gate's subject is the repository being
153
173
  * pushed, so that is what must be named.
154
174
  *
175
+ * ## Global fallback (2026-10-27)
176
+ *
177
+ * A project file still WINS, so a repo can override seats deliberately. When
178
+ * the project has none, the machine-wide config is used instead of returning
179
+ * null — and this is load-bearing, not a convenience:
180
+ *
181
+ * Before this fallback existed, DELETING a project-level config silently
182
+ * disabled the entire #574 seat check, because `bindingSeats === null`
183
+ * short-circuits `validateRequestedModel`. The gate kept reporting PASS while
184
+ * any three distinct gateway models — including the expensive overseas ones
185
+ * #574 exists to catch — could satisfy it, with no message anywhere. A user
186
+ * consolidating onto one global config to stop maintaining a copy per repo
187
+ * would have unknowingly traded that protection away.
188
+ *
189
+ * Only ENOENT triggers the fallback: an unreadable project config
190
+ * (EACCES/EISDIR) must surface rather than silently routing this run to a
191
+ * different file. Same semantics as `loadDelphiConfig()` in the DSH plugin.
192
+ *
193
+ * ## No config anywhere is now FAIL-CLOSED (2026-10-27)
194
+ *
195
+ * Returning null when both locations are absent re-opened the exact hole #574
196
+ * closed: measured, `b-claude-4.8-opus` / `gpt-5.5` / `gemini-3-pro` pass the
197
+ * validator when no config exists. That was tolerable while a config shipped
198
+ * inside every repo (deleting one looked like opting out); once the machine-wide
199
+ * file is the single source of truth, "no config" becomes the normal state of a
200
+ * fresh machine or a bare CI runner — i.e. the bypass is the default path.
201
+ *
202
+ * The caller distinguishes two cases it already knows:
203
+ * - no project context at all (no root argument) -> unbound, #423 behaviour;
204
+ * - a real repo with evidence but no config anywhere -> refuse.
205
+ * This function therefore throws rather than returning null for the second
206
+ * case; `main()` only reaches it when a project root was named.
207
+ *
155
208
  * @param {string} projectRoot repository root whose config binds the evidence
156
- * @returns {Map<string, string> | null} seats, or null when no config exists
209
+ * @param {NodeJS.ProcessEnv} env environment for the global-path lookup
210
+ * @returns {Map<string, string>} seats; never null
211
+ * @throws exits via fail() when neither location has a config
157
212
  */
158
- function loadBindingSeats(projectRoot) {
159
- const configPath = path.join(projectRoot, DELPHI_CONFIG_FILE);
213
+ function loadBindingSeats(projectRoot, env = process.env) {
214
+ const projectPath = path.join(projectRoot, DELPHI_CONFIG_FILE);
215
+ const fallbackPath = globalConfigPath(env);
216
+
160
217
  let raw;
218
+ let source = projectPath;
219
+ let projectMissing = false;
161
220
  try {
162
- raw = fs.readFileSync(configPath, 'utf8');
163
- } catch (error) {
164
- if (error && error.code === 'ENOENT') return null;
165
- fail(`cannot read ${DELPHI_CONFIG_FILE}: ${error && error.message ? error.message : String(error)}`);
221
+ raw = fs.readFileSync(projectPath, 'utf8');
222
+ } catch (projectError) {
223
+ if (projectError && projectError.code === 'ENOENT') {
224
+ projectMissing = true;
225
+ try {
226
+ raw = fs.readFileSync(fallbackPath, 'utf8');
227
+ source = fallbackPath;
228
+ } catch (globalError) {
229
+ if (globalError && globalError.code === 'ENOENT') {
230
+ // Both absent: this repo has walkthrough evidence but nothing binds
231
+ // it to seats. Fail closed and say exactly where we looked.
232
+ fail(
233
+ `no ${DELPHI_CONFIG_FILE} found, so the reviewer seats cannot be verified. `
234
+ + `Looked in: ${projectPath}, ${fallbackPath}. `
235
+ + `Create the machine-wide config or add one to the repository.`
236
+ );
237
+ }
238
+ fail(
239
+ `cannot read ${DELPHI_CONFIG_FILE}: ${globalError && globalError.message
240
+ ? globalError.message
241
+ : String(globalError)}`
242
+ );
243
+ }
244
+ } else {
245
+ fail(
246
+ `cannot read ${DELPHI_CONFIG_FILE}: ${projectError && projectError.message
247
+ ? projectError.message
248
+ : String(projectError)}`
249
+ );
250
+ }
166
251
  }
167
252
  let parsed;
168
253
  try {
169
254
  parsed = JSON.parse(raw);
170
255
  } catch (error) {
171
- fail(`${DELPHI_CONFIG_FILE} is not valid JSON: ${error.message}`);
256
+ fail(`${source} is not valid JSON: ${error.message}`);
172
257
  }
173
258
  const seats = resolveBindingSeats(parsed);
174
259
  for (const role of REQUIRED_ROLES) {
@@ -176,9 +261,60 @@ function loadBindingSeats(projectRoot) {
176
261
  fail(`${DELPHI_CONFIG_FILE} declares no seat for the ${role} role; all three roles must be configured.`);
177
262
  }
178
263
  }
264
+ // A repo-local copy that disagrees with the machine-wide one is exactly how
265
+ // the 2026-10-27 incident happened: a stale per-repo file silently won and the
266
+ // review kept using a seat that had been retired. Winning is still allowed
267
+ // (deliberate overrides exist), but it must never be silent.
268
+ if (!projectMissing) {
269
+ warnOnSeatDrift(projectPath, fallbackPath, seats);
270
+ }
179
271
  return seats;
180
272
  }
181
273
 
274
+ /**
275
+ * Warn (never block) when a repository-local config disagrees with the
276
+ * machine-wide one.
277
+ *
278
+ * 2026-10-27 incident: the machine-wide config had been updated to replace a
279
+ * flaky seat, but an older copy survived in the repo root. The project file
280
+ * won, the review kept calling the retired seat, and nothing anywhere said so —
281
+ * the only symptom was intermittent HTTP 500s that looked like a gateway
282
+ * problem. Silence is what made it expensive.
283
+ *
284
+ * Deliberate per-repo overrides are legitimate, so this is a WARN on stderr and
285
+ * never changes the exit code. Absence of either file is not drift.
286
+ *
287
+ * @param {string} projectPath repository config path
288
+ * @param {string} fallbackPath machine-wide config path
289
+ * @param {Map<string, string>} projectSeats seats actually in effect
290
+ */
291
+ function warnOnSeatDrift(projectPath, fallbackPath, projectSeats) {
292
+ let machineRaw;
293
+ try {
294
+ machineRaw = fs.readFileSync(fallbackPath, 'utf8');
295
+ } catch {
296
+ return; // no machine-wide config -> nothing to disagree with
297
+ }
298
+ let machineSeats;
299
+ try {
300
+ machineSeats = resolveBindingSeats(JSON.parse(machineRaw));
301
+ } catch {
302
+ return; // a malformed machine config is reported by its own path when used
303
+ }
304
+ const drift = REQUIRED_ROLES.filter(
305
+ (role) => machineSeats.has(role) && projectSeats.get(role) !== machineSeats.get(role)
306
+ );
307
+ if (drift.length === 0) return;
308
+ const detail = drift
309
+ .map((role) => `${role}: repo=${projectSeats.get(role)} machine=${machineSeats.get(role)}`)
310
+ .join('; ');
311
+ process.stderr.write(
312
+ `⚠️ ${DELPHI_CONFIG_FILE} in this repository overrides the machine-wide seats `
313
+ + `(${detail}). The repository copy wins. If the machine-wide config was updated `
314
+ + `to retire a seat, this repo is still calling the old one.\n`
315
+ );
316
+ }
317
+
182
318
  /**
183
319
  * Validate an expert's identity fields: shape, role, approval status, and
184
320
  * channel provenance. Split out of `validateExpert`, which exceeded the
@@ -284,10 +420,24 @@ function validateExpert(expert, index, roles, models, resolvedModels, bindingSea
284
420
 
285
421
  function validateEvidence(evidence, expectedCommit, expectedBranch, now, bindingSeats) {
286
422
  if (!isPlainObject(evidence)) fail('top level must be a plain object.');
423
+ validateEvidenceIdentity(evidence, expectedCommit, expectedBranch);
424
+ validateEvidenceTimestamps(evidence, now);
425
+ validateConsensusRatio(evidence.consensus_ratio);
426
+ validateExperts(evidence.experts, bindingSeats);
427
+ }
428
+
429
+ /** The document must describe this exact push, and must approve it. */
430
+ function validateEvidenceIdentity(evidence, expectedCommit, expectedBranch) {
287
431
  if (evidence.commit !== expectedCommit) fail('commit does not match HEAD.');
288
432
  if (evidence.branch !== expectedBranch) fail('branch does not match the current branch.');
289
433
  if (evidence.verdict !== 'APPROVED') fail('top-level verdict must be APPROVED.');
434
+ }
290
435
 
436
+ /**
437
+ * The evidence must be recent and self-consistent: not from the future, not
438
+ * expired, and valid for exactly one hour from its own timestamp.
439
+ */
440
+ function validateEvidenceTimestamps(evidence, now) {
291
441
  const timestamp = parseTimestamp(evidence.timestamp, 'timestamp');
292
442
  const expires = parseTimestamp(evidence.expires, 'expires');
293
443
  if (timestamp > now) fail('timestamp is in the future.');
@@ -295,15 +445,22 @@ function validateEvidence(evidence, expectedCommit, expectedBranch, now, binding
295
445
  if (expires - timestamp !== VALIDITY_MS) {
296
446
  fail('expires must be exactly one hour after timestamp.');
297
447
  }
448
+ }
298
449
 
299
- if (typeof evidence.consensus_ratio !== 'number'
300
- || !Number.isFinite(evidence.consensus_ratio)
301
- || evidence.consensus_ratio < 0.90
302
- || evidence.consensus_ratio > 1) {
450
+ /** The three experts must have agreed at 90% or better. */
451
+ function validateConsensusRatio(ratio) {
452
+ if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio < 0.90 || ratio > 1) {
303
453
  fail('consensus_ratio must be a finite number from 0.90 through 1.');
304
454
  }
455
+ }
305
456
 
306
- if (!Array.isArray(evidence.experts) || evidence.experts.length !== 3) {
457
+ /**
458
+ * Validate each of the three expert records, then confirm every required role
459
+ * appeared. `roles` accumulates across the records, so it is checked once after
460
+ * the loop rather than per expert.
461
+ */
462
+ function validateExperts(experts, bindingSeats) {
463
+ if (!Array.isArray(experts) || experts.length !== 3) {
307
464
  fail('experts must contain exactly three records.');
308
465
  }
309
466
  const roles = new Set();
@@ -311,7 +468,7 @@ function validateEvidence(evidence, expectedCommit, expectedBranch, now, binding
311
468
  // Scoped to this validation run, so one document's models cannot leak into the
312
469
  // next (a long-lived process validating several pushes).
313
470
  const resolvedModels = new Set();
314
- evidence.experts.forEach((expert, index) => {
471
+ experts.forEach((expert, index) => {
315
472
  validateExpert(expert, index, roles, models, resolvedModels, bindingSeats);
316
473
  });
317
474
  if (roles.size !== REQUIRED_ROLES.length) fail('all required expert roles must be present.');
package/hooks/pre-commit CHANGED
@@ -1506,9 +1506,32 @@ else
1506
1506
  fi
1507
1507
 
1508
1508
  # Debug statement detection (debugger statement in JS/TS)
1509
- TS_DEBUG_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx)$' || true)
1509
+ #
1510
+ # Dependencies and build output are filtered out first (#648). The list
1511
+ # comes from `git diff --cached`, so a project (or a CI fixture) that
1512
+ # vendors or commits its dependencies gets the dependency's OWN source
1513
+ # scanned as if it were project code. TypeScript 7.x ships a bare
1514
+ # `debugger;` in dist/internal/utils.js, which blocked commits that changed
1515
+ # nothing of their own:
1516
+ # ❌ BLOCKED - 'debugger' statements detected:
1517
+ # node_modules/typescript/dist/internal/utils.js:18: debugger;
1518
+ # Pinning the dependency's version only postpones that: any package may
1519
+ # carry a debugger statement, and which one lands changes with every
1520
+ # install. The rule the gate actually wants is "no debugger in OUR code",
1521
+ # so the ignore set Gate 2 already uses decides what counts as ours — one
1522
+ # notion of build output across the gates, overridable through the same
1523
+ # XP_GATE_JSCPD_DEFAULT_IGNORE switch.
1524
+ TS_DEBUG_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx)$' | {
1525
+ while IFS= read -r _df; do
1526
+ [ -n "$_df" ] || continue
1527
+ if ! jscpd_is_default_ignored "$_df"; then
1528
+ printf '%s\n' "$_df"
1529
+ fi
1530
+ done
1531
+ } || true)
1510
1532
  if [ -n "$TS_DEBUG_FILES" ]; then
1511
- DEBUGGER_HITS=$(grep -n '^\s*debugger\s*;\?\s*$' $TS_DEBUG_FILES 2>/dev/null || true)
1533
+ # NUL-separated so paths with spaces survive; a bare $VAR would word-split.
1534
+ DEBUGGER_HITS=$(printf '%s\n' "$TS_DEBUG_FILES" | tr '\n' '\0' | xargs -0 grep -n '^\s*debugger\s*;\?\s*$' 2>/dev/null || true)
1512
1535
  if [ -n "$DEBUGGER_HITS" ]; then
1513
1536
  echo ""
1514
1537
  echo "❌ BLOCKED - 'debugger' statements detected:"
@@ -2885,9 +2908,33 @@ else
2885
2908
  echo "ℹ️ Shell coverage not typically measured"
2886
2909
  ;;
2887
2910
  *)
2911
+ # #640: hand the staged set to the adapter. Java's run_coverage uses it
2912
+ # for three-tier smart selection (changed-tests -> related -> full);
2913
+ # without it the adapter deliberately runs the full suite rather than
2914
+ # guessing, so this export failing closed is safe.
2915
+ # Colon-separated: newlines do not survive every adapter's word
2916
+ # splitting, and paths with spaces stay intact inside the value.
2917
+ GATE_CHANGED_FILES=$(printf '%s' "$CHANGED_FILES" | tr '\n' ':')
2918
+ export GATE_CHANGED_FILES
2919
+ # The adapter runs inside $(...), so it cannot set PARTIAL_TEST_RUN
2920
+ # for us. It writes the chosen strategy to this file and we raise the
2921
+ # flag here, in the shell that later reads it. Without this a Java
2922
+ # subset run under 80% coverage would take the hard-BLOCK branch
2923
+ # instead of the WARN branch, exactly the bug #556 fixed for Python.
2924
+ _java_partial_file=$(mktemp 2>/dev/null || echo "")
2925
+ if [ -n "$_java_partial_file" ]; then
2926
+ export XP_GATE_PARTIAL_RUN_FILE="$_java_partial_file"
2927
+ fi
2888
2928
  default_cov_output=$(run_coverage 2>&1)
2889
2929
  COV_EXIT=$((COV_EXIT || $?))
2890
2930
  echo "$default_cov_output" | head -30
2931
+ unset XP_GATE_PARTIAL_RUN_FILE GATE_CHANGED_FILES
2932
+ if [ -n "$_java_partial_file" ]; then
2933
+ if [ -s "$_java_partial_file" ]; then
2934
+ PARTIAL_TEST_RUN=true
2935
+ fi
2936
+ rm -f "$_java_partial_file" 2>/dev/null || true
2937
+ fi
2891
2938
  ;;
2892
2939
  esac
2893
2940
  else
@@ -4129,6 +4176,90 @@ else
4129
4176
  fi
4130
4177
  # ============================================================================
4131
4178
 
4179
+ # ============================================================================
4180
+ # GATE 13: Compiler Strictness Check
4181
+ # The compiler is the first and cheapest static analyser for a compiled language
4182
+ # and XP-Gate never used it as one: cpp.sh only runs clang-tidy/cppcheck over the
4183
+ # first 20 files, and Gate 9's build-integrity half is TypeScript-and-Maven only.
4184
+ # A measured `g++ -Wall -Wextra` pass reports -Wnon-virtual-dtor, -Wreorder,
4185
+ # -Wparentheses and -Wclass-memaccess on an innocuous-looking file; bare `javac`
4186
+ # reports NOTHING for a raw type where `-Xlint:all` reports [rawtypes].
4187
+ #
4188
+ # Verdicts: 0 PASS, 1 FAIL (new findings -> blocked), 2 BLOCK (ran but output
4189
+ # untrustworthy), 3 WARN (tool absent / cross-compiling / timeout / warn-only).
4190
+ # A missing compiler is a SKIP-class WARN per the project convention, NOT a block.
4191
+ #
4192
+ # Escape hatches: XP_GATE_DISABLE_13=1 (kill switch),
4193
+ # XP_GATE_COMPILER_STRICTNESS=strict|balanced|warn-only,
4194
+ # XP_GATE_COMPILER_ANALYZER=1 (gcc -fanalyzer, C only, opt-in).
4195
+ # Design: docs/plans/2026-10-26-gate-13-compiler-strictness-design.md
4196
+ #
4197
+ # NOTE: this block is a TAIL APPEND after Gate 12 and intentionally touches no
4198
+ # line of the gates above it.
4199
+ # ============================================================================
4200
+ GATE_13_STATUS="SKIP"
4201
+ if [ "${XP_GATE_DISABLE_13:-}" = "1" ]; then
4202
+ echo "⏭️ SKIPPED - Gate 13: Compiler Strictness (XP_GATE_DISABLE_13=1)"
4203
+ GATE_13_STATUS="SKIP"
4204
+ elif [ -f "$GATE_DIR/gate-13-compiler-strictness.sh" ] || [ -f "$(git rev-parse --show-toplevel 2>/dev/null)/githooks/gate-13-compiler-strictness.sh" ]; then
4205
+ echo "⚙️ Gate 13: Compiler Strictness Check..."
4206
+ GATE13_START=$(gate_start_ms)
4207
+ (
4208
+ # Each stage runs inside the subshell so its shell state cannot leak back
4209
+ # into pre-commit. Verdicts are reduced to the WORST code seen:
4210
+ # FAIL/BLOCK (1/2) always win; WARN (3) only beats PASS (0).
4211
+ _g13_script="$GATE_DIR/gate-13-compiler-strictness.sh"
4212
+ [ -f "$_g13_script" ] || _g13_script="$(git rev-parse --show-toplevel)/githooks/gate-13-compiler-strictness.sh"
4213
+ # shellcheck source=/dev/null
4214
+ . "$_g13_script"
4215
+
4216
+ _g13_worst=0
4217
+ _g13_rc=0
4218
+ _g13_note() {
4219
+ case "$1" in
4220
+ 1|2) _g13_worst=$1 ;;
4221
+ 3) [ "$_g13_worst" -eq 0 ] && _g13_worst=3 ;;
4222
+ esac
4223
+ return 0
4224
+ }
4225
+
4226
+ gate13_run_cpp_analysis; _g13_rc=$?; _g13_note "$_g13_rc"
4227
+ gate13_run_java_analysis; _g13_rc=$?; _g13_note "$_g13_rc"
4228
+ gate13_run_analyzer; _g13_rc=$?; _g13_note "$_g13_rc"
4229
+
4230
+ exit "$_g13_worst"
4231
+ )
4232
+ GATE_13_RC=$?
4233
+ GATE13_END=$(now_ms)
4234
+ GATE_13_DURATION=$(( (GATE13_END - GATE13_START) / 1000 ))
4235
+ case "$GATE_13_RC" in
4236
+ 0) GATE_13_STATUS="PASS" ;;
4237
+ 1) GATE_13_STATUS="FAIL" ;;
4238
+ 2) GATE_13_STATUS="BLOCK" ;;
4239
+ *) GATE_13_STATUS="WARN" ;;
4240
+ esac
4241
+ echo " Gate 13 ($GATE_13_STATUS) completed in ${GATE_13_DURATION}s"
4242
+ record_gate_audit "gate-13" "compiler-strictness" "$GATE_13_STATUS" "0" "$GATE13_START"
4243
+ # Every other gate blocks by exiting immediately (e.g. Gate 1's
4244
+ # "❌ BLOCKED ... exit 1"); there is no aggregate-then-exit shared with
4245
+ # gates 0-12, so Gate 13 must do the same or its FAIL would only be cosmetic
4246
+ # in the report while the commit sails through.
4247
+ if [ "$GATE_13_STATUS" = "FAIL" ] || [ "$GATE_13_STATUS" = "BLOCK" ]; then
4248
+ echo ""
4249
+ echo "❌ BLOCKED - Gate 13: Compiler Strictness (${GATE_13_STATUS})"
4250
+ echo " Fix the compiler findings above. To record pre-existing warnings:"
4251
+ echo " xp-gate compiler-baseline create"
4252
+ echo " To relax strictness: XP_GATE_COMPILER_STRICTNESS=warn-only"
4253
+ echo " Emergency only: XP_GATE_DISABLE_13=1"
4254
+ exit 1
4255
+ fi
4256
+ else
4257
+ echo "⏭️ SKIPPED - Gate 13: Compiler Strictness (gate-13-compiler-strictness.sh not found)"
4258
+ GATE_13_STATUS="SKIP"
4259
+ fi
4260
+ echo ""
4261
+ # ============================================================================
4262
+
4132
4263
  generate_quality_report() {
4133
4264
  local HISTORY_FILE=".quality-history.jsonl"
4134
4265
  local COMMIT_HASH
@@ -4425,6 +4556,22 @@ generate_quality_report() {
4425
4556
  }
4426
4557
  ENDJSON
4427
4558
 
4559
+ # ── Gate 13 injection (tail append; no existing line above is modified) ────
4560
+ # The heredoc is closed deliberately before Gate 13 so this sprint ADDS lines
4561
+ # instead of editing gates 0-12's report block, keeping the two branches'
4562
+ # hunks disjoint. Scores are recomputed over 13 gates afterwards.
4563
+ if [ -n "${GATE_13_STATUS:-}" ] && [ -f "$REPORT_FILE" ]; then
4564
+ _XP_G13="$REPORT_FILE" _XP_G13S="$GATE_13_STATUS" node -e "
4565
+ const fs=require('fs'),p=process.env._XP_G13;
4566
+ try{
4567
+ const j=JSON.parse(fs.readFileSync(p,'utf8'));
4568
+ j.gates=j.gates||{};
4569
+ j.gates.gate13_compiler_strictness={name:'Compiler Strictness',status:process.env._XP_G13S,tool:'gate-13-compiler-strictness.sh'};
4570
+ fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n');
4571
+ }catch(e){}
4572
+ " 2>/dev/null || true
4573
+ fi
4574
+
4428
4575
  # ── Persistent per-run report (Issue #101) ────────────────────────────────────────
4429
4576
  local REPORTS_DIR=".xp-gate/reports/pre-commit"
4430
4577
  local REPORT_TS
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @test REQ-635
3
+ * @intent `xp-gate init` 会向 ~/.dsh/skills 部署技能、npm 包也带 plugins/dsh,
4
+ * 但 PLATFORM_PROFILES 里没有 dsh 条目,getSkillsDirs('dsh') 静默回落到
5
+ * opencode 的路径,导致 DSH 机器的技能从不被解析(#635)
6
+ * @covers AC-635-04, AC-635-05
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ describe('PLATFORM_PROFILES covers dsh (#635)', () => {
13
+ let tmpHome;
14
+ let originalHome;
15
+
16
+ beforeEach(() => {
17
+ originalHome = process.env.HOME;
18
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dsh-'));
19
+ process.env.HOME = tmpHome;
20
+ vi.resetModules();
21
+ delete require.cache[require.resolve('../detect-deps')];
22
+ delete require.cache[require.resolve('../shared-paths')];
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.env.HOME = originalHome;
27
+ fs.rmSync(tmpHome, { recursive: true, force: true });
28
+ });
29
+
30
+ // AC-635-04: asking for dsh skills must return the dsh directory, not a
31
+ // silent fallback to opencode's.
32
+ it('getSkillsDirs("dsh") resolves ~/.dsh, not the opencode fallback', () => {
33
+ const { getSkillsDirs } = require('../detect-deps');
34
+ const dirs = getSkillsDirs('dsh');
35
+
36
+ expect(dirs.length).toBeGreaterThan(0);
37
+ expect(dirs[0]).toBe(path.join(tmpHome, '.dsh', 'skills'));
38
+ // The pre-fix behaviour: falls through to PLATFORM_PROFILES.opencode.
39
+ expect(dirs[0]).not.toContain(path.join('.config', 'opencode'));
40
+ });
41
+
42
+ // AC-635-05: the profile is a real entry, so a caller enumerating platforms
43
+ // sees dsh alongside the three that were already there.
44
+ it('dsh is an enumerable platform profile with skillsDirs and requiredDeps', () => {
45
+ const { PLATFORM_PROFILES, getSkillsDirs } = require('../detect-deps');
46
+
47
+ expect(Object.keys(PLATFORM_PROFILES)).toContain('dsh');
48
+ expect(Object.keys(PLATFORM_PROFILES)).toEqual(
49
+ expect.arrayContaining(['opencode', 'claude-code', 'qoder', 'dsh'])
50
+ );
51
+
52
+ const profile = PLATFORM_PROFILES.dsh;
53
+ expect(Array.isArray(profile.skillsDirs)).toBe(true);
54
+ expect(profile.requiredDeps).toBeDefined();
55
+ // Profiles store home-relative segments; getSkillsDirs resolves them.
56
+ expect(profile.skillsDirs[0]).toEqual(['.dsh', 'skills']);
57
+ expect(getSkillsDirs('dsh')[0]).toBe(path.join(tmpHome, '.dsh', 'skills'));
58
+ });
59
+
60
+ // Every profile must be plain data — no getters mixed in with values, which
61
+ // would break Object.keys()/JSON round-trips and made the object's contract
62
+ // depend on which key you touched (Delphi R1 CI-01).
63
+ it('every profile is plain enumerable data, with no accessor properties', () => {
64
+ const { PLATFORM_PROFILES } = require('../detect-deps');
65
+
66
+ for (const name of Object.keys(PLATFORM_PROFILES)) {
67
+ const descriptor = Object.getOwnPropertyDescriptor(PLATFORM_PROFILES, name);
68
+ expect(descriptor.get, `${name} must not be an accessor`).toBeUndefined();
69
+ // A plain value also survives a JSON round-trip unchanged.
70
+ expect(JSON.parse(JSON.stringify(PLATFORM_PROFILES[name]))).toEqual(
71
+ PLATFORM_PROFILES[name]
72
+ );
73
+ }
74
+ });
75
+
76
+ // Paths must follow process.env.HOME rather than pinning the home directory
77
+ // at require() time — the trap doctor.js documents at its top.
78
+ it('resolved paths follow a HOME change after require', () => {
79
+ const { getSkillsDirs } = require('../detect-deps');
80
+ // Loaded above with the first tmpHome; move HOME and resolve again.
81
+ const secondHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dsh2-'));
82
+ try {
83
+ process.env.HOME = secondHome;
84
+ expect(getSkillsDirs('dsh')[0]).toBe(path.join(secondHome, '.dsh', 'skills'));
85
+ // The pre-existing profiles must follow too — they used to be frozen at
86
+ // require() time from a destructured HOME_DIR.
87
+ expect(getSkillsDirs('opencode')[0]).toBe(
88
+ path.join(secondHome, '.config', 'opencode', 'skills')
89
+ );
90
+ } finally {
91
+ fs.rmSync(secondHome, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ // Anti-vacuity: the three pre-existing profiles must keep their own paths,
96
+ // proving the getter did not disturb them.
97
+ it('leaves the pre-existing profiles unchanged', () => {
98
+ const { getSkillsDirs } = require('../detect-deps');
99
+
100
+ expect(getSkillsDirs('opencode')[0]).toBe(
101
+ path.join(tmpHome, '.config', 'opencode', 'skills')
102
+ );
103
+ expect(getSkillsDirs('qoder')[0]).toBe(path.join(tmpHome, '.qoder', 'skills'));
104
+ expect(getSkillsDirs('claude-code')[0]).toBe(path.join(tmpHome, '.claude', 'skills'));
105
+ });
106
+ });