@codyswann/lisa 2.346.0 → 2.348.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 (68) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +36 -1
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +224 -0
  5. package/expo/copy-overwrite/scripts/bdd/contract.mjs +121 -0
  6. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +231 -0
  7. package/expo/copy-overwrite/scripts/bdd/parse.mjs +220 -0
  8. package/expo/copy-overwrite/scripts/bdd/render.mjs +170 -0
  9. package/expo/copy-overwrite/scripts/bdd/report.mjs +361 -0
  10. package/expo/copy-overwrite/scripts/bdd/validate.mjs +282 -0
  11. package/expo/copy-overwrite/scripts/bdd/waivers.mjs +210 -0
  12. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +278 -0
  13. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +657 -0
  14. package/expo/create-only/bdd/coverage-map.json +38 -0
  15. package/expo/create-only/bdd/features/.keep +5 -0
  16. package/expo/github-rulesets/bdd-coverage.json +33 -0
  17. package/expo/package-lisa/package.lisa.json +3 -0
  18. package/package.json +1 -1
  19. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-agy/plugin.json +1 -1
  32. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  52. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-agy/plugin.json +1 -1
  57. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  62. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  67. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,657 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-bdd-coverage — the BDD behavior-contract gate.
4
+ *
5
+ * Validates `bdd/features/*.feature` against `bdd/coverage-map.json` and
6
+ * reports five separate facts: behaviors DECLARED, obligations MAPPED to an
7
+ * automated test (traceability), mapped tests that RAN, what those runs
8
+ * returned, and what is WAIVED. Traceability coverage is not execution
9
+ * coverage and is never a pass rate.
10
+ *
11
+ * THREE-STATE ADOPTION (`BDD_MODE`, supplied by CI, never inferred):
12
+ *
13
+ * not-adopted The contract is not required. The gate reports and exits 0,
14
+ * and the check MUST NOT be a required ruleset context.
15
+ * bootstrap A visible, non-blocking check carrying a named owner and a
16
+ * hard expiry. Contract defects are warnings; a missing or
17
+ * passed expiry is a failure, so bootstrap cannot become
18
+ * permanent.
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
22
+ * this state is the check a required ruleset context.
23
+ *
24
+ * A required context is NEVER auto-skipped: GitHub counts a skipped required
25
+ * check as passing, which is the exact anti-pattern this gate exists to
26
+ * avoid.
27
+ *
28
+ * Usage:
29
+ * node scripts/check-bdd-coverage.mjs [--write] [--json] [--results <file>]
30
+ *
31
+ * @module scripts/check-bdd-coverage
32
+ */
33
+ import * as fs from "node:fs";
34
+ import * as path from "node:path";
35
+ import { fileURLToPath, pathToFileURL } from "node:url";
36
+
37
+ import {
38
+ ADOPTION_STATES,
39
+ SUPPORTED_MAP_SCHEMA_VERSIONS,
40
+ declaredPlatforms,
41
+ } from "./bdd/contract.mjs";
42
+ import {
43
+ SUCCESS_STATUSES,
44
+ WARNABLE_DEFECT_CODES,
45
+ buildSummary,
46
+ contractVersion,
47
+ correlationId,
48
+ hasFatalDefect,
49
+ loadEnvelopeModule,
50
+ subjectFor,
51
+ } from "./bdd/envelope.mjs";
52
+ import { checkDeletions, checkRatchet, loadBaseline } from "./bdd/baseline.mjs";
53
+ import { loadScenarios } from "./bdd/parse.mjs";
54
+ import { buildReport } from "./bdd/report.mjs";
55
+ import { renderBurndown } from "./bdd/render.mjs";
56
+ import {
57
+ validateMappings,
58
+ validateScenarios,
59
+ validateTrackerTags,
60
+ } from "./bdd/validate.mjs";
61
+ import { validateWaivers } from "./bdd/waivers.mjs";
62
+
63
+ const PACKAGE_ROOT = path.resolve(
64
+ path.dirname(fileURLToPath(import.meta.url)),
65
+ ".."
66
+ );
67
+ const MAP_REL = "bdd/coverage-map.json";
68
+ const defect = (code, message) => ({ code, message });
69
+
70
+ /**
71
+ * Resolve the adoption state from the environment.
72
+ * @param {Record<string, string|undefined>} env - Process environment.
73
+ * @returns {{mode: string, error: string|null}} The resolved mode.
74
+ */
75
+ export function resolveMode(env) {
76
+ const raw = (env.BDD_MODE ?? "").trim();
77
+ if (raw === "") return { mode: "not-adopted", error: null };
78
+ if (!ADOPTION_STATES.includes(raw)) {
79
+ return {
80
+ mode: "not-adopted",
81
+ error: `BDD_MODE="${raw}" is not one of ${ADOPTION_STATES.join(", ")}`,
82
+ };
83
+ }
84
+ return { mode: raw, error: null };
85
+ }
86
+
87
+ /**
88
+ * Read and parse the coverage map, distinguishing "absent" from "malformed".
89
+ * @param {string} root - Repo root.
90
+ * @returns {{present: boolean, contract: object|null, error: string|null}} Load result.
91
+ */
92
+ export function loadContract(root) {
93
+ const file = path.join(root, MAP_REL);
94
+ if (!fs.existsSync(file))
95
+ return { present: false, contract: null, error: null };
96
+ try {
97
+ const contract = JSON.parse(fs.readFileSync(file, "utf8"));
98
+ if (
99
+ contract === null ||
100
+ typeof contract !== "object" ||
101
+ Array.isArray(contract)
102
+ ) {
103
+ return {
104
+ present: true,
105
+ contract: null,
106
+ error: `${MAP_REL} is not a JSON object`,
107
+ };
108
+ }
109
+ return { present: true, contract, error: null };
110
+ } catch (error) {
111
+ return {
112
+ present: true,
113
+ contract: null,
114
+ error: `${MAP_REL} is not valid JSON: ${error.message}`,
115
+ };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Validate the manifest's own adoption block against the mode CI declared.
121
+ *
122
+ * The two must agree: CI is the authority (it survives deletion of `bdd/`),
123
+ * and the manifest is the self-describing record. A disagreement means an
124
+ * adoption was half-performed, which is exactly the state that produces a
125
+ * required check nobody is actually running.
126
+ * @param {object} contract - Parsed coverage map.
127
+ * @param {string} mode - Mode declared by CI.
128
+ * @param {string} today - ISO date to evaluate the expiry against.
129
+ * @returns {object[]} Defects found.
130
+ */
131
+ export function validateAdoption(contract, mode, today) {
132
+ const adoption = contract.adoption ?? {};
133
+ const defects = [];
134
+ if (adoption.state && adoption.state !== mode) {
135
+ defects.push(
136
+ defect(
137
+ "adoption-drift",
138
+ `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.`
139
+ )
140
+ );
141
+ }
142
+ if (mode === "enforced" && !adoption.state) {
143
+ defects.push(
144
+ defect(
145
+ "adoption-drift",
146
+ `enforced mode requires adoption.state "enforced" in ${MAP_REL}`
147
+ )
148
+ );
149
+ }
150
+ if (mode !== "bootstrap") return defects;
151
+ return [...defects, ...bootstrapDefects(adoption, today)];
152
+ }
153
+
154
+ /**
155
+ * Bootstrap owes a named owner and a hard expiry, and dies at that expiry.
156
+ * @param {object} adoption - The manifest's adoption block.
157
+ * @param {string} today - ISO date to evaluate against.
158
+ * @returns {object[]} Defects found.
159
+ */
160
+ function bootstrapDefects(adoption, today) {
161
+ const defects = [];
162
+ if (!adoption.owner) {
163
+ defects.push(
164
+ defect(
165
+ "bootstrap-metadata",
166
+ "bootstrap requires adoption.owner (a named person, not a team)"
167
+ )
168
+ );
169
+ }
170
+ if (!adoption.expiresAt) {
171
+ defects.push(
172
+ defect(
173
+ "bootstrap-metadata",
174
+ "bootstrap requires adoption.expiresAt (an ISO date); a bootstrap with no time-box never ends"
175
+ )
176
+ );
177
+ } else if (!/^\d{4}-\d{2}-\d{2}$/.test(adoption.expiresAt)) {
178
+ defects.push(
179
+ defect(
180
+ "bootstrap-metadata",
181
+ "adoption.expiresAt must be an ISO date (YYYY-MM-DD)"
182
+ )
183
+ );
184
+ } else if (adoption.expiresAt < today) {
185
+ defects.push(
186
+ defect(
187
+ "bootstrap-expired",
188
+ `the BDD bootstrap expired on ${adoption.expiresAt} (owner: ${adoption.owner ?? "unnamed"}). Advance to enforced or re-authorize the time-box.`
189
+ )
190
+ );
191
+ }
192
+ return defects;
193
+ }
194
+
195
+ /**
196
+ * Read every supplied execution-result document.
197
+ * @param {string} root - Repo root.
198
+ * @param {readonly string[]} files - Repo-relative or absolute result paths.
199
+ * @returns {{runs: object[], defects: object[]}} Parsed runs and any read errors.
200
+ */
201
+ export function loadExecutionResults(root, files) {
202
+ const runs = [];
203
+ const defects = [];
204
+ for (const file of files) {
205
+ const resolved = path.resolve(root, file);
206
+ if (!fs.existsSync(resolved)) {
207
+ defects.push(
208
+ defect("execution-results", `execution results not found: ${file}`)
209
+ );
210
+ continue;
211
+ }
212
+ try {
213
+ const parsed = JSON.parse(fs.readFileSync(resolved, "utf8"));
214
+ for (const run of Array.isArray(parsed) ? parsed : [parsed]) {
215
+ if (!run.runner) {
216
+ defects.push(
217
+ defect(
218
+ "execution-results",
219
+ `${file}: each run must name its runner`
220
+ )
221
+ );
222
+ continue;
223
+ }
224
+ runs.push(run);
225
+ }
226
+ } catch (error) {
227
+ defects.push(defect("execution-results", `${file}: ${error.message}`));
228
+ }
229
+ }
230
+ return { runs, defects };
231
+ }
232
+
233
+ /**
234
+ * Run every contract validator plus the base-revision comparisons.
235
+ * @param {object} input - Root, contract, scenarios, platforms, and options.
236
+ * @returns {object[]} Defects found.
237
+ */
238
+ function validateAll({ root, contract, scenarios, platforms, options }) {
239
+ const defects = [
240
+ ...validateScenarios(scenarios, platforms),
241
+ ...validateTrackerTags(scenarios, contract.trackers),
242
+ ...validateMappings({ root, scenarios, contract }),
243
+ ...validateWaivers({ scenarios, contract, today: options.today }),
244
+ ];
245
+ if (!options.baseSha) return defects;
246
+ const baseline = loadBaseline(root, options.baseSha);
247
+ if (!baseline.available) {
248
+ return [
249
+ ...defects,
250
+ defect(
251
+ "baseline",
252
+ `base revision ${options.baseSha} is not readable; the ratchet and deletion checks could not run`
253
+ ),
254
+ ];
255
+ }
256
+ return [
257
+ ...defects,
258
+ ...checkRatchet({
259
+ baseContract: baseline.contract,
260
+ contract,
261
+ labels: options.labels,
262
+ }),
263
+ ...checkDeletions({
264
+ baseIds: baseline.scenarioIds,
265
+ scenarios,
266
+ contract,
267
+ labels: options.labels,
268
+ }),
269
+ ];
270
+ }
271
+
272
+ /**
273
+ * Defects that only exist in enforced mode, where absence must fail.
274
+ * @param {object} input - Contract, scenarios, report, and platforms.
275
+ * @returns {object[]} Defects found.
276
+ */
277
+ function enforcedDefects({ contract, scenarios, report, platforms }) {
278
+ const defects = [];
279
+ if (scenarios.length === 0) {
280
+ defects.push(
281
+ defect(
282
+ "empty-contract",
283
+ "enforced mode: bdd/features declares zero scenarios"
284
+ )
285
+ );
286
+ }
287
+ if ((contract.mappings ?? []).length === 0) {
288
+ defects.push(
289
+ defect(
290
+ "empty-contract",
291
+ "enforced mode: bdd/coverage-map.json declares zero test mappings"
292
+ )
293
+ );
294
+ }
295
+ if (Object.keys(contract.runnerPlatforms ?? {}).length === 0) {
296
+ defects.push(
297
+ defect(
298
+ "empty-contract",
299
+ "enforced mode: bdd/coverage-map.json declares no runnerPlatforms"
300
+ )
301
+ );
302
+ }
303
+ for (const platform of report.floor.unset) {
304
+ defects.push(
305
+ defect(
306
+ "floor-missing",
307
+ `enforced mode: no coverageFloor declared for platform ${platform}`
308
+ )
309
+ );
310
+ }
311
+ for (const [platform, value] of Object.entries(report.floor.byPlatform)) {
312
+ if (!value.ok) {
313
+ defects.push(
314
+ defect(
315
+ "floor-regression",
316
+ `${platform} traceability coverage ${value.actual}% is below its committed floor of ${value.floor}%`
317
+ )
318
+ );
319
+ }
320
+ }
321
+ void platforms;
322
+ return defects;
323
+ }
324
+
325
+ /**
326
+ * Evaluate the gate.
327
+ * @param {string} root - Repo root.
328
+ * @param {object} options - Mode, dates, labels, base SHA, and result files.
329
+ * @returns {object} The result envelope.
330
+ */
331
+ export function run(root, options) {
332
+ const { mode } = options;
333
+ const loaded = loadContract(root);
334
+ const fatal = configFatals(loaded, mode);
335
+ if (fatal)
336
+ return result({ mode, defects: [fatal], report: null, contract: null });
337
+ if (!loaded.present) {
338
+ return result({ mode, defects: [], report: null, contract: null });
339
+ }
340
+ const contract = loaded.contract;
341
+ const versionDefect = schemaDefect(contract);
342
+ const platforms = declaredPlatforms(contract.runnerPlatforms);
343
+ const scenarios = loadScenarios(root, platforms);
344
+ const execution = loadExecutionResults(root, options.resultFiles ?? []);
345
+ const report = buildReport({
346
+ scenarios,
347
+ contract,
348
+ runs: execution.runs,
349
+ platforms,
350
+ });
351
+ const defects = [
352
+ ...(versionDefect ? [versionDefect] : []),
353
+ ...validateAdoption(contract, mode, options.today),
354
+ ...execution.defects,
355
+ ...validateAll({ root, contract, scenarios, platforms, options }),
356
+ ...(mode === "enforced"
357
+ ? enforcedDefects({ contract, scenarios, report, platforms })
358
+ : []),
359
+ ];
360
+ return result({ mode, defects, report, contract });
361
+ }
362
+
363
+ /**
364
+ * Configuration problems that stop the gate before it can evaluate anything.
365
+ * @param {object} loaded - Result of {@link loadContract}.
366
+ * @param {string} mode - Adoption state.
367
+ * @returns {object|null} The fatal defect, or null.
368
+ */
369
+ function configFatals(loaded, mode) {
370
+ if (loaded.error) return defect("config-malformed", loaded.error);
371
+ if (loaded.present || mode === "not-adopted") return null;
372
+ return defect(
373
+ "config-absent",
374
+ `${mode} mode requires ${MAP_REL}, which does not exist. In ${mode} mode absence is a failure, never a skip.`
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Reject a coverage map written against an unknown schema version.
380
+ * @param {object} contract - Parsed coverage map.
381
+ * @returns {object|null} The defect, or null.
382
+ */
383
+ function schemaDefect(contract) {
384
+ const version = contract.schemaVersion;
385
+ if (SUPPORTED_MAP_SCHEMA_VERSIONS.includes(version)) return null;
386
+ return defect(
387
+ "config-schema",
388
+ `bdd/coverage-map.json schemaVersion ${JSON.stringify(version)} is not supported (this gate reads ${SUPPORTED_MAP_SCHEMA_VERSIONS.join(", ")})`
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Statuses that must NOT map onto `failed`, because they describe a bad
394
+ * request or an unreadable contract rather than a contract that failed.
395
+ * Source constants, deliberately: an unrecognized code falls through to
396
+ * `failed`, which is the closed direction.
397
+ */
398
+ const INVALID_CODES = Object.freeze([
399
+ "config-malformed",
400
+ "config-schema",
401
+ "config-absent",
402
+ ]);
403
+
404
+ /**
405
+ * Assemble the gate's internal result, with each defect's severity resolved.
406
+ *
407
+ * Severity is decided here, once, from the adoption state and the warnable
408
+ * allowlist, so the human output, the envelope findings and the exit code can
409
+ * never disagree about whether something was a warning.
410
+ * @param {object} input - Mode, defects, report, and the parsed contract.
411
+ * @returns {object} The internal result.
412
+ */
413
+ function result({ mode, defects, report, contract }) {
414
+ const fatal = hasFatalDefect(mode, defects);
415
+ const graded = defects.map(item => ({
416
+ ...item,
417
+ severity:
418
+ mode === "enforced" || !WARNABLE_DEFECT_CODES.includes(item.code)
419
+ ? "error"
420
+ : "warning",
421
+ }));
422
+ return {
423
+ adoptionState: mode,
424
+ status: statusFor({ mode, defects: graded, fatal, report }),
425
+ defects: graded,
426
+ report,
427
+ contract,
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Map the run onto the standard envelope's status vocabulary.
433
+ * @param {object} input - Mode, graded defects, fatality, and the report.
434
+ * @returns {string} An envelope status.
435
+ */
436
+ function statusFor({ mode, defects, fatal, report }) {
437
+ if (defects.some(item => INVALID_CODES.includes(item.code))) return "invalid";
438
+ if (fatal) return "failed";
439
+ if (mode === "not-adopted" && !report) return "not-adopted";
440
+ return "completed";
441
+ }
442
+
443
+ /**
444
+ * One operator-readable line naming what the gate proved, and what it did not.
445
+ * @param {object} run - The internal result.
446
+ * @returns {string} Summary line.
447
+ */
448
+ function summaryLine(run) {
449
+ const head = `bdd-coverage ${run.adoptionState}: ${run.status}`;
450
+ if (!run.report) return `${head} (${run.defects.length} findings)`;
451
+ const trace = run.report.traceability.overall;
452
+ const execution = run.report.execution.supplied
453
+ ? `${run.report.execution.executed}/${run.report.execution.mappedTests} mapped tests executed, ${run.report.execution.passed} passed / ${run.report.execution.failed} failed / ${run.report.execution.skipped} skipped`
454
+ : "no execution evidence supplied";
455
+ return `${head}; ${run.report.scenarios.declared} scenarios declared, traceability ${trace.covered}/${trace.total} (${trace.percentage}%), ${execution}, ${run.report.waived.count} waived, ${run.defects.length} findings`;
456
+ }
457
+
458
+ /**
459
+ * The one-sentence `reason` the envelope requires for a non-success status.
460
+ * @param {object} run - The internal result.
461
+ * @returns {string} The reason.
462
+ */
463
+ function reasonFor(run) {
464
+ const first = run.defects.find(item => item.severity === "error");
465
+ return first
466
+ ? `${first.code}: ${first.message}`
467
+ : `bdd-coverage ${run.adoptionState} did not complete`;
468
+ }
469
+
470
+ /**
471
+ * Convert the internal result into Lisa's standard command envelope.
472
+ *
473
+ * `mode` is the ENVELOPE's mode — the gate really runs, so it is always
474
+ * `real`. The BDD adoption state is a different axis and rides in
475
+ * `summary.adoptionState`, with `status: "not-adopted"` carrying it for a
476
+ * repo that has not wired the contract.
477
+ * @param {object} input - The result, the environment, and CLI options.
478
+ * @returns {object} An envelope conforming to lisa-command-envelope.v1.
479
+ */
480
+ export function toCommandEnvelope({
481
+ run: gateRun,
482
+ env,
483
+ options,
484
+ filesWritten,
485
+ }) {
486
+ const fields = {
487
+ capability: "bdd-coverage",
488
+ mode: "real",
489
+ operation: "check",
490
+ environment: env.BDD_ENVIRONMENT || "local",
491
+ contractVersion: contractVersion(gateRun.contract),
492
+ dryRun: !options.write,
493
+ status: gateRun.status,
494
+ correlationId: correlationId(env.BDD_CORRELATION_ID, {
495
+ adoptionState: gateRun.adoptionState,
496
+ status: gateRun.status,
497
+ summary: summaryLine(gateRun),
498
+ }),
499
+ summary: {
500
+ ...buildSummary({
501
+ adoptionState: gateRun.adoptionState,
502
+ report: gateRun.report,
503
+ defects: gateRun.defects,
504
+ filesWritten,
505
+ }),
506
+ headline: summaryLine(gateRun),
507
+ },
508
+ findings: gateRun.defects.map(item => ({
509
+ code: item.code,
510
+ subject: subjectFor(item),
511
+ message: item.message,
512
+ severity: item.severity,
513
+ })),
514
+ };
515
+ return SUCCESS_STATUSES.includes(gateRun.status)
516
+ ? fields
517
+ : { ...fields, reason: reasonFor(gateRun) };
518
+ }
519
+
520
+ /**
521
+ * Parse argv into options.
522
+ * @param {readonly string[]} argv - Process arguments.
523
+ * @param {Record<string, string|undefined>} env - Process environment.
524
+ * @returns {object} Parsed options.
525
+ */
526
+ export function parseArgs(argv, env) {
527
+ const resultFiles = [];
528
+ for (let index = 0; index < argv.length; index += 1) {
529
+ if (argv[index] === "--results" && argv[index + 1]) {
530
+ resultFiles.push(argv[index + 1]);
531
+ index += 1;
532
+ }
533
+ }
534
+ if (env.BDD_EXECUTION_RESULTS) {
535
+ resultFiles.push(
536
+ ...env.BDD_EXECUTION_RESULTS.split(",")
537
+ .map(item => item.trim())
538
+ .filter(Boolean)
539
+ );
540
+ }
541
+ return {
542
+ write: argv.includes("--write"),
543
+ json: argv.includes("--json"),
544
+ report: argv.includes("--report"),
545
+ resultFiles,
546
+ baseSha: env.BDD_BASE_SHA || null,
547
+ labels: (env.BDD_PR_LABELS ?? "")
548
+ .split(",")
549
+ .map(item => item.trim())
550
+ .filter(Boolean),
551
+ today: env.BDD_TODAY || new Date().toISOString().slice(0, 10),
552
+ };
553
+ }
554
+
555
+ /**
556
+ * CLI entry point.
557
+ *
558
+ * stdout carries exactly one machine-readable result — the standard command
559
+ * envelope — and human narration goes to stderr, per the envelope contract.
560
+ * @returns {Promise<void>} Resolves once the exit code is set.
561
+ */
562
+ async function main() {
563
+ const root = process.env.BDD_COVERAGE_ROOT || PACKAGE_ROOT;
564
+ const resolved = resolveMode(process.env);
565
+ if (resolved.error) {
566
+ console.error(`[bdd-coverage] ${resolved.error}`);
567
+ process.exitCode = 2;
568
+ return;
569
+ }
570
+ const options = {
571
+ ...parseArgs(process.argv.slice(2), process.env),
572
+ mode: resolved.mode,
573
+ };
574
+ const gateRun = run(root, options);
575
+ const filesWritten =
576
+ options.write && gateRun.report ? writeArtifacts(root, gateRun.report) : 0;
577
+ const envelope = await sealEnvelope({ gateRun, options, filesWritten });
578
+ // Exactly ONE machine-readable object on stdout. `--report` is a diagnostic
579
+ // that swaps the envelope for the detailed report; it never adds a second
580
+ // document, because a stream carrying two shapes has no schema at all.
581
+ if (options.report) console.log(JSON.stringify(gateRun.report, null, 2));
582
+ else if (options.json) console.log(JSON.stringify(envelope, null, 2));
583
+ printHuman(gateRun, envelope);
584
+ process.exitCode = SUCCESS_STATUSES.includes(envelope.status) ? 0 : 1;
585
+ }
586
+
587
+ /**
588
+ * Build the envelope through the shared module when it is installed, so its
589
+ * validator — not this file — decides conformance.
590
+ *
591
+ * When the shared module is absent the same object is emitted unvalidated
592
+ * rather than nothing: a gate that produces no result is indistinguishable
593
+ * from one that passed.
594
+ * @param {object} input - The gate run, CLI options, and files written.
595
+ * @returns {Promise<object>} The envelope.
596
+ */
597
+ async function sealEnvelope({ gateRun, options, filesWritten }) {
598
+ const fields = toCommandEnvelope({
599
+ run: gateRun,
600
+ env: process.env,
601
+ options,
602
+ filesWritten,
603
+ });
604
+ const shared = await loadEnvelopeModule(
605
+ path.dirname(fileURLToPath(import.meta.url))
606
+ );
607
+ if (!shared) {
608
+ console.error(
609
+ "[bdd-coverage] scripts/lisa-command-envelope.mjs is not installed; emitting the envelope without validating it against the published schema."
610
+ );
611
+ return { schemaVersion: "lisa-command-envelope-v1", ...fields };
612
+ }
613
+ return shared.buildEnvelope(fields);
614
+ }
615
+
616
+ /**
617
+ * Write the regenerated machine report and burndown.
618
+ * @param {string} root - Repo root.
619
+ * @param {object} report - The report.
620
+ * @returns {number} How many files were written, for the envelope's counters.
621
+ */
622
+ function writeArtifacts(root, report) {
623
+ fs.writeFileSync(
624
+ path.join(root, "bdd", "coverage-report.json"),
625
+ `${JSON.stringify(report, null, 2)}\n`
626
+ );
627
+ fs.mkdirSync(path.join(root, "docs"), { recursive: true });
628
+ fs.writeFileSync(
629
+ path.join(root, "docs", "e2e-bdd-coverage.md"),
630
+ `${renderBurndown(report).trim()}\n`
631
+ );
632
+ return 2;
633
+ }
634
+
635
+ /**
636
+ * Narrate the result on stderr, which the envelope contract reserves for
637
+ * humans so stdout stays exactly one machine-readable object.
638
+ * @param {object} gateRun - The internal result.
639
+ * @param {object} envelope - The emitted envelope.
640
+ * @returns {void}
641
+ */
642
+ function printHuman(gateRun, envelope) {
643
+ for (const item of gateRun.defects) {
644
+ console.error(
645
+ `[bdd-coverage] ${item.severity}: ${item.code}: ${item.message}`
646
+ );
647
+ }
648
+ if (gateRun.adoptionState === "bootstrap" && gateRun.defects.length > 0) {
649
+ console.error(
650
+ "[bdd-coverage] bootstrap: warnings above are visible, not blockers, until this repo advances to enforced. Anything reported as `error` fails even here."
651
+ );
652
+ }
653
+ console.error(`[bdd-coverage] ${envelope.summary.headline}`);
654
+ }
655
+
656
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
657
+ main();
@@ -0,0 +1,38 @@
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.",
3
+ "schemaVersion": 2,
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
+ "runnerPlatforms": {
12
+ "_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
+ "playwright": ["web"],
14
+ "maestro": ["ios", "android"]
15
+ },
16
+ "coverageFloor": {
17
+ "_comment": "Committed traceability floor per platform. A ratchet: may rise, may never fall. Seeded at 0 so adopting on a brownfield app never red-gates CI before any scenario exists. Lowering one requires a coverageFloorBaseline record naming the exact change AND the maintainer-applied `bdd-floor-baseline` pull-request label.",
18
+ "web": 0,
19
+ "ios": 0,
20
+ "android": 0
21
+ },
22
+ "coverageFloorBaseline": [],
23
+ "retirements": [],
24
+ "trackers": {
25
+ "_comment": "Tracker-tag grammar is portfolio-wide; the allowed keys and repos are per-repo. Key style: @TUN-123, @SE-6833. Repo-issue style: @gh-2394 (this repo) or @gh-wiki-124 (a sibling repo). Tags are validated for syntax and membership only — the gate never contacts a tracker, so an unreachable issue can never block a merge.",
26
+ "required": false,
27
+ "keys": [],
28
+ "keyUrlTemplate": null,
29
+ "github": {
30
+ "org": null,
31
+ "defaultRepo": null,
32
+ "repos": []
33
+ }
34
+ },
35
+ "platformWaivers": [],
36
+ "mappings": [],
37
+ "exclusions": []
38
+ }
@@ -0,0 +1,5 @@
1
+ # Gherkin behavior contract lives here as *.feature files.
2
+ #
3
+ # Each scenario carries one stable @BDD-<DOMAIN>-<NNN> id, the platforms it
4
+ # requires, @figma-*/@ratified-* provenance, and a tracker tag (@TUN-123,
5
+ # @SE-6833, @gh-2394, @gh-wiki-124). See the Lisa rule bdd-e2e-coverage.