@codyswann/lisa 3.0.0 → 3.2.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 (139) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +50 -23
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +211 -121
  5. package/expo/copy-overwrite/scripts/bdd/contract.mjs +10 -2
  6. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +3 -2
  7. package/expo/copy-overwrite/scripts/bdd/render.mjs +2 -2
  8. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +45 -8
  9. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +775 -0
  10. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +71 -0
  11. package/expo/create-only/.maestro/flake-classification.json +21 -0
  12. package/expo/create-only/bdd/coverage-map.json +1 -2
  13. package/expo/package-lisa/package.lisa.json +1 -0
  14. package/package.json +1 -1
  15. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +75 -64
  18. package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +13 -5
  19. package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +30 -10
  20. package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +36 -23
  21. package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +16 -6
  22. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +8 -0
  23. package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +16 -6
  24. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  25. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +24 -0
  26. package/plugins/lisa/rules/eager/credential-substrate-precedence.md +52 -0
  27. package/plugins/lisa/rules/eager/integration-access-layer.md +7 -3
  28. package/plugins/lisa/rules/reference/bdd-e2e-coverage.md +19 -8
  29. package/plugins/lisa/rules/reference/credential-substrate-precedence.md +166 -0
  30. package/plugins/lisa/rules/reference/integration-access-layer.md +27 -15
  31. package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +76 -65
  32. package/plugins/lisa/skills/lisa-jam-access/SKILL.md +14 -6
  33. package/plugins/lisa/skills/lisa-linear-access/SKILL.md +31 -11
  34. package/plugins/lisa/skills/lisa-notion-access/SKILL.md +37 -24
  35. package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +17 -7
  36. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +8 -0
  37. package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +17 -7
  38. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  39. package/plugins/lisa-agy/plugin.json +1 -1
  40. package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +76 -65
  41. package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +14 -6
  42. package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +31 -11
  43. package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +37 -24
  44. package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +17 -7
  45. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +8 -0
  46. package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +17 -7
  47. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  48. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  51. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +24 -0
  55. package/plugins/lisa-copilot/rules/eager/credential-substrate-precedence.md +52 -0
  56. package/plugins/lisa-copilot/rules/eager/integration-access-layer.md +7 -3
  57. package/plugins/lisa-copilot/rules/reference/bdd-e2e-coverage.md +19 -8
  58. package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +166 -0
  59. package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +27 -15
  60. package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +76 -65
  61. package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +14 -6
  62. package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +31 -11
  63. package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +37 -24
  64. package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +17 -7
  65. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +8 -0
  66. package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +17 -7
  67. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  68. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +24 -0
  70. package/plugins/lisa-cursor/rules/bdd-e2e-coverage-reference.mdc +19 -8
  71. package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +171 -0
  72. package/plugins/lisa-cursor/rules/credential-substrate-precedence.mdc +57 -0
  73. package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +27 -15
  74. package/plugins/lisa-cursor/rules/integration-access-layer.mdc +7 -3
  75. package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +76 -65
  76. package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +14 -6
  77. package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +31 -11
  78. package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +37 -24
  79. package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +17 -7
  80. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +8 -0
  81. package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +17 -7
  82. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  83. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-expo-agy/plugin.json +1 -1
  86. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  91. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  96. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  101. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  106. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-rails-agy/plugin.json +1 -1
  111. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  115. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  116. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  120. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  121. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  123. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +24 -0
  124. package/plugins/src/base/rules/eager/credential-substrate-precedence.md +52 -0
  125. package/plugins/src/base/rules/eager/integration-access-layer.md +7 -3
  126. package/plugins/src/base/rules/reference/bdd-e2e-coverage.md +19 -8
  127. package/plugins/src/base/rules/reference/credential-substrate-precedence.md +166 -0
  128. package/plugins/src/base/rules/reference/integration-access-layer.md +27 -15
  129. package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +76 -65
  130. package/plugins/src/base/skills/lisa-jam-access/SKILL.md +14 -6
  131. package/plugins/src/base/skills/lisa-linear-access/SKILL.md +31 -11
  132. package/plugins/src/base/skills/lisa-notion-access/SKILL.md +37 -24
  133. package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +17 -7
  134. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +8 -0
  135. package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +17 -7
  136. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  137. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
  138. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +631 -5
  139. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
