@boyingliu01/xp-gate 0.31.1 → 0.31.3

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 (86) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +37 -1
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +254 -10
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +254 -10
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
  14. package/hooks/lib/coverage-total.sh +20 -5
  15. package/hooks/lib/gate-5c-alignment.sh +145 -0
  16. package/hooks/lib/jscpd-run.sh +40 -1
  17. package/hooks/lib/native-path.sh +106 -0
  18. package/hooks/lib/prepush-score.py +143 -0
  19. package/hooks/lib/sprint-gate-report.sh +23 -0
  20. package/hooks/lib/toolchain-path.sh +370 -0
  21. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  22. package/hooks/pre-commit +403 -165
  23. package/hooks/pre-push +192 -74
  24. package/hooks/sprint-gate.sh +14 -3
  25. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  26. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  27. package/lib/__tests__/doctor-delphi.test.js +46 -0
  28. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  29. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  30. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  31. package/lib/__tests__/doctor.test.js +7 -1
  32. package/lib/__tests__/hook-health-path.test.js +9 -1
  33. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  34. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  35. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  36. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  37. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  38. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  39. package/lib/arch.js +261 -44
  40. package/lib/check-alignment-runner.cjs +23 -0
  41. package/lib/check.js +4 -1
  42. package/lib/doctor.js +194 -49
  43. package/lib/gate-runner.js +265 -58
  44. package/lib/mattpocock-license-verdict.json +1 -1
  45. package/lib/principles.js +228 -6
  46. package/lib/shared-paths.js +53 -19
  47. package/mock-policy/AGENTS.md +3 -3
  48. package/mutation/AGENTS.md +3 -3
  49. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  50. package/package.json +1 -1
  51. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  52. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  53. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  54. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  55. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  56. package/plugins/dsh/package.json +1 -1
  57. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  59. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  60. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  61. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  62. package/plugins/dsh/src/delphi-config.ts +151 -9
  63. package/plugins/opencode/package.json +1 -1
  64. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  65. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  70. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  71. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  73. package/principles/AGENTS.md +3 -3
  74. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  75. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  76. package/principles/adapters/java.ts +127 -11
  77. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  78. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  79. package/principles/rules/solid/lsp.ts +348 -23
  80. package/scripts/__tests__/sync-package-content.test.js +32 -1
  81. package/scripts/sync-package-content.js +6 -1
  82. package/skills/delphi-review/AGENTS.md +3 -3
  83. package/skills/delphi-review/SKILL.md +50 -0
  84. package/skills/sprint-flow/AGENTS.md +3 -3
  85. package/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/sprint-gate.sh +14 -3
@@ -2,10 +2,21 @@
2
2
  'use strict';
3
3
 
4
4
  const fs = require('node:fs');
5
+ const path = require('node:path');
5
6
 
6
7
  const REQUIRED_ROLES = ['architecture', 'technical', 'feasibility'];
7
8
  const VALIDITY_MS = 60 * 60 * 1000;
8
9
 
