@boyingliu01/xp-gate 0.31.0 → 0.31.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +110 -7
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +10 -1
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +10 -1
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/coverage-total.sh +20 -5
  14. package/hooks/lib/gate-5c-alignment.sh +145 -0
  15. package/hooks/lib/jscpd-run.sh +40 -1
  16. package/hooks/lib/native-path.sh +106 -0
  17. package/hooks/lib/prepush-score.py +143 -0
  18. package/hooks/lib/sprint-gate-report.sh +23 -0
  19. package/hooks/lib/toolchain-path.sh +370 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  21. package/hooks/pre-commit +371 -163
  22. package/hooks/pre-push +192 -74
  23. package/hooks/sprint-gate.sh +14 -3
  24. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  25. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  26. package/lib/__tests__/doctor-delphi.test.js +46 -0
  27. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  28. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  30. package/lib/__tests__/doctor.test.js +7 -1
  31. package/lib/__tests__/hook-health-path.test.js +9 -1
  32. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  33. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  34. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  35. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  36. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  37. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  38. package/lib/arch.js +261 -44
  39. package/lib/check-alignment-runner.cjs +23 -0
  40. package/lib/check.js +4 -1
  41. package/lib/doctor.js +194 -49
  42. package/lib/gate-runner.js +265 -58
  43. package/lib/mattpocock-license-verdict.json +1 -1
  44. package/lib/principles.js +228 -6
  45. package/lib/shared-paths.js +53 -19
  46. package/mock-policy/AGENTS.md +3 -3
  47. package/mutation/AGENTS.md +3 -3
  48. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  49. package/package.json +1 -1
  50. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  51. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  53. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/plugins/dsh/package.json +1 -1
  56. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  57. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  58. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  61. package/plugins/dsh/src/delphi-config.ts +151 -9
  62. package/plugins/opencode/package.json +1 -1
  63. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  64. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  65. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  67. package/plugins/qoder/plugin.json +1 -1
  68. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  69. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  72. package/principles/AGENTS.md +3 -3
  73. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  74. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  75. package/principles/adapters/java.ts +127 -11
  76. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  77. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  78. package/principles/rules/solid/lsp.ts +348 -23
  79. package/scripts/__tests__/sync-package-content.test.js +32 -1
  80. package/scripts/sync-package-content.js +6 -1
  81. package/skills/delphi-review/AGENTS.md +3 -3
  82. package/skills/delphi-review/SKILL.md +50 -0
  83. package/skills/sprint-flow/AGENTS.md +3 -3
  84. package/skills/test-specification-alignment/AGENTS.md +3 -3
  85. package/sprint-gate.sh +14 -3
package/lib/arch.js CHANGED
@@ -21,9 +21,51 @@ const BASELINE_FILE = '.architecture-baseline.json';
21
21
  // (`archlint diff .architecture-baseline.json --fail-on high`).
22
22
  const FAIL_ON = 'high';
23
23
  const BLOCKING_SEVERITIES = new Set(['high', 'critical']);
24
- const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
24
+ /**
25
+ * Cap on captured archlint output.
26
+ *
27
+ * Written as a megabyte multiplier so the unit is in the name AND the literal is a
28
+ * named constant — a bare 64 next to 1024 reads as an arbitrary product.
29
+ */
30
+ const MEBIBYTE = 1024 * 1024;
31
+ const MAX_OUTPUT_MIB = 64;
32
+ const MAX_OUTPUT_BYTES = MAX_OUTPUT_MIB * MEBIBYTE;
25
33
  const BLOCKING_PREVIEW_LIMIT = 10;
26
34
 