@@ -1,20 +1,39 @@
1
1
  /**
2
- * Base-revision comparisons: the coverage-floor ratchet and the
3
- * scenario-deletion check.
2
+ * Base-revision comparisons: the non-regression invariants.
4
3
  *
5
- * Both answer the same question — "did this change make the number look
6
- * better by lowering the bar instead of raising the work?" — and both need a
7
- * base revision to answer it, so they share one git read.
4
+ * Three checks, one question — "did this change make the number look better by
5
+ * describing less of the product, rather than by covering more of it?" — and
6
+ * all three need a base revision to answer it, so they share one git read:
7
+ *
8
+ * 1. `coverage-regression` Coverage the repo already accepted cannot be
9
+ * given back.
10
+ * 2. `obligation-uncovered` Behavior that is NEW here is mapped or waived.
11
+ * 3. `scenario-deleted` A retired behavior is `@superseded` with a
12
+ * record, never quietly deleted.
13
+ *
14
+ * These replaced a numeric ratchet on `coverageFloor` (a floor that could only
15
+ * rise, whose reduction needed a `coverageFloorBaseline` record plus a
16
+ * maintainer label). The number is still a gate — `floor-regression` still
17
+ * fails a platform sitting below its committed floor, and `floor-invalid`
18
+ * still refuses a floor written so it cannot be evaluated — but the floor no
19
+ * longer carries the non-regression duty, because a number is a bad instrument
20
+ * for it. A percentage can hold steady while a specific accepted behavior
21
+ * loses its automation and an easier one gains some; and keeping a number
22
+ * honest costs a recurring "nudge the value" pull request that proves nothing.
23
+ * Checks 1 and 2 are strictly stronger: they are per obligation, they cannot
24
+ * be satisfied by an offsetting gain elsewhere, and they shrink to zero as
25
+ * waivers retire instead of accumulating.
8
26
  *
9
27
  * @module scripts/bdd/baseline
10
28
  */
11
29
  import { spawnSync } from "node:child_process";
12
30
 
13
- import { scenarioIdsIn } from "./parse.mjs";
31
+ import { declaredPlatforms } from "./contract.mjs";
32
+ import { parseFeatureSource, scenarioIdsIn } from "./parse.mjs";
14
33
 
15
34
  const defect = (code, message) => ({ code, message });
16
35
 
17
- /** The maintainer-applied PR label that authorizes a floor reduction. */
36
+ /** The maintainer-applied PR label that authorizes giving coverage back. */
18
37
  export const BASELINE_LABEL = "bdd-floor-baseline";
19
38
 
20
39
  /**
@@ -53,12 +72,19 @@ function featureFilesAt(root, revision) {
53
72
  }
54
73
 
55
74
  /**
56
- * Load the base revision's contract and scenario IDs.
75
+ * Load the base revision's contract and its parsed scenarios.
76
+ *
77
+ * The base is parsed against the UNION of the base's and the head's platform
78
+ * vocabularies. Using head's alone would make a base scenario's `@web` read as
79
+ * an unknown tag the moment a pull request deleted the web runner from
80
+ * `runnerPlatforms` — which would turn "I deleted the runner that proved this"
81
+ * into an invisible change rather than the coverage loss it is.
57
82
  * @param {string} root - Repo root.
58
83
  * @param {string} revision - Base commit-ish.
59
- * @returns {{available: boolean, contract: object|null, scenarioIds: Set<string>}} Base state.
84
+ * @param {ReadonlySet<string>} headPlatforms - The head contract's platforms.
85
+ * @returns {{available: boolean, contract: object|null, scenarios: object[], scenarioIds: Set<string>}} Base state.
60
86
  */
