@codyswann/lisa 3.69.5 → 3.70.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 (70) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +365 -45
  2. package/all/create-only/.github/workflows/continuous-gates.yml +74 -0
  3. package/cdk/create-only/.github/workflows/deploy.yml +41 -1
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +20 -12
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/expo/copy-overwrite/scripts/bdd/contract.mjs +41 -2
  11. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +18 -68
  12. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +135 -206
  13. package/expo/create-only/.github/workflows/deploy.yml +42 -1
  14. package/expo/create-only/bdd/coverage-map.json +1 -7
  15. package/harper-fabric/create-only/.github/workflows/deploy.yml +37 -0
  16. package/nestjs/create-only/.github/workflows/deploy.yml +44 -1
  17. package/package.json +1 -1
  18. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-agy/plugin.json +1 -1
  31. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  51. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-agy/plugin.json +1 -1
  56. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  61. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  66. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  68. package/rails/create-only/.github/workflows/deploy.yml +41 -0
  69. package/scripts/check-learnings-budget.ts +93 -18
  70. package/typescript/github-rulesets/quality-checks.json +4 -0
@@ -11,20 +11,21 @@
11
11
  * returned, and what is WAIVED. Traceability coverage is not execution
12
12
  * coverage and is never a pass rate.
13
13
  *
14
- * THREE-STATE ADOPTION (`BDD_MODE`, supplied by CI, never inferred):
14
+ * ONE ADOPTION CONTROL, and it is not this file's. Whether the property is
15
+ * governed at all is decided by the gate declaration — `required`, `optional`,
16
+ * or `off` — the same declaration every other quality job answers to. This gate
17
+ * used to carry a second, private axis (`BDD_MODE`, plus an `adoption` block in
18
+ * the coverage map) that could disagree with it, and the losing control lost
19
+ * silently. Both are retired; `BDD_MODE` is now refused rather than read.
15
20
  *
16
- * not-adopted The contract is not required. The gate reports and exits 0,
17
- * and the check MUST NOT be a required ruleset context.
18
- * bootstrap A visible, non-blocking check carrying a named owner and a
19
- * hard expiry. Contract defects are warnings; a missing or
20
- * passed expiry is a failure, so bootstrap cannot become
21
- * permanent.
22
- * enforced Absence fails. A missing config, a malformed manifest, zero
23
- * scenarios, zero mappings, any contract defect, a platform
24
- * below its committed floor, coverage given back, new behavior
25
- * nobody mapped or waived, a deleted scenario, or a run with no
26
- * base revision to compare against all fail loudly. Only in
27
- * this state is the check a required ruleset context.
21
+ * So the prover has exactly one behaviour: it proves. Absence fails. A missing
22
+ * config, a malformed manifest, zero scenarios, zero mappings, any contract
23
+ * defect, a platform below its committed floor, coverage given back, new
24
+ * behavior nobody mapped or waived, a deleted scenario, or a run with no base
25
+ * revision to compare against all fail loudly, and there are no warnings — a
26
+ * defect graded amber is a defect nobody fixes. A project that does not want
27
+ * that declares the gate `off`, which is a decision a reader can find in the
28
+ * settings file, rather than a green check that found five hundred defects.
28
29
  *
29
30
  * The committed `coverageFloor` is an ABSOLUTE BAR, not a ratchet: it answers
30
31
  * "is this platform below the bar right now", and nothing stops a project
@@ -46,13 +47,13 @@ import * as path from "node:path";
46
47
  import { fileURLToPath } from "node:url";
47
48
 
48
49
  import {
49
- ADOPTION_STATES,
50
+ GATE_LEVELS,
51
+ RETIRED_ADOPTION_STATES,
50
52
  SUPPORTED_MAP_SCHEMA_VERSIONS,
51
53
  declaredPlatforms,
52
54
  } from "./bdd/contract.mjs";