35
+ /**
36
+ * Source extensions counted when measuring how much of a project a scan covered.
37
+ *
38
+ * Deliberately the same extension set archlint's detectors understand, plus the
39
+ * mainstream statically-typed ones it does not — the point of the census is to notice
40
+ * a project whose bulk is in a language no detector covers (#590's Java case), and
41
+ * that only works if those files are counted.
42
+ */
43
+ const LANGUAGE_BY_EXTENSION = new Map([
44
+ ['.ts', 'typescript'],
45
+ ['.tsx', 'typescript'],
46
+ ['.js', 'javascript'],
47
+ ['.jsx', 'javascript'],
48
+ ['.mjs', 'javascript'],
49
+ ['.cjs', 'javascript'],
50
+ ['.java', 'java'],
51
+ ['.kt', 'kotlin'],
52
+ ['.kts', 'kotlin'],
53
+ ['.py', 'python'],
54
+ ['.go', 'go'],
55
+ ['.rs', 'rust'],
56
+ ['.cs', 'csharp'],
57
+ ['.rb', 'ruby'],
58
+ ['.php', 'php'],
59
+ ['.swift', 'swift'],
60
+ ['.dart', 'dart'],
61
+ ['.scala', 'scala'],
62
+ ['.c', 'c'],
63
+ ['.cc', 'cpp'],
64
+ ['.cpp', 'cpp'],
65
+ ['.h', 'c'],
66
+ ['.hpp', 'cpp'],
67
+ ]);
68
+
27
69
  /**
28
70
  * Windows extended-length prefixes (`\\?\C:\...`, `\\?\UNC\server\share\...`) —
29
71
  * strip for readable output. UNC first: its prefix also starts with `\\?\`.
@@ -84,10 +126,18 @@ function ownDependencyEntry() {
84
126
 
85
127
  /** First match of `name` on PATH, or null. */
86
128
  function findOnPath(name) {
87
- const lookup = spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], {
88
- encoding: 'utf8',
89
- });
90
- if (lookup.status !== 0 || !lookup.stdout) return null;
129
+ let lookup;
130
+ try {
131
+ lookup = spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], {
132
+ encoding: 'utf8',
133
+ });
134
+ } catch (err) {
135
+ // A throw here means the lookup binary itself is unavailable. That is "not found on
136
+ // PATH", which is exactly what this function reports, not a reason to crash.
137
+ console.error(`[xp-gate arch] PATH lookup for ${name} could not run: ${err.message}`);
138
+ return null;
139
+ }
140
+ if (lookup.error || lookup.status !== 0 || !lookup.stdout) return null;
91
141
  const first = lookup.stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
92
142
  return first && fs.existsSync(first) ? first : null;
93
143
  }