61
- export function loadBaseline(root, revision) {
87
+ export function loadBaseline(root, revision, headPlatforms = new Set()) {
62
88
  const raw = showAtRevision(root, revision, "bdd/coverage-map.json");
63
89
  let contract = null;
64
90
  if (raw !== null) {
@@ -68,145 +94,211 @@ export function loadBaseline(root, revision) {
68
94
  contract = null;
69
95
  }
70
96
  }
71
- const sources = featureFilesAt(root, revision)
72
- .map(file => showAtRevision(root, revision, file))
73
- .filter(source => source !== null);
97
+ const documents = featureFilesAt(root, revision)
98
+ .map(file => ({ file, source: showAtRevision(root, revision, file) }))
99
+ .filter(document => document.source !== null);
100
+ const platforms = new Set([
101
+ ...declaredPlatforms(contract?.runnerPlatforms),
102
+ ...headPlatforms,
103
+ ]);
74
104
  return {
75
- available: raw !== null || sources.length > 0,
105
+ available: raw !== null || documents.length > 0,
76
106
  contract,
77
- scenarioIds: scenarioIdsIn(sources),
107
+ scenarios: documents.flatMap(document =>
108
+ parseFeatureSource(document.source, document.file, platforms)
109
+ ),
110
+ scenarioIds: scenarioIdsIn(documents.map(document => document.source)),
78
111
  };
79
112
  }
80
113
 
81
114
  /**
82
- * The coverage-floor ratchet: a floor may rise, and may never fall.
115
+ * Every `SCENARIO:platform` a contract's mappings actually claim.
83
116
  *
84
- * Lowering it takes TWO artifacts that one author cannot produce by editing
85
- * one file: a `coverageFloorBaseline` record naming the exact drop, its
86
- * reason, ticket, approver and authorizing run, AND the maintainer-applied
87
- * `bdd-floor-baseline` label on the pull request. Either alone fails.
88
- * @param {object} input - Base and head contracts plus the PR labels.
89
- * @returns {object[]} Defects found.
117
+ * Structural only it deliberately does NOT ask whether each mapping's
118
+ * evidence string still resolves. Evidence rot is already its own defect
119
+ * (`mapping-evidence`) and already drops the obligation out of the reported
120
+ * percentage; counting it here too would report one act twice and would make
121
+ * these checks depend on reading files out of a git revision.
122
+ * @param {readonly object[]} scenarios - Parsed scenarios for that revision.
123
+ * @param {object|null} contract - That revision's coverage map.
124
+ * @returns {Set<string>} Accepted keys.
90
125
  */
91
- export function checkRatchet({ baseContract, contract, labels }) {
92
- const before = baseContract?.coverageFloor ?? {};
93
- const after = contract.coverageFloor ?? {};
94
- // A head value that is not a finite number is treated as a REMOVAL, never
95
- // as "unchanged". Quoting a floor as "19" would otherwise make the
96
- // comparison `"19" < 19` false, recording no drop, while the report treats
97
- // the same value as no floor at all — a one-character, single-file edit
98
- // that disables enforcement and produces no defect. That is precisely what
99
- // the ratchet exists to prevent, so an unusable value fails closed.
100
- const drops = Object.keys(before)
101
- .filter(platform => isUsableFloor(before[platform]))
102
- .filter(
103
- platform =>
104
- !isUsableFloor(after[platform]) || after[platform] < before[platform]
105
- )
106
- .map(platform => ({
107
- platform,
108
- from: before[platform],
109
- to: isUsableFloor(after[platform]) ? after[platform] : null,
110
- malformed:
111
- after[platform] !== undefined && !isUsableFloor(after[platform]),
112
- }));
113
- return drops.flatMap(drop => ratchetDefects(drop, contract, labels));
126
+ export function acceptedKeys(scenarios, contract) {
127
+ const required = new Set(
128
+ scenarios.filter(scenario => scenario.required).map(scenario => scenario.id)
129
+ );
130
+ const keys = new Set();
131
+ for (const mapping of contract?.mappings ?? []) {
132
+ if (!required.has(mapping.scenario)) continue;
133
+ for (const platform of mapping.platforms ?? []) {
134
+ keys.add(`${mapping.scenario}:${platform}`);
135
+ }
136
+ }
137
+ return keys;
114
138
  }
115
139
 
116
140
  /**
117
- * Whether a raw `coverageFloor` entry is a usable floor.
118
- *
119
- * Mirrors `classifyFloor` in report.mjs; a unit test asserts the two agree,
120
- * because a disagreement is exactly the gap a quoted value slipped through.
121
- * @param {unknown} value - The raw entry.
122
- * @returns {boolean} True when it is a finite number in 0..100.
141
+ * Every `SCENARIO:platform` a revision OWES required scenarios expanded over
142
+ * the platforms they declare, less the ones a waiver removed.
143
+ * @param {readonly object[]} scenarios - Parsed scenarios for that revision.
144
+ * @param {object|null} contract - That revision's coverage map.
145
+ * @returns {Set<string>} Obligation keys.
146
+ */
147
+ export function obligationKeys(scenarios, contract) {
148
+ const waived = waivedKeys(contract);
149
+ const keys = new Set();
150
+ for (const scenario of scenarios) {
151
+ if (!scenario.required) continue;
152
+ for (const platform of scenario.platforms) {
153
+ const key = `${scenario.id}:${platform}`;
154
+ if (!waived.has(key)) keys.add(key);
155
+ }
156
+ }
157
+ return keys;
158
+ }
159
+
160
+ /**
161
+ * Every `SCENARIO:platform` a waiver removes from the denominator.
162
+ * @param {object|null} contract - A coverage map.
163
+ * @returns {Set<string>} Waived keys.
123
164
  */
124
- export function isUsableFloor(value) {
125
- return (
126
- typeof value === "number" &&
127
- Number.isFinite(value) &&
128
- value >= 0 &&
129
- value <= 100
165
+ function waivedKeys(contract) {
166
+ return new Set(
167
+ (contract?.platformWaivers ?? []).flatMap(waiver =>
168
+ (waiver.platforms ?? []).map(platform => `${waiver.scenario}:${platform}`)
169
+ )
130
170
  );
131
171
  }
132
172
 
133
173
  /**
134
- * Describe one observed reduction for the operator.
135
- * @param {object} drop - The observed reduction.
136
- * @returns {string} A one-line description.
174
+ * Split a `SCENARIO:platform` key back into its parts.
175
+ * @param {string} key - The key.
176
+ * @returns {{scenario: string, platform: string}} Its parts.
137
177
  */
138
- function describeDrop(drop) {
139
- if (drop.malformed) {
140
- return `coverageFloor.${drop.platform} is no longer a number (was ${drop.from}); a non-numeric floor disables enforcement, so it counts as a removal`;
141
- }
142
- return drop.to === null
143
- ? `coverageFloor.${drop.platform} was removed (was ${drop.from})`
144
- : `coverageFloor.${drop.platform} lowered ${drop.from} → ${drop.to}`;
178
+ function partsOf(key) {
179
+ const at = key.indexOf(":");
180
+ return { scenario: key.slice(0, at), platform: key.slice(at + 1) };
145
181
  }
146
182
 
147
183
  /**
148
- * Defects for one attempted floor reduction.
149
- * @param {object} drop - The observed reduction.
150
- * @param {object} contract - Head contract.
184
+ * Fields a retirement record owes whoever has to re-litigate it later.
185
+ * Shared with {@link checkDeletions} so the two routes out of the denominator
186
+ * deleting the scenario and un-mapping it — cannot demand different proof.
187
+ */
188
+ const RETIREMENT_FIELDS = ["reason", "ticket", "approvedBy", "recordedAt"];
189
+
190
+ /**
191
+ * Whether a scenario has been retired the way the contract requires: a
192
+ * complete `retirements` record AND the maintainer-applied label. Either alone
193
+ * is not an authorization — that is the whole point of asking for two
194
+ * artifacts one author cannot produce by editing one file.
195
+ * @param {object|undefined} record - The retirements entry, if any.
151
196
  * @param {readonly string[]} labels - PR labels.
197
+ * @returns {boolean} True when the retirement is authorized and complete.
198
+ */
199
+ function isRetired(record, labels) {
200
+ if (!record || !labels.includes(BASELINE_LABEL)) return false;
201
+ return RETIREMENT_FIELDS.every(field => Boolean(record[field]));
202
+ }
203
+
204
+ /**
205
+ * Coverage the repo already accepted cannot be given back.
206
+ *
207
+ * This is the invariant that replaced the floor ratchet, and it is deliberately
208
+ * blind to the percentage: an obligation that was mapped at the base revision
209
+ * and is not mapped here is a regression even when the headline number went UP
210
+ * because easier behavior was covered in the same change.
211
+ *
212
+ * Keys whose scenario disappeared from the contract entirely are left to
213
+ * {@link checkDeletions}, which names that act precisely; reporting both would
214
+ * describe one deletion as two different failures.
215
+ * @param {object} input - Baseline, head contract and scenarios, and PR labels.
152
216
  * @returns {object[]} Defects found.
153
217
  */
154
- function ratchetDefects(drop, contract, labels) {
155
- const what = describeDrop(drop);
156
- const record = (contract.coverageFloorBaseline ?? []).find(
157
- entry =>
158
- entry.platform === drop.platform &&
159
- entry.from === drop.from &&
160
- entry.to === drop.to
218
+ export function checkCoverageRegression({
219
+ baseline,
220
+ contract,
221
+ scenarios,
222
+ labels,
223
+ }) {
224
+ const before = acceptedKeys(baseline.scenarios, baseline.contract);
225
+ const after = acceptedKeys(scenarios, contract);
226
+ const present = new Set(scenarios.map(scenario => scenario.id));
227
+ const retired = new Map(
228
+ (contract.retirements ?? []).map(entry => [entry.scenario, entry])
161
229
  );
162
- const defects = [];
163
- if (!record) {
164
- defects.push(
165
- defect(
166
- "floor-ratchet",
167
- `${what}: the floor is a ratchet. A reduction needs a coverageFloorBaseline record naming this exact change (platform, from, to, reason, ticket, approvedBy, runUrl).`
168
- )
169
- );
170
- } else {
171
- defects.push(...baselineRecordDefects(record, what));
230
+ const waived = waivedKeys(contract);
231
+ return [...before]
232
+ .filter(key => !after.has(key) && present.has(partsOf(key).scenario))
233
+ .sort()
234
+ .flatMap(key => regressionDefect(key, { retired, waived, labels }));
235
+ }
236
+
237
+ /**
238
+ * The defect for one obligation whose accepted coverage disappeared, or none
239
+ * when it left the denominator through an authorized route.
240
+ * @param {string} key - The `SCENARIO:platform` key.
241
+ * @param {object} input - Retirement records, waived keys, and PR labels.
242
+ * @returns {object[]} Zero or one defect.
243
+ */
244
+ function regressionDefect(key, { retired, waived, labels }) {
245
+ const { scenario, platform } = partsOf(key);
246
+ if (isRetired(retired.get(scenario), labels)) return [];
247
+ if (waived.has(key) && labels.includes(BASELINE_LABEL)) return [];
248
+ return [
249
+ defect(
250
+ "coverage-regression",
251
+ `${scenario}:${platform} was covered at the base revision and is not covered here. Coverage this repository already accepted cannot be handed back quietly. Restore the mapping, or take one of the two recorded routes out — a retirements record (${RETIREMENT_FIELDS.join(", ")}) for a behavior the product no longer has, or a platformWaivers entry for a runner that cannot decide it — and get the maintainer-applied "${BASELINE_LABEL}" label on this pull request. ${routeTaken(key, { retired, waived, labels })}`
252
+ ),
253
+ ];
254
+ }
255
+
256
+ /**
257
+ * Say which half of the authorization is missing, so the operator is told what
258
+ * to do rather than only what went wrong.
259
+ * @param {string} key - The `SCENARIO:platform` key.
260
+ * @param {object} input - Retirement records, waived keys, labels, contract.
261
+ * @returns {string} A one-sentence next step.
262
+ */
263
+ function routeTaken(key, { retired, waived, labels }) {
264
+ const { scenario } = partsOf(key);
265
+ const record = retired.get(scenario);
266
+ const authorized = labels.includes(BASELINE_LABEL);
267
+ if ((record || waived.has(key)) && !authorized) {
268
+ return `A record is present but the "${BASELINE_LABEL}" label is not: recording a reduction in the same pull request that makes it is not an authorization.`;
172
269
  }
173
- if (!labels.includes(BASELINE_LABEL)) {
174
- defects.push(
175
- defect(
176
- "floor-ratchet",
177
- `${what}: requires the maintainer-applied "${BASELINE_LABEL}" label. Changing the floor in the same pull request that changes the code is not an authorization.`
178
- )
179
- );
270
+ if (record) {
271
+ const missing = RETIREMENT_FIELDS.filter(field => !record[field]);
272
+ return `Its retirements record is incomplete (no ${missing.join(", no ")}).`;
180
273
  }
181
- return defects;
274
+ return "Neither a retirements record nor a waiver was found for it.";
182
275
  }
183
276
 
184
277
  /**
185
- * Completeness of a baseline-update record. `runUrl` is validated for shape
186
- * only — the gate never contacts a tracker or CI API, so a merge can never
187
- * depend on one being reachable.
188
- * @param {object} record - The coverageFloorBaseline entry.
189
- * @param {string} what - Human description of the drop.
278
+ * Behavior that is NEW here is mapped or waived.
279
+ *
280
+ * The companion to {@link checkCoverageRegression}: without it a repository
281
+ * could hold every accepted obligation and still let the product outrun its
282
+ * contract, one unmapped scenario at a time. Pre-existing gaps are untouched
283
+ * an obligation that was already owed at the base revision is burndown, not a
284
+ * regression — which is what lets a brownfield project adopt `enforced`
285
+ * without first backfilling its whole history.
286
+ * @param {object} input - Baseline plus the head contract and scenarios.
190
287
  * @returns {object[]} Defects found.
191
288
  */
192
- function baselineRecordDefects(record, what) {
193
- const defects = ["reason", "ticket", "approvedBy", "runUrl", "recordedAt"]
194
- .filter(field => !record[field])
195
- .map(field =>
196
- defect(
197
- "floor-ratchet",
198
- `${what}: coverageFloorBaseline record has no ${field}`
199
- )
200
- );
201
- if (record.runUrl && !/^https:\/\/\S+$/.test(String(record.runUrl))) {
202
- defects.push(
203
- defect(
204
- "floor-ratchet",
205
- `${what}: coverageFloorBaseline.runUrl must be an https URL`
206
- )
207
- );
208
- }
209
- return defects;
289
+ export function checkNewObligations({ baseline, contract, scenarios }) {
290
+ const before = obligationKeys(baseline.scenarios, baseline.contract);
291
+ const covered = acceptedKeys(scenarios, contract);
292
+ return [...obligationKeys(scenarios, contract)]
293
+ .filter(key => !before.has(key) && !covered.has(key))
294
+ .sort()
295
+ .map(key => {
296
+ const { scenario, platform } = partsOf(key);
297
+ return defect(
298
+ "obligation-uncovered",
299
+ `${scenario}:${platform} is new here and nothing covers it. New behavior arrives mapped to an automated test or waived with a dated, owned platformWaivers entry — the contract does not accept a third answer. (Behavior that was already uncovered before this change is burndown, not a defect, and is listed in the report's gaps.)`
300
+ );
301
+ });
210
302
  }
211
303
 
212
304
  /**
@@ -245,9 +337,7 @@ function deletionDefects(id, record, labels) {
245
337
  ),
246
338
  ];
247
339
  }
248
- const missing = ["reason", "ticket", "approvedBy", "recordedAt"].filter(
249
- field => !record[field]
250
- );
340
+ const missing = RETIREMENT_FIELDS.filter(field => !record[field]);
251
341
  const defects = missing.map(field =>
252
342
  defect("scenario-deleted", `${id}: retirements record has no ${field}`)
253
343
  );
@@ -9,8 +9,16 @@
9
9
  * @module scripts/bdd/contract
10
10
  */
11
11
 
12
- /** Report/envelope schema emitted by the gate. See docs/bdd-coverage-schema.md. */
13
- export const REPORT_SCHEMA_VERSION = 2;
12
+ /**
13
+ * Report/envelope schema emitted by the gate. See docs/bdd-coverage-schema.md.
14
+ *
15
+ * v3 retired the `floor-ratchet` defect code — defect codes are API, and the
16
+ * compatibility policy retires one only with a bump. The report's own fields
17
+ * are unchanged, and `coverage-map.schemaVersion` is untouched: a v2 map still
18
+ * reads exactly as before, its now-unused `coverageFloorBaseline` simply
19
+ * ignored.
20
+ */
21
+ export const REPORT_SCHEMA_VERSION = 3;
14
22
 
15
23
  /** Coverage-map schema versions this gate can read. */
16
24
  export const SUPPORTED_MAP_SCHEMA_VERSIONS = [1, 2];
@@ -42,17 +42,17 @@ const ENVELOPE_MODULE_PATHS = Object.freeze([
42
42
  * adoption-drift, config-*, discovery-invalid) are deliberately absent, so
43
43
  * they always fail: a malformed discovery block would silently switch off the
44
44
  * only check that can see an undeclared test, exactly as a quoted coverage
45
- * floor silently switches off the ratchet.
45
+ * floor silently switches off the bar (`floor-invalid`, likewise absent).
46
46
  */
47
47
  export const WARNABLE_DEFECT_CODES = Object.freeze([
48
48
  "baseline",
49
+ "coverage-regression",
49
50
  "discovery-missing",
50
51
  "empty-contract",
51
52
  "exclusion-metadata",
52
53
  "exclusion-stale",
53
54
  "execution-results",
54
55
  "floor-missing",
55
- "floor-ratchet",
56
56
  "floor-regression",
57
57
  "mapping-duplicate",
58
58
  "mapping-evidence",
@@ -60,6 +60,7 @@ export const WARNABLE_DEFECT_CODES = Object.freeze([
60
60
  "mapping-orphan",
61
61
  "mapping-platform",
62
62
  "mapping-runner",
63
+ "obligation-uncovered",
63
64
  "scenario-deleted",
64
65
  "scenario-duplicate-id",
65
66
  "scenario-id",
@@ -215,9 +215,9 @@ ${platformTable(report)}
215
215
 
216
216
  ${executionSection(report)}
217
217
 
218
- ## Coverage floor (ratchet)
218
+ ## Coverage floor
219
219
 
220
- The committed floor may rise and may never fall. Lowering it requires a \`coverageFloorBaseline\` record naming the exact change plus the maintainer-applied \`bdd-floor-baseline\` label — two artifacts one author cannot produce alone.
220
+ The committed floor is an absolute bar — "is this platform below it right now" — and nothing else. It is **not** a ratchet and nobody has to nudge it upward: what protects coverage already earned is a separate, per-obligation check. Coverage accepted at the base revision cannot disappear, and behavior that is new must arrive mapped or waived; either reduction takes a recorded route (a \`retirements\` record or a \`platformWaivers\` entry) plus the maintainer-applied \`bdd-floor-baseline\` label — two artifacts one author cannot produce alone.
221
221
 
222
222
  ${floorTable(report)}
223
223
 
@@ -17,10 +17,18 @@
17
17
  * passed expiry is a failure, so bootstrap cannot become
18
18
  * permanent.
19
19
  * enforced Absence fails. A missing config, a malformed manifest, zero
20
- * scenarios, zero mappings, any contract defect, a floor
21
- * regression, or a deleted scenario all fail loudly. Only in
20
+ * scenarios, zero mappings, any contract defect, a platform
21
+ * below its committed floor, coverage given back, new behavior
22
+ * nobody mapped or waived, a deleted scenario, or a run with no
23
+ * base revision to compare against all fail loudly. Only in
22
24
  * this state is the check a required ruleset context.
23
25
  *
26
+ * The committed `coverageFloor` is an ABSOLUTE BAR, not a ratchet: it answers
27
+ * "is this platform below the bar right now", and nothing stops a project
28
+ * lowering it. Non-regression is a separate, deterministic job done per
29
+ * obligation in `bdd/baseline.mjs` — see that module for why a number was the
30
+ * wrong instrument for it.
31
+ *
24
32
  * A required context is NEVER auto-skipped: GitHub counts a skipped required
25
33
  * check as passing, which is the exact anti-pattern this gate exists to
26
34
  * avoid.
@@ -49,7 +57,12 @@ import {
49
57
  loadEnvelopeModule,
50
58
  subjectFor,
51
59
  } from "./bdd/envelope.mjs";
52
- import { checkDeletions, checkRatchet, loadBaseline } from "./bdd/baseline.mjs";
60
+ import {
61
+ checkCoverageRegression,
62
+ checkDeletions,
63
+ checkNewObligations,
64
+ loadBaseline,
65
+ } from "./bdd/baseline.mjs";
53
66
  import {
54
67
  disclosureDefects,
55
68
  discoverSpecs,
@@ -258,24 +271,26 @@ function validateAll({
258
271
  ...discovery.defects,
259
272
  ...disclosureDefects({ root, contract, discovery }),
260
273
  ];
261
- if (!options.baseSha) return defects;
262
- const baseline = loadBaseline(root, options.baseSha);
274
+ if (!options.baseSha) return [...defects, ...missingBaseDefects(options)];
275
+ const baseline = loadBaseline(root, options.baseSha, platforms);
263
276
  if (!baseline.available) {
264
277
  return [
265
278
  ...defects,
266
279
  defect(
267
280
  "baseline",
268
- `base revision ${options.baseSha} is not readable; the ratchet and deletion checks could not run`
281
+ `base revision ${options.baseSha} is not readable, so the non-regression checks could not run. A gate that cannot compare against a base does not get to report that nothing regressed.`
269
282
  ),
270
283
  ];
271
284
  }
272
285
  return [
273
286
  ...defects,
274
- ...checkRatchet({
275
- baseContract: baseline.contract,
287
+ ...checkCoverageRegression({
288
+ baseline,
276
289
  contract,
290
+ scenarios,
277
291
  labels: options.labels,
278
292
  }),
293
+ ...checkNewObligations({ baseline, contract, scenarios }),
279
294
  ...checkDeletions({
280
295
  baseIds: baseline.scenarioIds,
281
296
  scenarios,
@@ -285,6 +300,28 @@ function validateAll({
285
300
  ];
286
301
  }
287
302
 
303
+ /**
304
+ * Enforced mode owes a base revision.
305
+ *
306
+ * Non-regression is the whole of what protects accepted coverage now that the
307
+ * floor is a plain bar rather than a ratchet, and every one of those checks
308
+ * needs a base. Running without one used to skip them in silence, which is a
309
+ * gate reporting a property it never evaluated. Bootstrap stays quiet — it is
310
+ * non-blocking by construction — and a local run outside CI is not making a
311
+ * merge decision, so neither is asked for a base it does not have.
312
+ * @param {object} options - Parsed CLI/environment options.
313
+ * @returns {object[]} Zero or one defect.
314
+ */
315
+ function missingBaseDefects(options) {
316
+ if (options.mode !== "enforced") return [];
317
+ return [
318
+ defect(
319
+ "baseline",
320
+ "enforced mode requires BDD_BASE_SHA: without a base revision the gate cannot tell coverage that was given back from coverage that was never there, so it refuses to claim either."
321
+ ),
322
+ ];
323
+ }
324
+
288
325
  /**
289
326
  * A malformed coverage floor, in EVERY adopted state.
290
327
  *