10
+ /**
11
+ * Project-level Delphi seat configuration.
12
+ *
13
+ * Present => evidence is BOUND to it (#574): each expert's `requested_model`
14
+ * must be exactly the seat the config assigns to that expert's role.
15
+ * Absent => evidence stays unbound (the #423 behaviour), so projects that
16
+ * review through another platform's channel are unaffected.
17
+ */
18
+ const DELPHI_CONFIG_FILE = '.delphi-config.json';
19
+
9
20
  /**
10
21
  * Allowed provenance values for an expert record.
11
22
  *
@@ -58,7 +69,126 @@ function parseTimestamp(value, field) {
58
69
  return date.getTime();
59
70
  }
60
71
 
61
- function validateExpert(expert, index, roles, models, resolvedModels) {
72
+ /**
73
+ * Record one seat into `seats` if the entry carries a usable model id.
74
+ * Shared by both `experts` shapes so the acceptance rule lives in exactly one
75
+ * place (#599: the two branches were byte-for-byte copies, which is what pushed
76
+ * this function over the complexity block threshold).
77
+ *
78
+ * @param {Map<string, string>} seats role -> trimmed model id, mutated
79
+ * @param {string} role configured role name
80
+ * @param {unknown} entry candidate expert record
81
+ */
82
+ function addSeat(seats, role, entry) {
83
+ if (!isPlainObject(entry)) return;
84
+ const model = entry.model ?? entry.requested_model;
85
+ if (typeof model === 'string' && model.trim() !== '') seats.set(role, model.trim());
86
+ }
87
+
88
+ /**
89
+ * Normalise the two `experts` shapes `.delphi-config.json` appears in — an
90
+ * object keyed by role (what xp-gate writes) and an array carrying `role` —
91
+ * into a plain role -> model map. Mirrors `normalizeExperts()` in
92
+ * `plugins/dsh/src/delphi-config.ts` and `iter_seats()` in
93
+ * `scripts/check_delphi_local_models.py`: one config shape, one reading.
94
+ *
95
+ * @param {unknown} experts raw `experts` value
96
+ * @returns {Map<string, string>} role -> trimmed model id
97
+ */
98
+ function seatsFromExperts(experts) {
99
+ const seats = new Map();
100
+ if (Array.isArray(experts)) {
101
+ for (const entry of experts) {
102
+ if (isPlainObject(entry) && typeof entry.role === 'string') {
103
+ addSeat(seats, entry.role, entry);
104
+ }
105
+ }
106
+ } else if (isPlainObject(experts)) {
107
+ for (const [role, entry] of Object.entries(experts)) {
108
+ addSeat(seats, role, entry);
109
+ }
110
+ }
111
+ return seats;
112
+ }
113
+
114
+ /**
115
+ * Resolve the binding seats from a parsed `.delphi-config.json`.
116
+ *
117
+ * Only the ACTIVE profile binds, exactly like every other Delphi loader. A
118
+ * config that names profiles but leaves no resolvable active one is rejected
119
+ * rather than ignored: silently falling back to "unbound" would turn a broken
120
+ * config into a bypass.
121
+ *
122
+ * @param {unknown} config parsed config document
123
+ * @returns {Map<string, string>} role -> model id that evidence must match
124
+ */
125
+ function resolveBindingSeats(config) {
126
+ if (!isPlainObject(config)) fail(`${DELPHI_CONFIG_FILE} must contain a JSON object.`);
127
+
128
+ const profiles = config.profiles;
129
+ if (!isPlainObject(profiles) || Object.keys(profiles).length === 0) {
130
+ // Legacy/flat shape: experts live at the top level.
131
+ return seatsFromExperts(config.experts);
132
+ }
133
+
134
+ const active = config.active_profile;
135
+ if (typeof active !== 'string' || !isPlainObject(profiles[active])) {
136
+ const available = Object.keys(profiles).join(', ');
137
+ fail(
138
+ `${DELPHI_CONFIG_FILE} has no resolvable active_profile (active_profile=${JSON.stringify(active)}; `
139
+ + `available: ${available}). Set one so the gate knows which seats bind.`
140
+ );
141
+ }
142
+ return seatsFromExperts(profiles[active].experts);
143
+ }
144
+
145
+ /**
146
+ * Read the binding seats for the project rooted at `projectRoot`.
147
+ *
148
+ * The root is passed EXPLICITLY by the caller, never derived from
149
+ * `process.cwd()`. Deriving it from cwd made the binding depend on where the
150
+ * validator was launched from: running the test suite from inside xp-gate
151
+ * itself picked up xp-gate's own `.delphi-config.json` and re-bound every
152
+ * fixture to xp-gate's seats. The gate's subject is the repository being
153
+ * pushed, so that is what must be named.
154
+ *
155
+ * @param {string} projectRoot repository root whose config binds the evidence
156
+ * @returns {Map<string, string> | null} seats, or null when no config exists
157
+ */
158
+ function loadBindingSeats(projectRoot) {
159
+ const configPath = path.join(projectRoot, DELPHI_CONFIG_FILE);
160
+ let raw;
161
+ 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)}`);
166
+ }
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(raw);
170
+ } catch (error) {
171
+ fail(`${DELPHI_CONFIG_FILE} is not valid JSON: ${error.message}`);
172
+ }
173
+ const seats = resolveBindingSeats(parsed);
174
+ for (const role of REQUIRED_ROLES) {
175
+ if (!seats.has(role)) {
176
+ fail(`${DELPHI_CONFIG_FILE} declares no seat for the ${role} role; all three roles must be configured.`);
177
+ }
178
+ }
179
+ return seats;
180
+ }
181
+
182
+ /**
183
+ * Validate an expert's identity fields: shape, role, approval status, and
184
+ * channel provenance. Split out of `validateExpert`, which exceeded the
185
+ * complexity block threshold (#599).
186
+ *
187
+ * @param {object} expert expert record
188
+ * @param {number} index position in the experts array
189
+ * @param {Set<string>} roles roles seen so far; the role is added
190
+ */
191
+ function validateExpertIdentity(expert, index, roles) {
62
192
  if (!isPlainObject(expert)) fail(`experts[${index}] must be a plain object.`);
63
193
  if (!REQUIRED_ROLES.includes(expert.role)) fail(`experts[${index}].role is invalid.`);
64
194
  if (roles.has(expert.role)) fail(`expert role ${expert.role} is duplicated.`);
@@ -80,14 +210,33 @@ function validateExpert(expert, index, roles, models, resolvedModels) {
80
210
  `expert ${expert.role} channel must be one of: ${VALID_CHANNELS.join(', ')}.`
81
211
  );
82
212
  }
213
+ }
83
214
 
84
- // requested_model is compared EXACTLY (after trimming); resolved_model below is
85
- // compared case-INSENSITIVELY. The asymmetry is intentional, not an oversight:
86
- // a requested ID is a case-sensitive gateway identifier and AGENTS.md requires
87
- // "three distinct trimmed requested model IDs", whereas resolved_model answers
88
- // "did these actually run on one model?" -- and gateways do report the same model
89
- // with different casing per call, which is how #423 slipped through. Pinned by
90
- // scripts/__tests__/gate-mw-provenance.test.cjs (AC-423-08).
215
+ /**
216
+ * Validate the requested model: non-empty, distinct across experts, and — when
217
+ * the project binds seats — exactly the model configured for this role.
218
+ *
219
+ * requested_model is compared EXACTLY (after trimming) while resolved_model is
220
+ * compared case-INSENSITIVELY. The asymmetry is intentional, not an oversight: a
221
+ * requested ID is a case-sensitive gateway identifier and AGENTS.md requires
222
+ * "three distinct trimmed requested model IDs", whereas resolved_model answers
223
+ * "did these actually run on one model?" -- and gateways do report the same model
224
+ * with different casing per call, which is how #423 slipped through. Pinned by
225
+ * scripts/__tests__/gate-mw-provenance.test.cjs (AC-423-08).
226
+ *
227
+ * #574: when the project ships a `.delphi-config.json`, the seat this role was
228
+ * configured with is the ONLY model allowed to report for it. Distinctness alone
229
+ * was satisfiable by any three of the gateway's ~138 models -- measured
230
+ * 2026-10-22, a walkthrough ran on s-deepseek-v4.1-flash / g-deepseek-v4-pro /
231
+ * doubao-seed-2.0-pro while the config named three local-* seats, and this
232
+ * validator passed it. Membership is checked per ROLE (not as a set) so a swapped
233
+ * pair is rejected too: the role attribution would otherwise be a lie.
234
+ *
235
+ * @param {object} expert expert record
236
+ * @param {Set<string>} models requested models seen so far; this one is added
237
+ * @param {Map<string, string>|null} bindingSeats configured seats, or null
238
+ */
239
+ function validateRequestedModel(expert, models, bindingSeats) {
91
240
  if (typeof expert.requested_model !== 'string' || expert.requested_model.trim() === '') {
92
241
  fail(`expert ${expert.role} requested_model must be non-empty.`);
93
242
  }
@@ -95,10 +244,28 @@ function validateExpert(expert, index, roles, models, resolvedModels) {
95
244
  if (models.has(model)) fail(`requested_model ${model} is duplicated.`);
96
245
  models.add(model);
97
246
 
98
- // `resolved_model` must be a real, DISTINCT model. Previously null was allowed,
99
- // so an expert that never resolved a model still counted as successful; and
100
- // nothing compared resolved models, so all three could report the same one
101
- // while `requested_model` differed.
247
+ if (bindingSeats !== null) {
248
+ const configured = bindingSeats.get(expert.role);
249
+ if (model !== configured) {
250
+ fail(
251
+ `expert ${expert.role} requested_model ${model} does not match the configured seat `
252
+ + `${configured} from ${DELPHI_CONFIG_FILE}.`
253
+ );
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Validate the resolved model: a non-empty string, distinct ignoring case.
260
+ *
261
+ * Previously null was allowed, so an expert that never resolved a model still
262
+ * counted as successful; and nothing compared resolved models, so all three could
263
+ * report the same one while `requested_model` differed.
264
+ *
265
+ * @param {object} expert expert record
266
+ * @param {Set<string>} resolvedModels resolved models seen so far; added here
267
+ */
268
+ function validateResolvedModel(expert, resolvedModels) {
102
269
  if (typeof expert.resolved_model !== 'string' || expert.resolved_model.trim() === '') {
103
270
  fail(`expert ${expert.role} resolved_model must be a non-empty string.`);
104
271
  }
@@ -109,7 +276,13 @@ function validateExpert(expert, index, roles, models, resolvedModels) {
109
276
  resolvedModels.add(resolved);
110
277
  }
111
278
 
112
- function validateEvidence(evidence, expectedCommit, expectedBranch, now) {
279
+ function validateExpert(expert, index, roles, models, resolvedModels, bindingSeats) {
280
+ validateExpertIdentity(expert, index, roles);
281
+ validateRequestedModel(expert, models, bindingSeats);
282
+ validateResolvedModel(expert, resolvedModels);
283
+ }
284
+
285
+ function validateEvidence(evidence, expectedCommit, expectedBranch, now, bindingSeats) {
113
286
  if (!isPlainObject(evidence)) fail('top level must be a plain object.');
114
287
  if (evidence.commit !== expectedCommit) fail('commit does not match HEAD.');
115
288
  if (evidence.branch !== expectedBranch) fail('branch does not match the current branch.');
@@ -139,25 +312,53 @@ function validateEvidence(evidence, expectedCommit, expectedBranch, now) {
139
312
  // next (a long-lived process validating several pushes).
140
313
  const resolvedModels = new Set();
141
314
  evidence.experts.forEach((expert, index) => {
142
- validateExpert(expert, index, roles, models, resolvedModels);
315
+ validateExpert(expert, index, roles, models, resolvedModels, bindingSeats);
143
316
  });
144
317
  if (roles.size !== REQUIRED_ROLES.length) fail('all required expert roles must be present.');
145
318
  }
146
319
 
320
+ /**
321
+ * Read and parse the evidence document, failing with a readable message rather
322
+ * than letting a raw ENOENT/JSON.parse error escape.
323
+ *
324
+ * @param {string} file path to the evidence JSON
325
+ * @returns {unknown} parsed document
326
+ */
327
+ function readEvidence(file) {
328
+ try {
329
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
330
+ } catch {
331
+ return fail('file must exist and contain valid JSON.');
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Resolve the project root the caller named, if any.
337
+ *
338
+ * #574: the seats that bind are the ones configured in the repository being
339
+ * pushed. The caller names that root; a missing argument means "no project
340
+ * context", which keeps evidence unbound rather than guessing from cwd.
341
+ *
342
+ * @param {string|undefined} projectRoot raw argv value
343
+ * @returns {string|null} absolute root, or null for "no project context"
344
+ */
345
+ function resolveProjectRoot(projectRoot) {
346
+ if (projectRoot === undefined || projectRoot === '') return null;
347
+ return path.resolve(projectRoot);
348
+ }
349
+
147
350
  function main() {
148
- const [file, expectedCommit, expectedBranch, nowValue] = process.argv.slice(2);
351
+ const [file, expectedCommit, expectedBranch, nowValue, projectRoot] = process.argv.slice(2);
149
352
  if (!file || !expectedCommit || !expectedBranch) {
150
- fail('usage: validate-code-walkthrough.cjs <file> <commit> <branch> [now].');
353
+ fail(`usage: validate-code-walkthrough.cjs <file> <commit> <branch> [now] [project-root].`);
151
354
  }
152
355
 
153
- let evidence;
154
- try {
155
- evidence = JSON.parse(fs.readFileSync(file, 'utf8'));
156
- } catch {
157
- fail('file must exist and contain valid JSON.');
158
- }
356
+ const evidence = readEvidence(file);
159
357
  const now = nowValue === undefined ? Date.now() : parseTimestamp(nowValue, 'current time');
160
- validateEvidence(evidence, expectedCommit, expectedBranch, now);
358
+
359
+ const root = resolveProjectRoot(projectRoot);
360
+ const bindingSeats = root === null ? null : loadBindingSeats(root);
361
+ validateEvidence(evidence, expectedCommit, expectedBranch, now, bindingSeats);
161
362
  }
162
363
 
163
364
  main();