@@ -132,7 +182,18 @@ function quoteForShell(arg) {
132
182
  * otherwise output is inherited so the user sees archlint's own report.
133
183
  * Returns { ok, status, stdout, stderr }; ok:false means the tool never ran.
134
184
  */
135
- function runArchlint(resolved, args, { cwd, capture = false } = {}) {
185
+ /**
186
+ * Work out the executable + argument list for a resolved archlint.
187
+ *
188
+ * Split from `runArchlint` to keep that function inside the long-function limit, and
189
+ * because the tier→invocation mapping is the part worth reading on its own: only the
190
+ * `node-script` tier avoids a shell, which is why it is the pinned default.
191
+ *
192
+ * @param {{kind: string, script?: string, bin?: string}} resolved
193
+ * @param {string[]} args arguments to the archlint CLI
194
+ * @returns {{command: string, commandArgs: string[], useShell: boolean}}
195
+ */
196
+ function archlintInvocation(resolved, args) {
136
197
  let command;
137
198
  let commandArgs;
138
199
  let useShell = false;
@@ -153,21 +214,45 @@ function runArchlint(resolved, args, { cwd, capture = false } = {}) {
153
214
  command = quoteForShell(command);
154
215
  commandArgs = commandArgs.map(quoteForShell);
155
216
  }
156
- const result = spawnSync(command, commandArgs, {
157
- cwd,
158
- encoding: 'utf8',
159
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
160
- shell: useShell,
161
- maxBuffer: MAX_OUTPUT_BYTES,
162
- });
217
+ return { command, commandArgs, useShell };
218
+ }
219
+
220
+ /**
221
+ * Report why archlint could not be launched, in terms the reader can act on.
222
+ *
223
+ * @param {{source: string}} resolved
224
+ * @param {Error} error spawnSync's error, or a caught throw
225
+ */
226
+ function reportLaunchFailure(resolved, error) {
227
+ if (error.code === 'ENOENT') {
228
+ console.error(`[xp-gate arch] ERROR: archlint not found (${resolved.source}).`);
229
+ console.error(` Fix: npm install --save-dev ${ARCHLINT_PACKAGE} in your project,`);
230
+ console.error(' or reinstall xp-gate when its own pinned copy is the missing one.');
231
+ return;
232
+ }
233
+ console.error(`[xp-gate arch] ERROR: failed to run archlint (${resolved.source}): ${error.message}`);
234
+ }
235
+
236
+ function runArchlint(resolved, args, { cwd, capture = false } = {}) {
237
+ const { command, commandArgs, useShell } = archlintInvocation(resolved, args);
238
+ let result;
239
+ try {
240
+ result = spawnSync(command, commandArgs, {
241
+ cwd,
242
+ encoding: 'utf8',
243
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
244
+ shell: useShell,
245
+ maxBuffer: MAX_OUTPUT_BYTES,
246
+ });
247
+ } catch (err) {
248
+ // spawnSync normally reports launch failures via `result.error`; a throw means the
249
+ // call itself was malformed. Both are "the tool never ran", which must never read as
250
+ // a clean scan.
251
+ console.error(`[xp-gate arch] ERROR: could not invoke archlint (${resolved.source}): ${err.message}`);
252
+ return { ok: false, status: 1, stdout: '', stderr: '' };
253
+ }
163
254
  if (result.error) {
164
- if (result.error.code === 'ENOENT') {
165
- console.error(`[xp-gate arch] ERROR: archlint not found (${resolved.source}).`);
166
- console.error(` Fix: npm install --save-dev ${ARCHLINT_PACKAGE} in your project,`);
167
- console.error(' or reinstall xp-gate when its own pinned copy is the missing one.');
168
- } else {
169
- console.error(`[xp-gate arch] ERROR: failed to run archlint (${resolved.source}): ${result.error.message}`);
170
- }
255
+ reportLaunchFailure(resolved, result.error);
171
256
  return { ok: false, status: 1, stdout: '', stderr: '' };
172
257
  }
173
258
  return {
@@ -182,7 +267,106 @@ function runArchlint(resolved, args, { cwd, capture = false } = {}) {
182
267
  * Parse `archlint scan --format json` output into a verdict.
183
268
  * Fail-closed: unparseable output is reported as an error, never as "clean".
184
269
  */
185
- function verdictFromScanJson(jsonText) {
270
+ /**
271
+ * Below this fraction of the project's source files, a scan is treated as not having
272
+ * covered the project.
273
+ *
274
+ * Not zero, because archlint legitimately analyses only the files its detectors
275
+ * understand, and a polyglot repo will always have some files outside its extensions.
276
+ * A quarter is deliberately generous: the case this exists for is 1-of-140, where any
277
+ * threshold catches it, and a real JS/TS project sits far above 0.25.
278
+ */
279
+ const MIN_ANALYSIS_COVERAGE = 0.25;
280
+
281
+ /**
282
+ * Source-file census for the project, used to judge whether a scan covered it.
283
+ *
284
+ * Walks the tree once, skipping the usual vendored directories. Cheap enough to run
285
+ * before a scan that takes seconds, and it is the ONLY way to know that
286
+ * `files_analyzed: 1` is a fifth of a percent rather than "this project is tiny".
287
+ *
288
+ * A failure to count returns `{}` rather than a guess: an unknown project size must
289
+ * leave the grade alone (see the `no project size known` case in the tests), never
290
+ * produce a spurious "not covered" verdict.
291
+ *
292
+ * @param {string} projectRoot repository root
293
+ * @returns {{projectFileCount?: number, dominantLanguage?: string}}
294
+ */
295
+ function projectScanContext(projectRoot) {
296
+ const counts = new Map();
297
+ let total = 0;
298
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'target', 'coverage', 'vendor']);
299
+ const walk = (dir, depth) => {
300
+ // Depth bound: a symlink cycle would otherwise walk forever.
301
+ if (depth > 24) return;
302
+ let entries;
303
+ try {
304
+ entries = fs.readdirSync(dir, { withFileTypes: true });
305
+ } catch {
306
+ return;
307
+ }
308
+ for (const entry of entries) {
309
+ if (entry.isDirectory()) {
310
+ if (SKIP.has(entry.name)) continue;
311
+ walk(path.join(dir, entry.name), depth + 1);
312
+ continue;
313
+ }
314
+ if (!entry.isFile()) continue;
315
+ const ext = path.extname(entry.name).toLowerCase();
316
+ if (!LANGUAGE_BY_EXTENSION.has(ext)) continue;
317
+ total += 1;
318
+ const lang = LANGUAGE_BY_EXTENSION.get(ext);
319
+ counts.set(lang, (counts.get(lang) || 0) + 1);
320
+ }
321
+ };
322
+ walk(projectRoot, 0);
323
+ if (total === 0) return {};
324
+ let dominantLanguage;
325
+ let best = -1;
326
+ // Deterministic tie-break: candidates in sorted order, strict `>` keeps the first.
327
+ for (const [lang, n] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
328
+ if (n > best) {
329
+ best = n;
330
+ dominantLanguage = lang;
331
+ }
332
+ }
333
+ return { projectFileCount: total, dominantLanguage };
334
+ }
335
+
336
+ /**
337
+ * Decide whether a scan covered enough of the project for its grade to mean anything.
338
+ *
339
+ * #590: a Java project with 140 `.java` files and no JS/TS got
340
+ * `files_analyzed: 1`, `total_smells: 0`, and therefore `10.0 / Excellent`. archlint's
341
+ * detectors are import-graph based over JS/TS extensions, so on that project every
342
+ * architecture check Gate 6 exists for — cycles, god modules, dead code, shotgun
343
+ * surgery — was silently skipped, while the report said the architecture was excellent.
344
+ *
345
+ * "Did not look" and "looked and found nothing" must be distinguishable. This is the
346
+ * same shape as #589 (Gate 3 permanently SKIP) and #594 (NaN coverage as PASS): the
347
+ * third instance today, which is why the check lives here rather than in the printer.
348
+ *
349
+ * @param {object|null} summary archlint's summary object
350
+ * @param {{projectFileCount?: number, dominantLanguage?: string}} [context]
351
+ * @returns {string|null} why the grade is untrustworthy, or null when it is fine
352
+ */
353
+ function analysisCoverageGap(summary, context) {
354
+ if (!summary) return null; // no grade at all: nothing to withhold
355
+ const analyzed = Number(summary.files_analyzed);
356
+ const total = Number(context && context.projectFileCount);
357
+ // Without both numbers there is nothing to compare. Reporting a gap on a guess would
358
+ // withhold grades from every caller that has not supplied a count — a worse outcome
359
+ // than the defect, and the reason the existing arch tests keep working unchanged.
360
+ if (!Number.isFinite(analyzed) || !Number.isFinite(total) || total <= 0) return null;
361
+ if (analyzed >= total * MIN_ANALYSIS_COVERAGE) return null;
362
+
363
+ const lang = (context && context.dominantLanguage) || 'the project';
364
+ // The numbers are in the message on purpose: an operator who sees "1 of 140" believes
365
+ // it, where "low coverage" reads as a threshold opinion.
366
+ return `archlint analysed ${analyzed} of ${total} source file(s), so it did not cover ${lang}. Its grade is withheld.`;
367
+ }
368
+
369
+ function verdictFromScanJson(jsonText, context) {
186
370
  let parsed;
187
371
  try {
188
372
  parsed = JSON.parse(jsonText);
@@ -207,16 +391,29 @@ function verdictFromScanJson(jsonText) {
207
391
  if (blockingDetails.length < BLOCKING_PREVIEW_LIMIT) blockingDetails.push(smell);
208
392
  }
209
393
  }
394
+ const summary = (parsed && parsed.summary) || null;
395
+ // #590: withhold an untrustworthy grade. The findings themselves are still reported —
396
+ // they are real regardless of how much was covered — but a 10.0 is a claim about a
397
+ // project, and this is the check that stops the claim being made about a project that
398
+ // was not read.
399
+ const coverageGap = analysisCoverageGap(summary, context);
210
400
  return {
211
401
  parseError: null,
212
402
  total: smells.length,
213
403
  blocking,
214
404
  blockingDetails,
215
- summary: (parsed && parsed.summary) || null,
405
+ summary: coverageGap ? null : summary,
406
+ coverageGap,
216
407
  };
217
408
  }
218
409
 
219
410
  function printScanSummary(verdict) {
411
+ if (verdict.coverageGap) {
412
+ // Say it before the findings line: an operator reading top-down must know the grade
413
+ // is missing and why, or the absence looks like a formatting quirk.
414
+ console.log(`[xp-gate arch] Grade withheld: ${verdict.coverageGap}`);
415
+ console.log(' Architecture was NOT measured for the languages archlint has no detector for.');
416
+ }
220
417
  const grade = verdict.summary && verdict.summary.architecture_grade;
221
418
  if (grade && grade.score !== undefined) {
222
419
  console.log(`[xp-gate arch] Architecture quality: ${grade.score}/10 (${grade.level})`);
@@ -237,30 +434,44 @@ function printScanSummary(verdict) {
237
434
  * Tool failures (spawn error, non-zero scan exit, unparseable JSON) always
238
435
  * exit non-zero — a broken scan must never read as a clean one.
239
436
  */
437
+ /**
438
+ * Baseline-ratchet mode: only NEW high/critical findings block.
439
+ *
440
+ * Split out of `runBuiltinScan` to keep that function inside the long-function limit
441
+ * (the #590 census pushed it over), and because this mode's contract — the exit code
442
+ * itself IS the verdict — deserves to be readable in one place.
443
+ *
444
+ * @param {string} projectRoot repository root
445
+ * @param {{kind: string}} resolved resolved archlint
446
+ * @param {string} baselinePath path to the baseline file
447
+ * @returns {number} exit code
448
+ */
449
+ function runBaselineRatchet(projectRoot, resolved, baselinePath) {
450
+ console.log(`[xp-gate arch] Baseline ratchet mode — ${BASELINE_FILE} present; blocking new ${FAIL_ON}+ findings only`);
451
+ const diff = runArchlint(resolved, ['diff', baselinePath, '--fail-on', FAIL_ON], { cwd: projectRoot });
452
+ if (!diff.ok) return 1;
453
+ // `--fail-on high` makes the exit code itself the verdict contract: 0 = no new high+
454
+ // findings, 1 = at least one. This is the exact mechanism Gate 6's pre-commit has used
455
+ // since before #447, so parity here is the point, not a shortcut. Anything other than
456
+ // 0/1 is a tool failure and blocks.
457
+ if (diff.status === 0) {
458
+ console.log(`✅ arch: no new ${FAIL_ON}+ architecture findings (baseline ratchet)`);
459
+ return 0;
460
+ }
461
+ if (diff.status === 1) {
462
+ console.error(`❌ BLOCKED - new ${FAIL_ON}+ architecture findings (baseline ratchet mode)`);
463
+ console.error(' Fix the findings above, or refresh the baseline with:');
464
+ console.error(` npx ${ARCHLINT_PACKAGE} snapshot -o ${BASELINE_FILE}`);
465
+ return 1;
466
+ }
467
+ console.error(`❌ ERROR - archlint diff exited ${diff.status}; tool failure is treated as blocking (never a silent pass)`);
468
+ return 1;
469
+ }
470
+
240
471
  function runBuiltinScan(projectRoot, resolved) {
241
472
  const baselinePath = path.join(projectRoot, BASELINE_FILE);
242
473
  if (fs.existsSync(baselinePath)) {
243
- console.log(`[xp-gate arch] Baseline ratchet mode — ${BASELINE_FILE} present; blocking new ${FAIL_ON}+ findings only`);
244
- const diff = runArchlint(resolved, ['diff', baselinePath, '--fail-on', FAIL_ON], { cwd: projectRoot });
245
- if (!diff.ok) return 1;
246
- // `--fail-on high` makes the exit code itself the verdict contract: 0 = no
247
- // new high+ findings, 1 = at least one. This is the exact mechanism Gate
248
- // 6's pre-commit has used since before #447 (issue evidence: "Gate 6
249
- // actually uses: archlint diff .architecture-baseline.json --fail-on high
250
- // ✅ normal"), so parity here is the point, not a shortcut. Anything other
251
- // than 0/1 is a tool failure and blocks.
252
- if (diff.status === 0) {
253
- console.log(`✅ arch: no new ${FAIL_ON}+ architecture findings (baseline ratchet)`);
254
- return 0;
255
- }
256
- if (diff.status === 1) {
257
- console.error(`❌ BLOCKED - new ${FAIL_ON}+ architecture findings (baseline ratchet mode)`);
258
- console.error(' Fix the findings above, or refresh the baseline with:');
259
- console.error(` npx ${ARCHLINT_PACKAGE} snapshot -o ${BASELINE_FILE}`);
260
- return 1;
261
- }
262
- console.error(`❌ ERROR - archlint diff exited ${diff.status}; tool failure is treated as blocking (never a silent pass)`);
263
- return 1;
474
+ return runBaselineRatchet(projectRoot, resolved, baselinePath);
264
475
  }
265
476
 
266
477
  console.log('[xp-gate arch] Zero-tolerance mode — no .architecture-baseline.json; blocking any high/critical finding');
@@ -281,7 +492,11 @@ function runBuiltinScan(projectRoot, resolved) {
281
492
  if (scan.stderr) process.stderr.write(scan.stderr);
282
493
  return 1;
283
494
  }
284
- const verdict = verdictFromScanJson(scan.stdout);
495
+ // #590: give the verdict the two facts it needs to judge its own grade — how many
496
+ // source files the project has, and which language dominates it. Without them a scan
497
+ // of 1-of-140 files still reports 10.0/Excellent, which is the vacuous pass this
498
+ // issue is about.
499
+ const verdict = verdictFromScanJson(scan.stdout, projectScanContext(projectRoot));
285
500
  if (verdict.parseError) {
286
501
  console.error(`❌ ERROR - ${verdict.parseError}; tool failure is treated as blocking (never a silent pass)`);
287
502
  return 1;
@@ -360,6 +575,8 @@ module.exports = {
360
575
  binEntryFromPackageDir,
361
576
  resolveArchlint,
362
577
  verdictFromScanJson,
578
+ analysisCoverageGap,
579
+ projectScanContext,
363
580
  quoteForShell,
364
581
  stripExtendedPrefix,
365
582
  BLOCKING_SEVERITIES,
@@ -68,6 +68,29 @@ if (jsonMode) {
68
68
  console.log('Status:', report.alignment_status);
69
69
  console.log('Score:', report.score);
70
70
  console.log('Misaligned:', report.misaligned_tests.length);
71
+
72
+ // #573 (recurrence of #57): a non-PASS verdict used to print only the status
73
+ // and counters. On a real run that read:
74
+ // Status: BLOCKED
75
+ // Score: 0
76
+ // Misaligned: 0
77
+ // which contradicts itself — zero misaligned tests, yet blocked — while the
78
+ // actual reason sat in report.errors that nothing ever printed. A gate that
79
+ // blocks without saying why is not actionable, and the usual response to an
80
+ // unactionable red is to bypass it, which is how dead gates are made.
81
+ if (report.alignment_status !== 'PASS') {
82
+ const reasons = Array.isArray(report.errors) ? report.errors : [];
83
+ if (reasons.length > 0) {
84
+ console.error('Reasons:');
85
+ for (const reason of reasons) console.error(' -', reason);
86
+ } else {
87
+ // Saying so is the point: a blocked verdict with no recorded reason is a
88
+ // defect in whoever produced the report, not something the reader can fix
89
+ // by editing their own files.
90
+ console.error('Reasons: (none recorded) — this is a bug in the report producer:');
91
+ console.error(' a non-PASS alignment verdict must carry at least one entry in report.errors.');
92
+ }
93
+ }
71
94
  }
72
95
  // Set exitCode instead of process.exit() so buffered stdout (notably the
73
96
  // --json document) flushes fully before the process terminates (#392 review).
package/lib/check.js CHANGED
@@ -147,7 +147,10 @@ async function checkAllGates(target) {
147
147
  }
148
148
 
149
149
  for (const gate of invokableGates) {
150
- const code = await runGate(gate.id, target);
150
+ // #589: `--all` means the whole tree. Passing the scope keeps the gates that
151
+ // read CHANGED_FILES from scanning an empty diff and reporting SKIP — which
152
+ // read like a pass while checking LESS than an ordinary commit.
153
+ const code = await runGate(gate.id, target, { scope: 'all' });
151
154
  if (code === 0) tally.passed += 1;
152
155
  else tally.failed += 1;
153
156
  console.log('');