@codyswann/lisa 4.32.2 → 4.33.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 (208) hide show
  1. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +94 -6
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +259 -5
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +20 -39
  4. package/all/merge/.claude/settings.json +1 -3
  5. package/cdk/merge/.claude/settings.json +1 -1
  6. package/dist/cli/doctor-declared-contexts.d.ts +1 -1
  7. package/dist/cli/doctor-declared-contexts.d.ts.map +1 -1
  8. package/dist/cli/doctor-declared-contexts.js +48 -7
  9. package/dist/cli/doctor-declared-contexts.js.map +1 -1
  10. package/dist/cli/gate-report-drift-section.d.ts.map +1 -1
  11. package/dist/cli/gate-report-drift-section.js +2 -0
  12. package/dist/cli/gate-report-drift-section.js.map +1 -1
  13. package/dist/configs/vitest/base.d.ts +81 -0
  14. package/dist/configs/vitest/base.d.ts.map +1 -1
  15. package/dist/configs/vitest/base.js +106 -0
  16. package/dist/configs/vitest/base.js.map +1 -1
  17. package/dist/configs/vitest/cdk.d.ts +2 -2
  18. package/dist/configs/vitest/cdk.d.ts.map +1 -1
  19. package/dist/configs/vitest/cdk.js +4 -2
  20. package/dist/configs/vitest/cdk.js.map +1 -1
  21. package/dist/configs/vitest/harper-fabric.d.ts +2 -2
  22. package/dist/configs/vitest/harper-fabric.d.ts.map +1 -1
  23. package/dist/configs/vitest/harper-fabric.js +4 -2
  24. package/dist/configs/vitest/harper-fabric.js.map +1 -1
  25. package/dist/configs/vitest/nestjs.d.ts +2 -2
  26. package/dist/configs/vitest/nestjs.d.ts.map +1 -1
  27. package/dist/configs/vitest/nestjs.js +4 -2
  28. package/dist/configs/vitest/nestjs.js.map +1 -1
  29. package/dist/configs/vitest/phaser.d.ts +2 -2
  30. package/dist/configs/vitest/phaser.d.ts.map +1 -1
  31. package/dist/configs/vitest/phaser.js +4 -2
  32. package/dist/configs/vitest/phaser.js.map +1 -1
  33. package/dist/configs/vitest/typescript.d.ts +2 -2
  34. package/dist/configs/vitest/typescript.d.ts.map +1 -1
  35. package/dist/configs/vitest/typescript.js +4 -2
  36. package/dist/configs/vitest/typescript.js.map +1 -1
  37. package/dist/core/gate-context-owners.d.ts +18 -0
  38. package/dist/core/gate-context-owners.d.ts.map +1 -1
  39. package/dist/core/gate-context-owners.js +7 -0
  40. package/dist/core/gate-context-owners.js.map +1 -1
  41. package/dist/core/gate-declaration-drift.d.ts +37 -0
  42. package/dist/core/gate-declaration-drift.d.ts.map +1 -1
  43. package/dist/core/gate-declaration-drift.js +100 -26
  44. package/dist/core/gate-declaration-drift.js.map +1 -1
  45. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  46. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  47. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  48. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  49. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  50. package/dist/core/upstream-evidence-manifest.js +47 -29
  51. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  52. package/dist/health/declared-checks-inspection.d.ts.map +1 -1
  53. package/dist/health/declared-checks-inspection.js +23 -7
  54. package/dist/health/declared-checks-inspection.js.map +1 -1
  55. package/dist/migrations/index.d.ts +1 -0
  56. package/dist/migrations/index.d.ts.map +1 -1
  57. package/dist/migrations/index.js +3 -0
  58. package/dist/migrations/index.js.map +1 -1
  59. package/dist/migrations/prune-retired-claude-deny-rules.d.ts +39 -0
  60. package/dist/migrations/prune-retired-claude-deny-rules.d.ts.map +1 -0
  61. package/dist/migrations/prune-retired-claude-deny-rules.js +152 -0
  62. package/dist/migrations/prune-retired-claude-deny-rules.js.map +1 -0
  63. package/expo/merge/.claude/settings.json +1 -1
  64. package/harper-fabric/merge/.claude/settings.json +1 -1
  65. package/nestjs/merge/.claude/settings.json +1 -1
  66. package/package.json +4 -4
  67. package/phaser/merge/.claude/settings.json +1 -1
  68. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa/.codex-plugin/skills/lisa-github-add-journey/SKILL.md +1 -0
  71. package/plugins/lisa/.codex-plugin/skills/lisa-github-validate-issue/SKILL.md +28 -1
  72. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +1 -0
  73. package/plugins/lisa/.codex-plugin/skills/lisa-jira-add-journey/SKILL.md +1 -0
  74. package/plugins/lisa/.codex-plugin/skills/lisa-jira-validate-ticket/SKILL.md +28 -1
  75. package/plugins/lisa/.codex-plugin/skills/lisa-linear-add-journey/SKILL.md +1 -0
  76. package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +27 -0
  77. package/plugins/lisa/.codex-plugin/skills/lisa-reproduce-bug/SKILL.md +8 -0
  78. package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
  79. package/plugins/lisa/.codex-plugin/skills/lisa-ticket-triage/SKILL.md +8 -0
  80. package/plugins/lisa/hooks/inject-resolved-config.mjs +12 -4
  81. package/plugins/lisa/hooks/inject-resolved-config.sh +39 -8
  82. package/plugins/lisa/rules/eager/control-reachability.md +39 -0
  83. package/plugins/lisa/rules/eager/falsifiable-checks.md +2 -0
  84. package/plugins/lisa/rules/eager/work-item-definition-of-ready.md +1 -1
  85. package/plugins/lisa/rules/reference/control-reachability.md +122 -0
  86. package/plugins/lisa/rules/reference/falsifiable-checks.md +2 -0
  87. package/plugins/lisa/skills/lisa-github-add-journey/SKILL.md +1 -0
  88. package/plugins/lisa/skills/lisa-github-validate-issue/SKILL.md +28 -1
  89. package/plugins/lisa/skills/lisa-implement/SKILL.md +1 -0
  90. package/plugins/lisa/skills/lisa-jira-add-journey/SKILL.md +1 -0
  91. package/plugins/lisa/skills/lisa-jira-validate-ticket/SKILL.md +28 -1
  92. package/plugins/lisa/skills/lisa-linear-add-journey/SKILL.md +1 -0
  93. package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +27 -0
  94. package/plugins/lisa/skills/lisa-reproduce-bug/SKILL.md +8 -0
  95. package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
  96. package/plugins/lisa/skills/lisa-ticket-triage/SKILL.md +8 -0
  97. package/plugins/lisa-agy/plugin.json +1 -1
  98. package/plugins/lisa-agy/skills/lisa-github-add-journey/SKILL.md +1 -0
  99. package/plugins/lisa-agy/skills/lisa-github-validate-issue/SKILL.md +28 -1
  100. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +1 -0
  101. package/plugins/lisa-agy/skills/lisa-jira-add-journey/SKILL.md +1 -0
  102. package/plugins/lisa-agy/skills/lisa-jira-validate-ticket/SKILL.md +28 -1
  103. package/plugins/lisa-agy/skills/lisa-linear-add-journey/SKILL.md +1 -0
  104. package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +27 -0
  105. package/plugins/lisa-agy/skills/lisa-reproduce-bug/SKILL.md +8 -0
  106. package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
  107. package/plugins/lisa-agy/skills/lisa-ticket-triage/SKILL.md +8 -0
  108. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  111. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +12 -4
  115. package/plugins/lisa-copilot/hooks/inject-resolved-config.sh +39 -8
  116. package/plugins/lisa-copilot/rules/eager/control-reachability.md +39 -0
  117. package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +2 -0
  118. package/plugins/lisa-copilot/rules/eager/work-item-definition-of-ready.md +1 -1
  119. package/plugins/lisa-copilot/rules/reference/control-reachability.md +122 -0
  120. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +2 -0
  121. package/plugins/lisa-copilot/skills/lisa-github-add-journey/SKILL.md +1 -0
  122. package/plugins/lisa-copilot/skills/lisa-github-validate-issue/SKILL.md +28 -1
  123. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +1 -0
  124. package/plugins/lisa-copilot/skills/lisa-jira-add-journey/SKILL.md +1 -0
  125. package/plugins/lisa-copilot/skills/lisa-jira-validate-ticket/SKILL.md +28 -1
  126. package/plugins/lisa-copilot/skills/lisa-linear-add-journey/SKILL.md +1 -0
  127. package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +27 -0
  128. package/plugins/lisa-copilot/skills/lisa-reproduce-bug/SKILL.md +8 -0
  129. package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
  130. package/plugins/lisa-copilot/skills/lisa-ticket-triage/SKILL.md +8 -0
  131. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  132. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +12 -4
  133. package/plugins/lisa-cursor/hooks/inject-resolved-config.sh +39 -8
  134. package/plugins/lisa-cursor/rules/control-reachability-reference.mdc +127 -0
  135. package/plugins/lisa-cursor/rules/control-reachability.mdc +44 -0
  136. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +2 -0
  137. package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +2 -0
  138. package/plugins/lisa-cursor/rules/work-item-definition-of-ready.mdc +1 -1
  139. package/plugins/lisa-cursor/skills/lisa-github-add-journey/SKILL.md +1 -0
  140. package/plugins/lisa-cursor/skills/lisa-github-validate-issue/SKILL.md +28 -1
  141. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +1 -0
  142. package/plugins/lisa-cursor/skills/lisa-jira-add-journey/SKILL.md +1 -0
  143. package/plugins/lisa-cursor/skills/lisa-jira-validate-ticket/SKILL.md +28 -1
  144. package/plugins/lisa-cursor/skills/lisa-linear-add-journey/SKILL.md +1 -0
  145. package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +27 -0
  146. package/plugins/lisa-cursor/skills/lisa-reproduce-bug/SKILL.md +8 -0
  147. package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
  148. package/plugins/lisa-cursor/skills/lisa-ticket-triage/SKILL.md +8 -0
  149. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  150. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  151. package/plugins/lisa-expo-agy/plugin.json +1 -1
  152. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  153. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  154. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  155. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  156. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  157. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  158. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  159. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  160. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  161. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  162. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  163. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  164. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  165. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  166. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  167. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  168. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  169. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  170. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  171. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  172. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  173. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  174. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  175. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  176. package/plugins/lisa-rails-agy/plugin.json +1 -1
  177. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  178. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  179. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  180. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  181. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  182. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  183. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  184. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  185. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  186. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  187. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  188. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  189. package/plugins/src/base/hooks/inject-resolved-config.mjs +12 -4
  190. package/plugins/src/base/hooks/inject-resolved-config.sh +39 -8
  191. package/plugins/src/base/rules/eager/control-reachability.md +39 -0
  192. package/plugins/src/base/rules/eager/falsifiable-checks.md +2 -0
  193. package/plugins/src/base/rules/eager/work-item-definition-of-ready.md +1 -1
  194. package/plugins/src/base/rules/reference/control-reachability.md +122 -0
  195. package/plugins/src/base/rules/reference/falsifiable-checks.md +2 -0
  196. package/plugins/src/base/skills/lisa-github-add-journey/SKILL.md +1 -0
  197. package/plugins/src/base/skills/lisa-github-validate-issue/SKILL.md +28 -1
  198. package/plugins/src/base/skills/lisa-implement/SKILL.md +1 -0
  199. package/plugins/src/base/skills/lisa-jira-add-journey/SKILL.md +1 -0
  200. package/plugins/src/base/skills/lisa-jira-validate-ticket/SKILL.md +28 -1
  201. package/plugins/src/base/skills/lisa-linear-add-journey/SKILL.md +1 -0
  202. package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +27 -0
  203. package/plugins/src/base/skills/lisa-reproduce-bug/SKILL.md +8 -0
  204. package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
  205. package/plugins/src/base/skills/lisa-ticket-triage/SKILL.md +8 -0
  206. package/rails/merge/.claude/settings.json +1 -1
  207. package/scripts/two-channel-couplings.json +1 -1
  208. package/typescript/merge/.claude/settings.json +1 -1