53
55
  import {
54
56
  SUCCESS_STATUSES,
55
- WARNABLE_DEFECT_CODES,
56
57
  buildSummary,
57
58
  contractVersion,
58
59
  correlationId,
@@ -80,7 +81,7 @@ import {
80
81
  validateScenarios,
81
82
  validateTrackerTags,
82
83
  } from "./bdd/validate.mjs";
83
- import { ISO_DATE, validateWaivers } from "./bdd/waivers.mjs";
84
+ import { validateWaivers } from "./bdd/waivers.mjs";
84
85
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
85
86
 
86
87
  const PACKAGE_ROOT = path.resolve(
@@ -90,33 +91,44 @@ const PACKAGE_ROOT = path.resolve(
90
91
  const MAP_REL = "bdd/coverage-map.json";
91
92
  const defect = (code, message) => ({ code, message });
92
93
 
93
- /** Defect / mode code, named once. */
94
- const NOT_ADOPTED = "not-adopted";
95
-
96
- /** Defect / mode code, named once. */
97
- const BOOTSTRAP_METADATA = "bootstrap-metadata";
98
-
99
- /** Defect / mode code, named once. */
94
+ /** Defect code, named once. */
100
95
  const EXECUTION_RESULTS = "execution-results";
101
96
 
102
- /** Defect / mode code, named once. */
97
+ /** Defect code, named once. */
103
98
  const EMPTY_CONTRACT = "empty-contract";
104
99
 
100
+ /** Defect code, named once. */
101
+ const ADOPTION_RETIRED = "adoption-retired";
102
+
103
+ /** How every retirement message ends, so all of them name the same remedy. */
104
+ const DECLARE_INSTEAD =
105
+ `Declare this gate in .lisa.config.json at one of ${GATE_LEVELS.join(", ")}` +
106
+ " — that declaration is now the only control over whether the property is" +
107
+ " governed.";
108
+
105
109
  /**
106
- * Resolve the adoption state from the environment.
110
+ * Refuse a `BDD_MODE` the gate no longer reads.
111
+ *
112
+ * `BDD_MODE` was a second adoption control alongside the gate declaration, and
113
+ * it is retired. Ignoring a value someone deliberately set would be the worst
114
+ * of the three options: the setting would keep looking like configuration while
115
+ * deciding nothing, which is the exact failure the collapse was done to remove.
116
+ *
117
+ * The refusal names the value. A retired one gets its own sentence saying what
118
+ * it was and why it went, because telling the author of `bootstrap` to check
119
+ * for a typo sends them hunting a mistake they did not make.
107
120
  * @param {Record<string, string|undefined>} env - Process environment.
108
- * @returns {{mode: string, error: string|null}} The resolved mode.
121
+ * @returns {{error: string|null}} The refusal, when there is one.
109
122
  */
110
- export function resolveMode(env) {
123
+ export function refuseRetiredMode(env) {
111
124
  const raw = (env.BDD_MODE ?? "").trim();
112
- if (raw === "") return { mode: NOT_ADOPTED, error: null };
113
- if (!ADOPTION_STATES.includes(raw)) {
114
- return {
115
- mode: NOT_ADOPTED,
116
- error: `BDD_MODE="${raw}" is not one of ${ADOPTION_STATES.join(", ")}`,
117
- };
118
- }
119
- return { mode: raw, error: null };
125
+ if (raw === "") return { error: null };
126
+ const known = RETIRED_ADOPTION_STATES[raw];
127
+ return {
128
+ error: known
129
+ ? `BDD_MODE="${raw}" is ${known}`
130
+ : `BDD_MODE is retired and "${raw}" was never one of its values. ${DECLARE_INSTEAD}`,
131
+ };
120
132
  }
121
133
 
122
134
  /**
@@ -152,90 +164,36 @@ export function loadContract(root) {
152
164
  }
153
165
 
154
166
  /**
155
- * Validate the manifest's own adoption block against the mode CI declared.
167
+ * Refuse an `adoption` block the gate no longer reads.
168
+ *
169
+ * The block mirrored `BDD_MODE` inside the manifest, and `adoption-drift`
170
+ * existed to catch the two disagreeing. With one control left there is nothing
171
+ * for it to mirror, so the block is dead configuration — and dead configuration
172
+ * that still reads like a switch is how a project believes it declared
173
+ * something it did not.
156
174
  *
157
- * The two must agree: CI is the authority (it survives deletion of `bdd/`),
158
- * and the manifest is the self-describing record. A disagreement means an
159
- * adoption was half-performed, which is exactly the state that produces a
160
- * required check nobody is actually running.
175
+ * Refused rather than ignored, and refused whatever it says: a stale
176
+ * `"state": "enforced"` is exactly as misleading as a stale `"bootstrap"`, and
177
+ * only deleting it makes the settings file true. The message names the value so
178
+ * the author of a `bootstrap` block is told what happened to it rather than
179
+ * left to guess.
161
180
  * @param {object} contract - Parsed coverage map.
162
- * @param {string} mode - Mode declared by CI.
163
- * @param {string} today - ISO date to evaluate the expiry against.
164
- * @returns {object[]} Defects found.
165
- */
166
- export function validateAdoption(contract, mode, today) {
167
- const adoption = contract.adoption ?? {};
168
- const defects = [];
169
- if (adoption.state && adoption.state !== mode) {
170
- defects.push(
171
- defect(
172
- "adoption-drift",
173
- `bdd/coverage-map.json declares adoption.state "${adoption.state}" but CI passed BDD_MODE "${mode}". Adoption is one operation: change both, and the ruleset context, together.`
174
- )
175
- );
176
- }
177
- if (mode === "enforced" && !adoption.state) {
178
- defects.push(
179
- defect(
180
- "adoption-drift",
181
- `enforced mode requires adoption.state "enforced" in ${MAP_REL}`
182
- )
183
- );
184
- }
185
- if (mode !== "bootstrap") return defects;
186
- return [...defects, ...bootstrapDefects(adoption, today)];
187
- }
188
-
189
- /**
190
- * Bootstrap owes a named owner and a hard expiry, and dies at that expiry.
191
- * @param {object} adoption - The manifest's adoption block.
192
- * @param {string} today - ISO date to evaluate against.
193
181
  * @returns {object[]} Defects found.
194
182
  */
195
- function bootstrapDefects(adoption, today) {
196
- const defects = [];
197
- if (!ISO_DATE.test(String(today))) {
198
- // The expiry is a lexical comparison against an ISO date, and every
199
- // comparison with a non-date is false — so an unreadable evaluation date
200
- // would make the time-box unreachable and bootstrap permanent.
201
- defects.push(
202
- defect(
203
- BOOTSTRAP_METADATA,
204
- `the evaluation date ${JSON.stringify(today ?? null)} is not an ISO date (YYYY-MM-DD), so the bootstrap expiry could not be evaluated`
205
- )
206
- );
207
- }
208
- if (!adoption.owner) {
209
- defects.push(
210
- defect(
211
- BOOTSTRAP_METADATA,
212
- "bootstrap requires adoption.owner (a named person, not a team)"
213
- )
214
- );
215
- }
216
- if (!adoption.expiresAt) {
217
- defects.push(
218
- defect(
219
- BOOTSTRAP_METADATA,
220
- "bootstrap requires adoption.expiresAt (an ISO date); a bootstrap with no time-box never ends"
221
- )
222
- );
223
- } else if (!ISO_DATE.test(adoption.expiresAt)) {
224
- defects.push(
225
- defect(
226
- BOOTSTRAP_METADATA,
227
- "adoption.expiresAt must be an ISO date (YYYY-MM-DD)"
228
- )
229
- );
230
- } else if (ISO_DATE.test(String(today)) && adoption.expiresAt < today) {
231
- defects.push(
232
- defect(
233
- "bootstrap-expired",
234
- `the BDD bootstrap expired on ${adoption.expiresAt} (owner: ${adoption.owner ?? "unnamed"}). Advance to enforced or re-authorize the time-box.`
235
- )
236
- );
237
- }
238
- return defects;
183
+ export function retiredAdoptionDefects(contract) {
184
+ const adoption = contract.adoption;
185
+ if (adoption === undefined) return [];
186
+ const state = typeof adoption?.state === "string" ? adoption.state : null;
187
+ const known = state ? RETIRED_ADOPTION_STATES[state] : null;
188
+ const preamble = known
189
+ ? `${MAP_REL} declares adoption.state "${state}", which is ${known}`
190
+ : `${MAP_REL} carries an "adoption" block, which is retired: the gate no longer reads it`;
191
+ return [
192
+ defect(
193
+ ADOPTION_RETIRED,
194
+ `${preamble} Delete the block. ${DECLARE_INSTEAD}`
195
+ ),
196
+ ];
239
197
  }
240
198
 
241
199
  /**
@@ -295,7 +253,7 @@ function validateAll({
295
253
  ...discovery.defects,
296
254
  ...disclosureDefects({ root, contract, discovery }),
297
255
  ];
298
- if (!options.baseSha) return [...defects, ...missingBaseDefects(options)];
256
+ if (!options.baseSha) return [...defects, MISSING_BASE_DEFECT];
299
257
  const baseline = loadBaseline(root, options.baseSha, platforms);
300
258
  if (!baseline.available) {
301
259
  return [
@@ -323,34 +281,29 @@ function validateAll({
323
281
  }
324
282
 
325
283
  /**
326
- * Enforced mode owes a base revision.
284
+ * What a run with no base revision reports.
327
285
  *
328
286
  * Non-regression is the whole of what protects accepted coverage now that the
329
287
  * floor is a plain bar rather than a ratchet, and every one of those checks
330
288
  * needs a base. Running without one used to skip them in silence, which is a
331
- * gate reporting a property it never evaluated. Bootstrap stays quiet — it is
332
- * non-blocking by construction — and a local run outside CI is not making a
333
- * merge decision, so neither is asked for a base it does not have.
334
- * @param {object} options - Parsed CLI/environment options.
335
- * @returns {object[]} Zero or one defect.
289
+ * gate reporting a property it never evaluated.
290
+ *
291
+ * Unconditional, and therefore a constant rather than a function. The exemption
292
+ * used to be spelled "not in enforced mode", and with the mode axis retired
293
+ * that phrase has no referent: a run either proves non-regression or admits it
294
+ * could not.
336
295
  */
337
- function missingBaseDefects(options) {
338
- if (options.mode !== "enforced") return [];
339
- return [
340
- defect(
341
- "baseline",
342
- "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."
343
- ),
344
- ];
345
- }
296
+ const MISSING_BASE_DEFECT = defect(
297
+ "baseline",
298
+ "BDD_BASE_SHA is required: 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."
299
+ );
346
300
 
347
301
  /**
348
- * A malformed coverage floor, in EVERY adopted state.
302
+ * A malformed coverage floor.
349
303
  *
350
- * This is config integrity, not contract quality, so bootstrap does not
351
- * downgrade it: a floor written as `"19"` rather than `19` disables the
352
- * ratchet AND removes the platform from enforcement, in one character, in
353
- * one file, with no other signal. It is refused rather than ignored.
304
+ * A floor written as `"19"` rather than `19` disables the ratchet AND removes
305
+ * the platform from enforcement, in one character, in one file, with no other
306
+ * signal. It is refused rather than ignored.
354
307
  * @param {object} report - The built report.
355
308
  * @returns {object[]} Defects found.
356
309
  */
@@ -364,7 +317,11 @@ function floorIntegrityDefects(report) {
364
317
  }
365
318
 
366
319
  /**
367
- * Defects that only exist in enforced mode, where absence must fail.
320
+ * Absence, which must fail.
321
+ *
322
+ * These used to be skipped outside `enforced` — the largest of the carve-outs
323
+ * the adoption axis bought, and the one that made a non-enforced run report a
324
+ * property it had not evaluated. There is one mode now, so they always run.
368
325
  *
369
326
  * The platform vocabulary is deliberately NOT a parameter: every platform this
370
327
  * function cares about already reaches it through `report.floor`, which was
@@ -374,21 +331,18 @@ function floorIntegrityDefects(report) {
374
331
  * @param {object} input - Contract, scenarios, report, and discovery.
375
332
  * @returns {object[]} Defects found.
376
333
  */
377
- function enforcedDefects({ contract, scenarios, report, discovery }) {
334
+ function completenessDefects({ contract, scenarios, report, discovery }) {
378
335
  const defects = [];
379
336
  if (scenarios.length === 0) {
380
337
  defects.push(
381
- defect(
382
- EMPTY_CONTRACT,
383
- "enforced mode: bdd/features declares zero scenarios"
384
- )
338
+ defect(EMPTY_CONTRACT, "bdd/features declares zero scenarios")
385
339
  );
386
340
  }
387
341
  if ((contract.mappings ?? []).length === 0) {
388
342
  defects.push(
389
343
  defect(
390
344
  EMPTY_CONTRACT,
391
- "enforced mode: bdd/coverage-map.json declares zero test mappings"
345
+ "bdd/coverage-map.json declares zero test mappings"
392
346
  )
393
347
  );
394
348
  }
@@ -396,7 +350,7 @@ function enforcedDefects({ contract, scenarios, report, discovery }) {
396
350
  defects.push(
397
351
  defect(
398
352
  EMPTY_CONTRACT,
399
- "enforced mode: bdd/coverage-map.json declares no runnerPlatforms"
353
+ "bdd/coverage-map.json declares no runnerPlatforms"
400
354
  )
401
355
  );
402
356
  }
@@ -405,7 +359,7 @@ function enforcedDefects({ contract, scenarios, report, discovery }) {
405
359
  defects.push(
406
360
  defect(
407
361
  "floor-missing",
408
- `enforced mode: no coverageFloor declared for platform ${platform}`
362
+ `no coverageFloor declared for platform ${platform}`
409
363
  )
410
364
  );
411
365
  }
@@ -443,14 +397,9 @@ function measured(value) {
443
397
  * @returns {object} The result envelope.
444
398
  */
445
399
  export function run(root, options) {
446
- const { mode } = options;
447
400
  const loaded = loadContract(root);
448
- const fatal = configFatals(loaded, mode);
449
- if (fatal)
450
- return result({ mode, defects: [fatal], report: null, contract: null });
451
- if (!loaded.present) {
452
- return result({ mode, defects: [], report: null, contract: null });
453
- }
401
+ const fatal = configFatals(loaded);
402
+ if (fatal) return result({ defects: [fatal], report: null, contract: null });
454
403
  const contract = loaded.contract;
455
404
  const versionDefect = schemaDefect(contract);
456
405
  const platforms = declaredPlatforms(contract.runnerPlatforms);
@@ -474,7 +423,7 @@ export function run(root, options) {
474
423
  });
475
424
  const defects = [
476
425
  ...(versionDefect ? [versionDefect] : []),
477
- ...validateAdoption(contract, mode, options.today),
426
+ ...retiredAdoptionDefects(contract),
478
427
  ...execution.defects,
479
428
  ...floorIntegrityDefects(report),
480
429
  ...validateAll({
@@ -486,25 +435,27 @@ export function run(root, options) {
486
435
  cache,
487
436
  discovery,
488
437
  }),
489
- ...(mode === "enforced"
490
- ? enforcedDefects({ contract, scenarios, report, discovery })
491
- : []),
438
+ ...completenessDefects({ contract, scenarios, report, discovery }),
492
439
  ];
493
- return result({ mode, defects, report, contract });
440
+ return result({ defects, report, contract });
494
441
  }
495
442
 
496
443
  /**
497
444
  * Configuration problems that stop the gate before it can evaluate anything.
445
+ *
446
+ * A missing manifest is one of them. It used to be tolerated outside
447
+ * `enforced`, which meant the commonest way to switch this gate off was to
448
+ * delete the file it reads — an absence that looks identical to an oversight.
449
+ * The way to switch it off is to declare it `off`.
498
450
  * @param {object} loaded - Result of {@link loadContract}.
499
- * @param {string} mode - Adoption state.
500
451
  * @returns {object|null} The fatal defect, or null.
501
452
  */
502
- function configFatals(loaded, mode) {
453
+ function configFatals(loaded) {
503
454
  if (loaded.error) return defect("config-malformed", loaded.error);
504
- if (loaded.present || mode === NOT_ADOPTED) return null;
455
+ if (loaded.present) return null;
505
456
  return defect(
506
457
  "config-absent",
507
- `${mode} mode requires ${MAP_REL}, which does not exist. In ${mode} mode absence is a failure, never a skip.`
458
+ `${MAP_REL} does not exist, and its absence is a failure, never a skip. ${DECLARE_INSTEAD}`
508
459
  );
509
460
  }
510
461
 
@@ -535,27 +486,19 @@ const INVALID_CODES = Object.freeze([
535
486
  ]);
536
487
 
537
488
  /**
538
- * Assemble the gate's internal result, with each defect's severity resolved.
489
+ * Assemble the gate's internal result.
539
490
  *
540
- * Severity is decided here, once, from the adoption state and the warnable
541
- * allowlist, so the human output, the envelope findings and the exit code can
542
- * never disagree about whether something was a warning.
543
- * @param {object} input - Mode, defects, report, and the parsed contract.
491
+ * No severity is resolved here any more, because there is no longer more than
492
+ * one. A defect fails the run; the exit code, the human output and the envelope
493
+ * findings therefore cannot disagree about whether something counted, which is
494
+ * the property the grading step existed to preserve and the reason it can go.
495
+ * @param {object} input - Defects, report, and the parsed contract.
544
496
  * @returns {object} The internal result.
545
497
  */
546
- function result({ mode, defects, report, contract }) {
547
- const fatal = hasFatalDefect(mode, defects);
548
- const graded = defects.map(item => ({
549
- ...item,
550
- severity:
551
- mode === "enforced" || !WARNABLE_DEFECT_CODES.includes(item.code)
552
- ? "error"
553
- : "warning",
554
- }));
498
+ function result({ defects, report, contract }) {
555
499
  return {
556
- adoptionState: mode,
557
- status: statusFor({ mode, defects: graded, fatal, report }),
558
- defects: graded,
500
+ status: statusFor({ defects, fatal: hasFatalDefect(defects), report }),
501
+ defects,
559
502
  report,
560
503
  contract,
561
504
  };
@@ -563,14 +506,13 @@ function result({ mode, defects, report, contract }) {
563
506
 
564
507
  /**
565
508
  * Map the run onto the standard envelope's status vocabulary.
566
- * @param {object} input - Mode, graded defects, fatality, and the report.
509
+ * @param {object} input - Defects, fatality, and the report.
567
510
  * @returns {string} An envelope status.
568
511
  */
569
- function statusFor({ mode, defects, fatal, report }) {
512
+ function statusFor({ defects, fatal, report }) {
570
513
  if (defects.some(item => INVALID_CODES.includes(item.code))) return "invalid";
571
514
  if (fatal) return "failed";
572
- if (mode === NOT_ADOPTED && !report) return NOT_ADOPTED;
573
- return "completed";
515
+ return report ? "completed" : "no-op";
574
516
  }
575
517
 
576
518
  /**
@@ -579,7 +521,7 @@ function statusFor({ mode, defects, fatal, report }) {
579
521
  * @returns {string} Summary line.
580
522
  */
581
523
  function summaryLine(run) {
582
- const head = `bdd-coverage ${run.adoptionState}: ${run.status}`;
524
+ const head = `bdd-coverage: ${run.status}`;
583
525
  if (!run.report) return `${head} (${run.defects.length} findings)`;
584
526
  const trace = run.report.traceability.overall;
585
527
  const execution = run.report.execution.supplied
@@ -594,19 +536,19 @@ function summaryLine(run) {
594
536
  * @returns {string} The reason.
595
537
  */
596
538
  function reasonFor(run) {
597
- const first = run.defects.find(item => item.severity === "error");
539
+ const first = run.defects[0];
598
540
  return first
599
541
  ? `${first.code}: ${first.message}`
600
- : `bdd-coverage ${run.adoptionState} did not complete`;
542
+ : "bdd-coverage did not complete";
601
543
  }
602
544
 
603
545
  /**
604
546
  * Convert the internal result into Lisa's standard command envelope.
605
547
  *
606
548
  * `mode` is the ENVELOPE's mode — the gate really runs, so it is always
607
- * `real`. The BDD adoption state is a different axis and rides in
608
- * `summary.adoptionState`, with `status: "not-adopted"` carrying it for a
609
- * repo that has not wired the contract.
549
+ * `real`. There is no second axis to carry: whether the property is governed is
550
+ * the gate declaration's answer, and it is decided before this gate is invoked
551
+ * at all.
610
552
  * @param {object} input - The result, the environment, and CLI options.
611
553
  * @returns {object} An envelope conforming to lisa-command-envelope.v1.
612
554
  */
@@ -625,13 +567,11 @@ export function toCommandEnvelope({
625
567
  dryRun: !options.write,
626
568
  status: gateRun.status,
627
569
  correlationId: correlationId(env.BDD_CORRELATION_ID, {
628
- adoptionState: gateRun.adoptionState,
629
570
  status: gateRun.status,
630
571
  summary: summaryLine(gateRun),
631
572
  }),
632
573
  summary: {
633
574
  ...buildSummary({
634
- adoptionState: gateRun.adoptionState,
635
575
  report: gateRun.report,
636
576
  defects: gateRun.defects,
637
577
  filesWritten,
@@ -642,7 +582,6 @@ export function toCommandEnvelope({
642
582
  code: item.code,
643
583
  subject: subjectFor(item),
644
584
  message: item.message,
645
- severity: item.severity,
646
585
  })),
647
586
  };
648
587
  return SUCCESS_STATUSES.includes(gateRun.status)
@@ -690,16 +629,13 @@ export function parseArgs(argv, env) {
690
629
  */
691
630
  async function main() {
692
631
  const root = process.env.BDD_COVERAGE_ROOT || PACKAGE_ROOT;
693
- const resolved = resolveMode(process.env);
694
- if (resolved.error) {
695
- console.error(`[bdd-coverage] ${resolved.error}`);
632
+ const refused = refuseRetiredMode(process.env);
633
+ if (refused.error) {
634
+ console.error(`[bdd-coverage] ${refused.error}`);
696
635
  process.exitCode = 2;
697
636
  return;
698
637
  }
699
- const options = {
700
- ...parseArgs(process.argv.slice(2), process.env),
701
- mode: resolved.mode,
702
- };
638
+ const options = parseArgs(process.argv.slice(2), process.env);
703
639
  const gateRun = run(root, options);
704
640
  const filesWritten =
705
641
  options.write && gateRun.report ? writeArtifacts(root, gateRun.report) : 0;
@@ -788,14 +724,7 @@ function writeArtifacts(root, report) {
788
724
  */
789
725
  function printHuman(gateRun, envelope) {
790
726
  for (const item of gateRun.defects) {
791
- console.error(
792
- `[bdd-coverage] ${item.severity}: ${item.code}: ${item.message}`
793
- );
794
- }
795
- if (gateRun.adoptionState === "bootstrap" && gateRun.defects.length > 0) {
796
- console.error(
797
- "[bdd-coverage] bootstrap: warnings above are visible, not blockers, until this repo advances to enforced. Anything reported as `error` fails even here."
798
- );
727
+ console.error(`[bdd-coverage] ${item.code}: ${item.message}`);
799
728
  }
800
729
  console.error(`[bdd-coverage] ${envelope.summary.headline}`);
801
730
  }
@@ -86,11 +86,36 @@ jobs:
86
86
  echo "approval_environment=$APPROVAL_ENV" >> $GITHUB_OUTPUT
87
87
  echo "require_approval=$REQUIRE" >> $GITHUB_OUTPUT
88
88
 
89
+ # ── Pre-deploy gates ──────────────────────────────────────────────────────
90
+ # Resolves `pre-deploy:<environment>` from the `gates` block of
91
+ # .lisa.config.json and runs whatever is declared there, BEFORE the release.
92
+ #
93
+ # This is what makes a declaration like
94
+ #
95
+ # "runtime-web-vulnerability": { "pre-deploy:production": "required" }
96
+ #
97
+ # mean something. A required gate that fails — or that resolves to no prover at
98
+ # all, which is UNPROVABLE rather than passing — stops the workflow here, so
99
+ # the release is never cut. A gate job running BESIDE the deploy would go red
100
+ # after the tag and the artefacts had already shipped, which is a report.
101
+ #
102
+ # A project that declares nothing at this moment gets a green job that ran no
103
+ # gates and says so, and the deploy behaves exactly as it did before.
104
+ pre_deploy_gates:
105
+ name: 🚦 Pre-Deploy Gates
106
+ uses: CodySwannGT/lisa/.github/workflows/gates.yml@main
107
+ needs: [determine_environment]
108
+ with:
109
+ moment: pre-deploy:${{ needs.determine_environment.outputs.environment }}
110
+ node_version: '22.21.1'
111
+ package_manager: 'bun'
112
+ secrets: inherit
113
+
89
114
  release:
90
115
  name: 📦 Release
91
116
  # Reference to the quality checks workflow
92
117
  uses: CodySwannGT/lisa/.github/workflows/release.yml@main
93
- needs: [determine_environment]
118
+ needs: [determine_environment, pre_deploy_gates]
94
119
  permissions:
95
120
  contents: write
96
121
  pull-requests: read
@@ -262,3 +287,19 @@ jobs:
262
287
 
263
288
  - name: 📢 Notify on success
264
289
  run: echo "Successfully deployed version ${{ steps.get_version.outputs.version }} to ${{ needs.determine_environment.outputs.environment }}"
290
+
291
+ # ── Post-deploy gates ─────────────────────────────────────────────────────
292
+ # Proves a property of what is now live — an accessibility audit or a DAST
293
+ # baseline against the published web build. A red here does not un-ship the
294
+ # release; it names the gate that does not hold so an operator can act on it,
295
+ # which is the difference between a post-deploy gate and a post-deploy log.
296
+ post_deploy_gates:
297
+ name: 🚦 Post-Deploy Gates
298
+ uses: CodySwannGT/lisa/.github/workflows/gates.yml@main
299
+ needs: [determine_environment, deploy]
300
+ if: ${{ always() && !cancelled() && needs.deploy.result == 'success' }}
301
+ with:
302
+ moment: post-deploy:${{ needs.determine_environment.outputs.environment }}
303
+ node_version: '22.21.1'
304
+ package_manager: 'bun'
305
+ secrets: inherit
@@ -1,13 +1,7 @@
1
1
  {
2
- "_comment": "BDD behavior contract, machine-readable half. See the Lisa rule `bdd-e2e-coverage` and docs/bdd-coverage-schema.md. Seeded in the `not-adopted` state: the gate reports and exits 0, and the BDD check MUST NOT be a required ruleset context until adoption.state is `enforced`. Adopt in one operation: set adoption.state, pass the matching bdd_mode from ci.yml, and add the ruleset context together.",
2
+ "_comment": "BDD behavior contract, machine-readable half. See the Lisa rule `bdd-e2e-coverage` and docs/bdd-coverage-schema.md. This file holds the contract, never the adoption decision: whether the gate is governed is declared once, as `behavior-contract` in .lisa.config.json, at `required`, `optional`, or `off`. An `adoption` block here is retired and is refused rather than ignored - a second copy of a decision is a second copy that can disagree.",
3
3
  "schemaVersion": 2,
4
4
  "asOf": "1970-01-01",
5
- "adoption": {
6
- "_comment": "not-adopted | bootstrap | enforced. Must equal the bdd_mode this repo's ci.yml passes to Lisa's quality.yml. bootstrap additionally requires `owner` (a named person) and `expiresAt` (an ISO date) — a bootstrap with no time-box never ends.",
7
- "state": "not-adopted",
8
- "owner": null,
9
- "expiresAt": null
10
- },
11
5
  "runnerPlatforms": {
12
6
  "_comment": "The ONLY place a test runner is named authoritatively. Declare each runner this project has and the platforms it covers, e.g. {\"playwright\": [\"web\"], \"maestro\": [\"ios\", \"android\"]}. Platform vocabulary is this project's own.",
13
7
  "playwright": ["web"],