@@ -29,6 +29,13 @@
29
29
  * measurement when the suite that produced it finished.
30
30
  * @module lib/gate-failure-diagnosis
31
31
  */
32
+ import { availableParallelism, loadavg } from "node:os";
33
+
34
+ /** Default load-average source for {@link machineLoad}. */
35
+ const osLoadavg = () => loadavg();
36
+
37
+ /** Default core-count source for {@link machineLoad}. */
38
+ const osCores = () => availableParallelism();
32
39
 
33
40
  /** What a failed gate's output was recognised as. */
34
41
  export const DIAGNOSIS = Object.freeze({
@@ -445,9 +452,11 @@ function wasKilled(code) {
445
452
  * sitting on top of. Nothing the output says is a verdict once the run was
446
453
  * terminated.
447
454
  * @param {number|null} code The exit code, or null when none was obtained.
455
+ * @param {LoadReading|null} [load] The machine's load at diagnosis time, from
456
+ * {@link machineLoad}. Omitted or null when it could not be read.
448
457
  * @returns {Diagnosis} The verdict.
449
458
  */
450
- function killedVerdict(code) {
459
+ function killedVerdict(code, load) {
451
460
  const named = SIGNAL_EXITS[code];
452
461
  const cause =
453
462
  named === undefined
@@ -460,10 +469,85 @@ function killedVerdict(code) {
460
469
  `reached no verdict, and its output is a truncated transcript rather ` +
461
470
  `than a result. Re-run it on a quieter machine before reading anything ` +
462
471
  `into what it printed`,
463
- evidence: [],
472
+ evidence: loadEvidence(load ?? null),
464
473
  };
465
474
  }
466
475
 
476
+ /**
477
+ * The machine's run-queue pressure, or `null` when it cannot be read.
478
+ * @typedef {object} LoadReading
479
+ * @property {number} load1 One-minute load average.
480
+ * @property {number} cores Logical cores available to this process.
481
+ * @property {number} ratio `load1 / cores` — runnable work per core.
482
+ */
483
+
484
+ /**
485
+ * Read the machine's current run-queue pressure.
486
+ *
487
+ * Injected rather than called inline so a test can state a load instead of
488
+ * inheriting whatever the test machine happened to be doing, which would make
489
+ * the assertion a coin flip on a busy box — the very condition this reading
490
+ * exists to describe.
491
+ * @param {() => number[]} [readLoadavg] Load-average source.
492
+ * @param {() => number} [readCores] Core-count source.
493
+ * @returns {LoadReading|null} The reading, or null when either source fails.
494
+ */
495
+ export function machineLoad(readLoadavg = osLoadavg, readCores = osCores) {
496
+ try {
497
+ const load1 = readLoadavg()[0];
498
+ const cores = readCores();
499
+ if (!Number.isFinite(load1) || !Number.isFinite(cores) || cores <= 0) {
500
+ return null;
501
+ }
502
+ return { load1, cores, ratio: load1 / cores };
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Turn a load reading into the one line an operator needs beside a kill.
510
+ *
511
+ * A kill that says only "the machine was busy" is still prose. What decides
512
+ * whether to re-run or to investigate is the NUMBER, and the number cuts both
513
+ * ways on purpose: an oversubscribed box says re-run, and a quiet one says the
514
+ * saturation story does not hold here, go and find the real killer. Reporting
515
+ * only the first case would make this a rubber stamp for "not my change".
516
+ *
517
+ * The reading is taken when the failure is diagnosed, which is after the kill,
518
+ * so it lags — load is falling by then. The line says so rather than implying a
519
+ * precision it does not have.
520
+ * @param {LoadReading|null} load The reading, or null when unavailable.
521
+ * @returns {string[]} Zero or one evidence line.
522
+ */
523
+ function loadEvidence(load) {
524
+ if (load === null) return [];
525
+ const ratio = load.ratio.toFixed(1);
526
+ const where = `load1 ${load.load1.toFixed(1)} across ${load.cores} core(s), ${ratio}x per core`;
527
+ return load.ratio >= SATURATED_RATIO
528
+ ? [
529
+ `machine at diagnosis (after the kill, so this is a floor): ${where}. ` +
530
+ `A box at this level terminates processes to survive; read this as ` +
531
+ `saturation, not as a defect in the change.`,
532
+ ]
533
+ : [
534
+ `machine at diagnosis (after the kill, so this is a floor): ${where}. ` +
535
+ `That is not saturation, so contention does not explain this kill — ` +
536
+ `look for a timeout, an OOM, or an operator interrupt.`,
537
+ ];
538
+ }
539
+
540
+ /**
541
+ * Runnable work per core above which the box is treated as saturated.
542
+ *
543
+ * Two, not one. A load average equal to the core count is a machine that is
544
+ * fully used, which is what a test suite is supposed to do; twice that is a
545
+ * machine where every runnable thread is waiting behind another one. The
546
+ * sighting behind this issue was 21x, and the state after the fleet had already
547
+ * been asked to throttle was 8.3x, so the boundary is not close to either arm.
548
+ */
549
+ const SATURATED_RATIO = 2;
550
+
467
551
  /**
468
552
  * The verdict for a failure whose transcript never reached this module.
469
553
  *
@@ -530,10 +614,11 @@ function emptyTranscriptVerdict() {
530
614
  * when the runner could not capture it.
531
615
  * @param {number|null|undefined} code The command's exit code, or null when the
532
616
  * runner could not obtain one.
617
+ * @param {LoadReading|null} load The machine's load at diagnosis time.
533
618
  * @returns {object} What the failure was, before it is attributed.
534
619
  */
535
- function classify(output, code) {
536
- if (wasKilled(code)) return killedVerdict(code ?? null);
620
+ function classify(output, code, load) {
621
+ if (wasKilled(code)) return killedVerdict(code ?? null, load);
537
622
 
538
623
  if (typeof output !== "string") return unavailableVerdict();
539
624
 
@@ -597,9 +682,12 @@ function classify(output, code) {
597
682
  * @param {number|null|undefined} [code] The command's exit code. Omitted by a
598
683
  * caller that has none; a caller that HAS one must pass it, because a kill is
599
684
  * only legible in the exit code and never in the output.
685
+ * @param {LoadReading|null} [load] The machine's load, for a kill's evidence
686
+ * line. Defaults to reading this machine; pass `null` to suppress the line,
687
+ * or a fixed reading to make the output deterministic.
600
688
  * @returns {Diagnosis} What the failure was, and whose it was.
601
689
  */
602
- export function diagnoseFailure(output, code) {
603
- const verdict = classify(output, code);
690
+ export function diagnoseFailure(output, code, load = machineLoad()) {
691
+ const verdict = classify(output, code, load);
604
692
  return { ...verdict, proves: ATTRIBUTION[verdict.kind] ?? null };
605
693
  }
@@ -69,11 +69,30 @@
69
69
  */
70
70
 
71
71
  import { createHash } from "node:crypto";
72
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
73
- import { join } from "node:path";
72
+ import {
73
+ appendFileSync,
74
+ existsSync,
75
+ readdirSync,
76
+ readFileSync,
77
+ statSync,
78
+ } from "node:fs";
79
+ import { createRequire } from "node:module";
80
+ import { dirname, join, parse } from "node:path";
81
+ import { fileURLToPath } from "node:url";
74
82
 
75
83
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
76
84
 
85
+ /**
86
+ * The npm manifest filename, named once.
87
+ *
88
+ * Three call sites read one: two resolve a project's own scripts, and the
89
+ * third walks up from a resolved package entry to find the manifest that owns
90
+ * it. A literal repeated across all three is a typo away from a lookup that
91
+ * silently answers "no manifest" — which every one of those readers is written
92
+ * to treat as an ordinary absence.
93
+ */
94
+ const PACKAGE_MANIFEST = "package.json";
95
+
77
96
  /** Enforcement levels a moment may carry. */
78
97
  export const LEVELS = ["required", "optional", "off"];
79
98
 
@@ -3798,7 +3817,7 @@ export function readGates(cwd = process.cwd()) {
3798
3817
  * @returns {Record<string, string>|null} The scripts, or null when unknown.
3799
3818
  */
3800
3819
  export function projectScripts(cwd = process.cwd()) {
3801
- const path = join(cwd, "package.json");
3820
+ const path = join(cwd, PACKAGE_MANIFEST);
3802
3821
  if (!existsSync(path)) return null;
3803
3822
  try {
3804
3823
  const scripts = JSON.parse(readFileSync(path, "utf8"))?.scripts;
@@ -3852,7 +3871,7 @@ function aliasFor({ declared, registryTask, shippedAs, scripts }) {
3852
3871
  * @returns {Record<string, string>} The scripts block.
3853
3872
  */
3854
3873
  export function readScripts(cwd = process.cwd()) {
3855
- const path = join(cwd, "package.json");
3874
+ const path = join(cwd, PACKAGE_MANIFEST);
3856
3875
  if (!existsSync(path)) return {};
3857
3876
  let manifest;
3858
3877
  try {
@@ -5994,6 +6013,217 @@ function readPostedNames(source) {
5994
6013
  return [...names];
5995
6014
  }
5996
6015
 
6016
+ /* ─── Which Lisa produced this report ───────────────────────────────────── */
6017
+
6018
+ /**
6019
+ * The package whose version answers "which Lisa is installed here".
6020
+ *
6021
+ * Named once. `registryVersion()` resolves it and then checks the manifest it
6022
+ * lands on carries this exact name, and the two must not be able to disagree —
6023
+ * a resolution checked against a different literal is a version attached to
6024
+ * whatever package happened to answer.
6025
+ */
6026
+ const LISA_PACKAGE = "@codyswann/lisa";
6027
+
6028
+ /**
6029
+ * What an unestablished identity field renders as.
6030
+ *
6031
+ * A word rather than an empty string, and never a fallback value. An operator
6032
+ * comparing two gate reports has to be able to tell "this ran a Lisa whose
6033
+ * version could not be resolved" from "this report has no version field at
6034
+ * all", and a blank renders as the second. Guessing renders as neither: the
6035
+ * host application's version in this slot is the specific failure
6036
+ * `registryVersion()` returns null to avoid.
6037
+ */
6038
+ export const IDENTITY_UNKNOWN = "unknown";
6039
+
6040
+ /**
6041
+ * The shipped enforcement artifacts whose COPY decides a verdict.
6042
+ *
6043
+ * A workflow ref answers "which wrapper ran". It does not answer "which copy
6044
+ * of the checked-in script did that wrapper execute", and when the enforcement
6045
+ * is a file living in the consuming repository those are independent facts —
6046
+ * the second one is the one that produces the verdict. Four live copies of one
6047
+ * enforcement script have been observed at once (a repository's default
6048
+ * branch, a pull request's head, an installed release, and upstream), two of
6049
+ * them sharing a version string, so the identity emitted here is a digest of
6050
+ * the bytes rather than a declared version.
6051
+ */
6052
+ const IDENTITY_ARTIFACTS = Object.freeze([
6053
+ "lisa-gates.mjs",
6054
+ "lisa-run-gates.mjs",
6055
+ ]);
6056
+
6057
+ /** The directory the executing copy of this registry lives in. */
6058
+ const SCRIPTS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
6059
+
6060
+ /**
6061
+ * The `@codyswann/lisa` version that owns the resolver behind this run.
6062
+ *
6063
+ * Resolve the package entry from this file, then walk to the owning manifest.
6064
+ * That works in both shipped layouts: inside the installed package and after
6065
+ * Lisa emits this registry into a host project's `scripts/` directory. Reading
6066
+ * a fixed relative path from the emitted copy escapes the package, while
6067
+ * reading `../package.json` confidently reports the host application's version.
6068
+ *
6069
+ * The package-name check stays load-bearing. It makes an unexpected resolution
6070
+ * return null rather than attaching another package's version to this registry.
6071
+ * @returns {string|null} The version, or null when it cannot be established.
6072
+ */
6073
+ export function registryVersion() {
6074
+ try {
6075
+ const entry = createRequire(import.meta.url).resolve(LISA_PACKAGE);
6076
+ const root = parse(entry).root;
6077
+ let directory = dirname(entry);
6078
+ while (true) {
6079
+ const manifest = join(directory, PACKAGE_MANIFEST);
6080
+ if (existsSync(manifest)) {
6081
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
6082
+ if (parsed.name === LISA_PACKAGE) return parsed.version ?? null;
6083
+ }
6084
+ if (directory === root) return null;
6085
+ directory = dirname(directory);
6086
+ }
6087
+ } catch {
6088
+ return null;
6089
+ }
6090
+ }
6091
+
6092
+ /**
6093
+ * A content digest of one file, or null when its bytes cannot be read.
6094
+ *
6095
+ * Twelve hex characters, not sixty-four. The question this answers is "did
6096
+ * these two surfaces run the same bytes", which is a comparison between two
6097
+ * printed lines an operator reads side by side; a full digest is not more
6098
+ * comparable and is materially less readable. Null rather than a placeholder
6099
+ * string, so a caller can tell "absent" from a digest that happens to look odd.
6100
+ * @param {string} file Path to the artifact.
6101
+ * @returns {string|null} `sha256:<12 hex>`, or null when unreadable.
6102
+ */
6103
+ export function artifactDigest(file) {
6104
+ try {
6105
+ const hex = createHash("sha256").update(readFileSync(file)).digest("hex");
6106
+ return `sha256:${hex.slice(0, 12)}`;
6107
+ } catch {
6108
+ return null;
6109
+ }
6110
+ }
6111
+
6112
+ /**
6113
+ * WHICH Lisa is running: the surface, the version, the ref, and the bytes.
6114
+ *
6115
+ * ONE COMPUTATION, SEVERAL RENDERINGS. A gate report from CI and a gate report
6116
+ * from a pre-push hook are claims about different code at different versions —
6117
+ * a consumer pins `@codyswann/lisa` for the local hook and calls the reusable
6118
+ * workflow at a floating ref for CI — and neither report used to say which. So
6119
+ * an observation about gate behaviour could not be dated, and a true warning
6120
+ * went stale with nothing in either repository able to notice.
6121
+ *
6122
+ * Every renderer below reads this object rather than resolving a version of
6123
+ * its own, because a second resolution path is a second answer that can
6124
+ * disagree with the first.
6125
+ * @param {object} [options] Inputs.
6126
+ * @param {Record<string,string|undefined>} [options.env] The environment.
6127
+ * @param {string} [options.directory] Where the enforcement scripts live.
6128
+ * @returns {object} The identity record.
6129
+ */
6130
+ export function lisaIdentity({
6131
+ env = process.env,
6132
+ directory = SCRIPTS_DIRECTORY,
6133
+ } = {}) {
6134
+ return {
6135
+ surface: env.GITHUB_ACTIONS === "true" ? "ci" : "local",
6136
+ registry_version: registryVersion(),
6137
+ workflow_ref: env.GITHUB_WORKFLOW_REF ?? null,
6138
+ workflow_sha: env.GITHUB_WORKFLOW_SHA ?? null,
6139
+ artifacts: Object.fromEntries(
6140
+ IDENTITY_ARTIFACTS.map(name => [
6141
+ name,
6142
+ artifactDigest(join(directory, name)),
6143
+ ])
6144
+ ),
6145
+ };
6146
+ }
6147
+
6148
+ /**
6149
+ * The workflow half of an identity, rendered for a reader.
6150
+ * @param {object} identity As returned by `lisaIdentity`.
6151
+ * @returns {string} `<ref>@<sha>`, or `unknown` when nothing was established.
6152
+ */
6153
+ function identityWorkflow(identity) {
6154
+ if (!identity.workflow_ref) return IDENTITY_UNKNOWN;
6155
+ return `${identity.workflow_ref}@${identity.workflow_sha ?? IDENTITY_UNKNOWN}`;
6156
+ }
6157
+
6158
+ /**
6159
+ * One operator-readable line naming which Lisa produced a report.
6160
+ *
6161
+ * Cheap enough to appear in ORDINARY output, which is the property that
6162
+ * matters. A stamp only an adjudicator sees helps a reader who is already
6163
+ * suspicious; the observations that go stale unchallenged are the ones nobody
6164
+ * had a reason to check.
6165
+ * @param {object} identity As returned by `lisaIdentity`.
6166
+ * @returns {string} The line, with `unknown` wherever nothing was established.
6167
+ */
6168
+ export function formatIdentityLine(identity) {
6169
+ return [
6170
+ `🔖 Lisa identity — surface=${identity.surface}`,
6171
+ `package=${LISA_PACKAGE}@${identity.registry_version ?? IDENTITY_UNKNOWN}`,
6172
+ `workflow=${identityWorkflow(identity)}`,
6173
+ ...Object.entries(identity.artifacts ?? {}).map(
6174
+ ([name, sha]) => `${name}=${sha ?? IDENTITY_UNKNOWN}`
6175
+ ),
6176
+ ].join(" · ");
6177
+ }
6178
+
6179
+ /**
6180
+ * The same identity as a run-summary block.
6181
+ * @param {object} identity As returned by `lisaIdentity`.
6182
+ * @returns {string} Markdown for `$GITHUB_STEP_SUMMARY`.
6183
+ */
6184
+ export function formatIdentityMarkdown(identity) {
6185
+ const version = identity.registry_version ?? IDENTITY_UNKNOWN;
6186
+ const rows = [
6187
+ ["surface", identity.surface],
6188
+ ["package", `${LISA_PACKAGE}@${version}`],
6189
+ ["workflow ref", identity.workflow_ref ?? IDENTITY_UNKNOWN],
6190
+ ["workflow sha", identity.workflow_sha ?? IDENTITY_UNKNOWN],
6191
+ ...Object.entries(identity.artifacts ?? {}).map(([name, sha]) => [
6192
+ name,
6193
+ sha ?? IDENTITY_UNKNOWN,
6194
+ ]),
6195
+ ];
6196
+ return [
6197
+ "### 🔖 Which Lisa ran this",
6198
+ "",
6199
+ "| field | value |",
6200
+ "| --- | --- |",
6201
+ ...rows.map(([field, value]) => `| ${field} | \`${value}\` |`),
6202
+ "",
6203
+ "Every gate report on this run was produced by the Lisa above. A report " +
6204
+ "from the other surface — the local pre-push gate — prints the same " +
6205
+ "fields, so the two can be compared without either repository: equal " +
6206
+ "digests mean the same enforcement bytes ran, and a differing " +
6207
+ "`workflow sha` dates any claim made from this run.",
6208
+ ].join("\n");
6209
+ }
6210
+
6211
+ /**
6212
+ * Append a block to the run summary, or report that there was nowhere to.
6213
+ * @param {string} markdown The block to append.
6214
+ * @returns {boolean} Whether it was written.
6215
+ */
6216
+ function appendRunSummary(markdown) {
6217
+ const target = process.env.GITHUB_STEP_SUMMARY;
6218
+ if (!target) return false;
6219
+ try {
6220
+ appendFileSync(target, `${markdown}\n`);
6221
+ return true;
6222
+ } catch {
6223
+ return false;
6224
+ }
6225
+ }
6226
+
5997
6227
  /**
5998
6228
  * CLI entry point.
5999
6229
  */
@@ -6003,6 +6233,30 @@ function main() {
6003
6233
  const hit = rest.find(arg => arg.startsWith(`--${name}=`));
6004
6234
  return hit ? hit.slice(name.length + 3) : null;
6005
6235
  };
6236
+ // FIRST, AND BEFORE ANY GATE IS RESOLVED. This command answers "which Lisa
6237
+ // is about to run", so it must be answerable on a project whose declarations
6238
+ // are the thing under suspicion — including one whose config this registry
6239
+ // would refuse. It reads no gates and cannot fail on them.
6240
+ if (command === "identity") {
6241
+ const identity = lisaIdentity();
6242
+ if (rest.includes("--json")) {
6243
+ console.log(JSON.stringify(identity, null, 2));
6244
+ return;
6245
+ }
6246
+ const line = formatIdentityLine(identity);
6247
+ if (flag("format") === "github") {
6248
+ // The annotation AND the run summary, not one of them: an annotation is
6249
+ // what a reader sees beside a failing gate, and the summary is what
6250
+ // survives into a link somebody pastes into a ticket a week later. The
6251
+ // stale claim this exists to refute is always read later.
6252
+ console.log(`::notice title=Lisa identity::${line}`);
6253
+ appendRunSummary(formatIdentityMarkdown(identity));
6254
+ return;
6255
+ }
6256
+ console.log(line);
6257
+ return;
6258
+ }
6259
+
6006
6260
  const { runner, gates, policy, config } = readGates();
6007
6261
  // Read once, here, so every subcommand answers about the same project. The
6008
6262
  // resolver is handed the result rather than reading the disk itself, which
@@ -6399,7 +6653,7 @@ function main() {
6399
6653
  }
6400
6654
 
6401
6655
  throw new Error(
6402
- "usage: lisa-gates.mjs validate|list|legs|quality-plan|reuse-plan|needs|contexts|retired-contexts|verify-contexts|skip-jobs|audit-config|inventory|unconfigured|seed"
6656
+ "usage: lisa-gates.mjs validate|list|legs|quality-plan|reuse-plan|needs|contexts|retired-contexts|verify-contexts|skip-jobs|audit-config|inventory|unconfigured|identity|seed"
6403
6657
  );
6404
6658
  }
6405
6659
 
@@ -49,9 +49,8 @@ import {
49
49
  rmSync,
50
50
  writeFileSync,
51
51
  } from "node:fs";
52
- import { createRequire } from "node:module";
53
52
  import { tmpdir } from "node:os";
54
- import { dirname, join, parse, relative } from "node:path";
53
+ import { dirname, join, relative } from "node:path";
55
54
  import { fileURLToPath } from "node:url";
56
55
 
57
56
  import { DIAGNOSIS, diagnoseFailure } from "./lib/gate-failure-diagnosis.mjs";
@@ -62,6 +61,8 @@ import {
62
61
  configurationProblems,
63
62
  declarationsAt,
64
63
  digest,
64
+ formatIdentityLine,
65
+ lisaIdentity,
65
66
  momentsExecutedBy,
66
67
  planDigest,
67
68
  projectScripts,
@@ -1196,40 +1197,6 @@ const EVIDENCE_STATUS_FOR = Object.freeze({
1196
1197
  [STATE.NOT_RUN]: "unknown",
1197
1198
  });
1198
1199
 
1199
- /**
1200
- * The `@codyswann/lisa` version that owns the resolver behind this run.
1201
- *
1202
- * Resolve the package entry from this file, then walk to the owning manifest.
1203
- * That works in both shipped layouts: inside the installed package and after
1204
- * Lisa emits this runner into a host project's `scripts/` directory. Reading a
1205
- * fixed relative path from the emitted copy escapes the package, while reading
1206
- * `../package.json` confidently reports the host application's version.
1207
- *
1208
- * The package-name check stays load-bearing. It makes an unexpected resolution
1209
- * return null rather than attaching another package's version to this registry.
1210
- * @returns {string|null} The version, or null when it cannot be established.
1211
- */
1212
- function registryVersion() {
1213
- try {
1214
- const entry = createRequire(import.meta.url).resolve("@codyswann/lisa");
1215
- let directory = dirname(entry);
1216
- const root = parse(directory).root;
1217
- while (true) {
1218
- const manifest = join(directory, "package.json");
1219
- if (existsSync(manifest)) {
1220
- const parsed = JSON.parse(readFileSync(manifest, "utf8"));
1221
- if (parsed.name === "@codyswann/lisa") {
1222
- return parsed.version ?? null;
1223
- }
1224
- }
1225
- if (directory === root) return null;
1226
- directory = dirname(directory);
1227
- }
1228
- } catch {
1229
- return null;
1230
- }
1231
- }
1232
-
1233
1200
  /**
1234
1201
  * One revision from git, or null when git cannot answer.
1235
1202
  *
@@ -1316,9 +1283,15 @@ function evidenceContract({ moment, gates, runner, scripts = null }) {
1316
1283
  moment,
1317
1284
  runner: runner ?? null,
1318
1285
  gates_digest: planDigest({ gates, moment, runner, scripts }),
1319
- registry_version: registryVersion(),
1320
- workflow_ref: process.env.GITHUB_WORKFLOW_REF ?? null,
1321
- workflow_sha: process.env.GITHUB_WORKFLOW_SHA ?? null,
1286
+ // SPREAD, not three hand-copied fields. The printed header and this
1287
+ // document have to be the same claim: a reader comparing a pasted gate
1288
+ // line against a stored envelope and finding them disagree learns nothing
1289
+ // except that Lisa has two answers. `surface` and `artifacts` ride along,
1290
+ // and they are the half that says WHICH COPY of the enforcement script
1291
+ // produced the verdict — a fact a workflow ref cannot carry, because the
1292
+ // wrapper's identity and the enforcement's identity are independent when
1293
+ // the enforcement is a file living in the consuming repository.
1294
+ ...lisaIdentity(),
1322
1295
  inputs_digest: inputsDigest(),
1323
1296
  };
1324
1297
  }
@@ -1619,6 +1592,14 @@ function main() {
1619
1592
  return EXIT.RUNNER_FAILED;
1620
1593
  }
1621
1594
 
1595
+ // PRINTED BEFORE ANY GATE RUNS, so it is present whether they pass, fail,
1596
+ // are optional, or never run at all. Every early return below is a report an
1597
+ // operator may quote later, and a quoted report that cannot be dated is the
1598
+ // defect this line exists to close: the local hook runs the version this
1599
+ // project PINNED, while CI runs a floating ref, so two reports about "the
1600
+ // gate" are routinely claims about different code.
1601
+ console.log(formatIdentityLine(lisaIdentity()));
1602
+
1622
1603
  /**
1623
1604
  * Record this run, and refuse to report a verdict we could not record.
1624
1605
  *
@@ -33,9 +33,7 @@
33
33
  "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
34
34
  },
35
35
  "permissions": {
36
- "deny": [
37
- "Read(./.entire/metadata/**)"
38
- ]
36
+ "deny": []
39
37
  },
40
38
  "plansDirectory": "./plans",
41
39
  "hooks": {
@@ -27,7 +27,7 @@
27
27
  "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
28
28
  },
29
29
  "permissions": {
30
- "deny": ["Read(./.entire/metadata/**)"]
30
+ "deny": []
31
31
  },
32
32
  "plansDirectory": "./plans"
33
33
  }
@@ -15,7 +15,7 @@
15
15
  * operator to delete a required check; those warn and say what to declare.
16
16
  * @module cli/doctor-declared-contexts
17
17
  */
18
- import type { DeclarationDriftReport } from "../core/gate-declaration-drift.js";
18
+ import { type DeclarationDriftReport } from "../core/gate-declaration-drift.js";
19
19
  import type { DoctorCheck } from "./doctor.js";
20
20
  /**
21
21
  * Turn a completed comparison into one operator-readable line.
@@ -1 +1 @@
1
- {"version":3,"file":"doctor-declared-contexts.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-declared-contexts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAEV,sBAAsB,EACvB,MAAM,mCAAmC,CAAC;AAM3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB/C;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,sBAAsB,GAC7B,WAAW,CA8Db;AAsDD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,WAAW,CAAC,CAiCtB"}
1
+ {"version":3,"file":"doctor-declared-contexts.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-declared-contexts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAIL,KAAK,sBAAsB,EAC5B,MAAM,mCAAmC,CAAC;AAM3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB/C;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,sBAAsB,GAC7B,WAAW,CAyDb;AAqFD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,WAAW,CAAC,CAiCtB"}
@@ -1,3 +1,21 @@
1
+ /**
2
+ * Report where the settings file and the ruleset that enforces it disagree.
3
+ *
4
+ * The machine payload under `--json` carries the whole comparison, three states
5
+ * and all. This is the line an operator reads without asking for JSON, and it
6
+ * is deliberately the OFFLINE half: doctor is documented as making no network
7
+ * call, and the template is what a repository's protection is provisioned
8
+ * from, so a disagreement with it is drift the moment anyone provisions —
9
+ * whether or not the live ruleset has caught up. The live half runs in
10
+ * `lisa health`, which already reaches GitHub.
11
+ *
12
+ * A `warn` here is not a softer `fail`. Two of the verdicts are contradictions
13
+ * — the two surfaces state opposite things and one of them is wrong — and
14
+ * those fail. The other two are silences, and a silence must not send an
15
+ * operator to delete a required check; those warn and say what to declare.
16
+ * @module cli/doctor-declared-contexts
17
+ */
18
+ import { isContradiction, isGap, } from "../core/gate-declaration-drift.js";
1
19
  import { readConfig } from "./gate-report-config.js";
2
20
  import { readFacadeFacts } from "./gate-report-facade.js";
3
21
  import { buildDeclarationDrift } from "./gate-report-joins.js";
@@ -41,8 +59,9 @@ export function declaredContextsCheck(report) {
41
59
  detail: `${named(retired.map(entry => entry.context))} can never report: Lisa renamed the job that used to post ${retired.length === 1 ? "it" : "them"}. ${retired[0]?.detail ?? ""} Run \`lisa health\` to sweep the LIVE rulesets, including any this repository hand-made, for the same name.`,
42
60
  };
43
61
  }
44
- const contradictions = report.entries.filter(entry => entry.verdict === "declared-not-enforced" ||
45
- entry.verdict === "enforced-declared-off");
62
+ // Asked of the comparator rather than re-listed here — see the note on the
63
+ // same call in `health/declared-checks-inspection`.
64
+ const contradictions = report.entries.filter(entry => isContradiction(entry.verdict));
46
65
  if (contradictions.length > 0) {
47
66
  return {
48
67
  name: NAME,
@@ -50,9 +69,7 @@ export function declaredContextsCheck(report) {
50
69
  detail: `The settings file and the ruleset template say opposite things about ${named(contradictions.map(entry => entry.context))}. ${contradictions[0]?.detail ?? ""} Fix whichever is wrong; nothing here proposes removing a check.`,
51
70
  };
52
71
  }
53
- const undeclared = report.entries.filter(entry => entry.verdict === "enforced-undeclared");
54
- const optional = report.entries.filter(entry => entry.verdict === "enforced-declared-optional");
55
- if (undeclared.length === 0 && optional.length === 0) {
72
+ if (!report.entries.some(entry => isGap(entry.verdict))) {
56
73
  return {
57
74
  name: NAME,
58
75
  status: "ok",
@@ -69,14 +86,38 @@ export function declaredContextsCheck(report) {
69
86
  // whose gate IS declared — which is untrue and sends the operator looking
70
87
  // for a declaration that is already there.
71
88
  detail: [
72
- undeclaredClause(undeclared),
73
- optionalClause(optional),
89
+ undeclaredClause(entriesWith(report, "enforced-undeclared")),
90
+ optionalClause(entriesWith(report, "enforced-declared-optional")),
91
+ awaitedClause(entriesWith(report, "enforced-awaited-elsewhere")),
74
92
  "Do NOT remove the required check to make this line go away.",
75
93
  ]
76
94
  .filter(clause => clause.length > 0)
77
95
  .join(" "),
78
96
  };
79
97
  }
98
+ /**
99
+ * The entries carrying one verdict.
100
+ * @param report - The comparison
101
+ * @param verdict - The verdict to collect
102
+ * @returns The matching entries
103
+ */
104
+ function entriesWith(report, verdict) {
105
+ return report.entries.filter(entry => entry.verdict === verdict);
106
+ }
107
+ /**
108
+ * The clause for contexts a gate's declaration awaits a different signal for.
109
+ * @param entries - The awaited-elsewhere entries
110
+ * @returns One sentence, or an empty string
111
+ */
112
+ function awaitedClause(entries) {
113
+ if (entries.length === 0)
114
+ return "";
115
+ const gates = gateIdsOf(entries);
116
+ const awaited = named([
117
+ ...new Set(entries.flatMap(entry => entry.awaitedInstead === null ? [] : [entry.awaitedInstead])),
118
+ ]);
119
+ return `The template also requires ${named(entries.map(entry => entry.context))} while the settings file declares ${gates} with an \`await:\` at pull-request, which promises ${awaited} as the merge condition instead. An awaited gate posts no job of its own, so unless a hand-written job posts these exact names they will never report and every pull request waits on them.`;
120
+ }
80
121
  /**
81
122
  * The clause for contexts whose gate the settings file never mentions.
82
123
  * @param entries - The undeclared entries