@codyswann/lisa 2.349.0 → 2.350.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 (84) hide show
  1. package/dist/cli/doctor-learnings-ledger.d.ts +23 -0
  2. package/dist/cli/doctor-learnings-ledger.d.ts.map +1 -0
  3. package/dist/cli/doctor-learnings-ledger.js +67 -0
  4. package/dist/cli/doctor-learnings-ledger.js.map +1 -0
  5. package/dist/cli/doctor.d.ts.map +1 -1
  6. package/dist/cli/doctor.js +6 -0
  7. package/dist/cli/doctor.js.map +1 -1
  8. package/dist/core/learnings-file-safety.d.ts +12 -1
  9. package/dist/core/learnings-file-safety.d.ts.map +1 -1
  10. package/dist/core/learnings-file-safety.js +20 -1
  11. package/dist/core/learnings-file-safety.js.map +1 -1
  12. package/dist/core/learnings-location.d.ts +40 -0
  13. package/dist/core/learnings-location.d.ts.map +1 -0
  14. package/dist/core/learnings-location.js +89 -0
  15. package/dist/core/learnings-location.js.map +1 -0
  16. package/dist/core/learnings-stray-ledger.d.ts +24 -0
  17. package/dist/core/learnings-stray-ledger.d.ts.map +1 -0
  18. package/dist/core/learnings-stray-ledger.js +155 -0
  19. package/dist/core/learnings-stray-ledger.js.map +1 -0
  20. package/dist/core/project-config.d.ts +1 -13
  21. package/dist/core/project-config.d.ts.map +1 -1
  22. package/dist/core/project-config.js +5 -53
  23. package/dist/core/project-config.js.map +1 -1
  24. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  25. package/dist/core/upstream-evidence-manifest.js +15 -7
  26. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  27. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +46 -7
  28. package/expo/copy-overwrite/scripts/bdd/parse.mjs +22 -2
  29. package/expo/copy-overwrite/scripts/bdd/render.mjs +34 -7
  30. package/expo/copy-overwrite/scripts/bdd/report.mjs +118 -18
  31. package/expo/copy-overwrite/scripts/bdd/validate.mjs +95 -22
  32. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +5 -32
  33. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +29 -3
  34. package/package.json +1 -1
  35. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-agy/plugin.json +1 -1
  38. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  41. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo-agy/plugin.json +1 -1
  48. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  68. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-agy/plugin.json +1 -1
  73. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  78. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  83. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
@@ -91,17 +91,59 @@ export function loadBaseline(root, revision) {
91
91
  export function checkRatchet({ baseContract, contract, labels }) {
92
92
  const before = baseContract?.coverageFloor ?? {};
93
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.
94
100
  const drops = Object.keys(before)
95
- .filter(platform => typeof before[platform] === "number")
96
- .filter(platform => (after[platform] ?? -Infinity) < before[platform])
101
+ .filter(platform => isUsableFloor(before[platform]))
102
+ .filter(
103
+ platform =>
104
+ !isUsableFloor(after[platform]) || after[platform] < before[platform]
105
+ )
97
106
  .map(platform => ({
98
107
  platform,
99
108
  from: before[platform],
100
- to: typeof after[platform] === "number" ? after[platform] : null,
109
+ to: isUsableFloor(after[platform]) ? after[platform] : null,
110
+ malformed:
111
+ after[platform] !== undefined && !isUsableFloor(after[platform]),
101
112
  }));
102
113
  return drops.flatMap(drop => ratchetDefects(drop, contract, labels));
103
114
  }
104
115
 
116
+ /**
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.
123
+ */
124
+ export function isUsableFloor(value) {
125
+ return (
126
+ typeof value === "number" &&
127
+ Number.isFinite(value) &&
128
+ value >= 0 &&
129
+ value <= 100
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Describe one observed reduction for the operator.
135
+ * @param {object} drop - The observed reduction.
136
+ * @returns {string} A one-line description.
137
+ */
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}`;
145
+ }
146
+
105
147
  /**
106
148
  * Defects for one attempted floor reduction.
107
149
  * @param {object} drop - The observed reduction.
@@ -110,10 +152,7 @@ export function checkRatchet({ baseContract, contract, labels }) {
110
152
  * @returns {object[]} Defects found.
111
153
  */
112
154
  function ratchetDefects(drop, contract, labels) {
113
- const what =
114
- drop.to === null
115
- ? `coverageFloor.${drop.platform} was removed (was ${drop.from})`
116
- : `coverageFloor.${drop.platform} lowered ${drop.from} → ${drop.to}`;
155
+ const what = describeDrop(drop);
117
156
  const record = (contract.coverageFloorBaseline ?? []).find(
118
157
  entry =>
119
158
  entry.platform === drop.platform &&
@@ -139,6 +139,9 @@ export function parseFeatureSource(source, file, platforms) {
139
139
  const scenarios = [];
140
140
  const lines = source.split(/\r?\n/);
141
141
  let feature = null;
142
+ let featureTags = [];
143
+ let featureIdTags = [];
144
+ let featureLine = 0;
142
145
  let pending = [];
143
146
  let current = null;
144
147
  for (let index = 0; index < lines.length; index += 1) {
@@ -151,6 +154,15 @@ export function parseFeatureSource(source, file, platforms) {
151
154
  const featureMatch = /^Feature:\s*(.+)$/.exec(trimmed);
152
155
  if (featureMatch) {
153
156
  feature = featureMatch[1].trim();
157
+ // Gherkin inherits Feature-level tags down to every scenario in the
158
+ // file. Dropping them made an inherited @web or @ratified-* read as
159
+ // missing, so a conforming feature file failed on defects it did not
160
+ // have. Scenario IDs are deliberately NOT inherited — a feature-level
161
+ // @BDD-* would hand every scenario the same ID, which is a duplicate,
162
+ // so it is reported instead.
163
+ featureTags = pending.filter(tag => !ID_PATTERN.test(tag));
164
+ featureIdTags = pending.filter(tag => ID_PATTERN.test(tag));
165
+ featureLine = index + 1;
154
166
  pending = [];
155
167
  current = null;
156
168
  continue;
@@ -159,14 +171,22 @@ export function parseFeatureSource(source, file, platforms) {
159
171
  trimmed
160
172
  );
161
173
  if (scenarioMatch) {
162
- const grouped = categorize(pending, platforms);
174
+ // Inherited tags come first so a scenario's own tags read last, and
175
+ // duplicates are collapsed: a scenario repeating an inherited @web must
176
+ // not look like it declared the platform twice.
177
+ const effective = [...new Set([...featureTags, ...pending])];
178
+ const grouped = categorize(effective, platforms);
163
179
  current = {
164
180
  id: grouped.ids[0] ?? null,
165
181
  name: scenarioMatch[1].trim(),
166
182
  feature: feature ?? "Unknown feature",
167
183
  file,
168
184
  line: index + 1,
169
- tags: [...pending],
185
+ tags: effective,
186
+ ownTags: [...pending],
187
+ inheritedTags: [...featureTags],
188
+ featureIdTags,
189
+ featureLine,
170
190
  ...grouped,
171
191
  required: grouped.lifecycle.length === 0,
172
192
  primarySteps: [],
@@ -17,10 +17,24 @@
17
17
  * @returns {string} Cell text.
18
18
  */
19
19
  const fmt = value =>
20
- value.total === 0
20
+ value.total === 0 || value.percentage === null
21
21
  ? "n/a (no obligations)"
22
22
  : `${value.covered}/${value.total} (${value.percentage.toFixed(1)}%)`;
23
23
 
24
+ /**
25
+ * Make author-supplied text safe inside a Markdown table cell.
26
+ *
27
+ * Waiver reasons and scenario titles are repo data. A single `|` ends the
28
+ * cell early and shifts every column after it, so the Ticket and Expires
29
+ * columns silently misreport — in a generated file nobody re-reads.
30
+ * @param {unknown} text - Arbitrary cell content.
31
+ * @returns {string} Text safe to interpolate into a table row.
32
+ */
33
+ const cell = text =>
34
+ String(text ?? "")
35
+ .replace(/\|/g, "\\|")
36
+ .replace(/\r?\n/g, " ");
37
+
24
38
  /**
25
39
  * Render the per-platform traceability table.
26
40
  * @param {object} report - The coverage report.
@@ -28,7 +42,7 @@ const fmt = value =>
28
42
  */
29
43
  function platformTable(report) {
30
44
  const rows = Object.entries(report.traceability.byPlatform)
31
- .map(([platform, value]) => `| ${platform} | ${fmt(value)} |`)
45
+ .map(([platform, value]) => `| ${cell(platform)} | ${fmt(value)} |`)
32
46
  .join("\n");
33
47
  return `| Platform | Obligations with mapped automation |\n|---|---:|\n${rows}\n| **Overall** | **${fmt(report.traceability.overall)}** |`;
34
48
  }
@@ -47,7 +61,7 @@ function executionSection(report) {
47
61
  const sources = execution.sources
48
62
  .map(
49
63
  source =>
50
- `- \`${source.runner}\` run \`${source.runId ?? "unidentified"}\` (${source.resultCount} results, completed ${source.completedAt ?? "unknown"})`
64
+ `- \`${cell(source.runner)}\` run \`${cell(source.runId ?? "unidentified")}\` (${source.resultCount} results, completed ${cell(source.completedAt ?? "unknown")})`
51
65
  )
52
66
  .join("\n");
53
67
  return `| Mapped tests | Executed | Passed | Failed | Skipped | Not run |\n|---:|---:|---:|---:|---:|---:|\n| ${execution.mappedTests} | ${execution.executed} | ${execution.passed} | ${execution.failed} | ${execution.skipped} | ${execution.notRun} |\n\nSources:\n\n${sources}`;
@@ -63,12 +77,25 @@ function waiverTable(report) {
63
77
  report.waived.entries
64
78
  .map(
65
79
  entry =>
66
- `| ${entry.scenario} | ${entry.platforms.join(", ")} | ${entry.runner ?? "—"} | ${entry.owner ?? "**unowned**"} | ${entry.reason ?? "—"} | ${entry.ticket ?? "**none**"} | ${entry.expiresAt ?? "**never**"} |`
80
+ `| ${cell(entry.scenario)} | ${cell(entry.platforms.join(", "))} | ${cell(entry.runner ?? "—")} | ${cell(entry.owner ?? "**unowned**")} | ${cell(entry.reason ?? "—")} | ${cell(entry.ticket ?? "**none**")} | ${cell(entry.expiresAt ?? "**never**")} |`
67
81
  )
68
82
  .join("\n") || "| — | — | — | — | None | — | — |";
69
83
  return `| Scenario | Platforms | Runner | Owner | Reason | Ticket | Expires |\n|---|---|---|---|---|---|---|\n${rows}`;
70
84
  }
71
85
 
86
+ /**
87
+ * Render one platform's committed floor.
88
+ *
89
+ * An unusable value is called out as invalid rather than shown as absent —
90
+ * "no floor here" and "somebody broke the floor" need different reactions.
91
+ * @param {object} value - One platform's floor evaluation.
92
+ * @returns {string} Cell text.
93
+ */
94
+ function floorCell(value) {
95
+ if (value.state === "invalid") return "**invalid**";
96
+ return value.floor === null ? "**unset**" : `${value.floor}%`;
97
+ }
98
+
72
99
  /**
73
100
  * Render the floor ratchet table.
74
101
  * @param {object} report - The coverage report.
@@ -78,7 +105,7 @@ function floorTable(report) {
78
105
  const rows = Object.entries(report.floor.byPlatform)
79
106
  .map(
80
107
  ([platform, value]) =>
81
- `| ${platform} | ${value.floor === null ? "**unset**" : `${value.floor}%`} | ${value.actual}% | ${value.ok ? "ok" : "**below floor**"} |`
108
+ `| ${cell(platform)} | ${floorCell(value)} | ${value.actual === null ? "n/a" : `${value.actual}%`} | ${value.ok ? "ok" : "**below floor**"} |`
82
109
  )
83
110
  .join("\n");
84
111
  return `| Platform | Committed floor | Current | Status |\n|---|---:|---:|---|\n${rows}`;
@@ -107,10 +134,10 @@ function gapSections(report) {
107
134
  const rows = [...entries.entries()]
108
135
  .map(
109
136
  ([id, value]) =>
110
- `| ${id} | ${value.name} | ${value.platforms.sort().join(", ")} |`
137
+ `| ${cell(id)} | ${cell(value.name)} | ${cell(value.platforms.sort().join(", "))} |`
111
138
  )
112
139
  .join("\n");
113
- return `### ${feature}\n\n| Scenario | Behavior with no mapped proof | Platforms |\n|---|---|---|\n${rows}`;
140
+ return `### ${cell(feature)}\n\n| Scenario | Behavior with no mapped proof | Platforms |\n|---|---|---|\n${rows}`;
114
141
  });
115
142
  return (
116
143
  sections.join("\n\n") ||
@@ -22,11 +22,13 @@ import {
22
22
  trackerUrl,
23
23
  } from "./contract.mjs";
24
24
 
25
- const percentage = (covered, total) =>
26
- total === 0 ? 100 : (covered / total) * 100;
27
-
28
25
  /**
29
26
  * Summarize a subset of obligations against the covered set.
27
+ *
28
+ * An empty denominator reports `percentage: null`, never 100. "Nothing was
29
+ * required here" and "everything required here is covered" are different
30
+ * claims, and printing the second for the first is how a platform with no
31
+ * obligations comes to look fully covered.
30
32
  * @param {readonly object[]} subset - Obligations to count.
31
33
  * @param {ReadonlySet<string>} coveredKeys - Keys with an aligned mapping.
32
34
  * @returns {object} Covered/total/percentage.
@@ -36,7 +38,10 @@ function summarize(subset, coveredKeys) {
36
38
  return {
37
39
  covered,
38
40
  total: subset.length,
39
- percentage: Number(percentage(covered, subset.length).toFixed(1)),
41
+ percentage:
42
+ subset.length === 0
43
+ ? null
44
+ : Number(((covered / subset.length) * 100).toFixed(1)),
40
45
  };
41
46
  }
42
47
 
@@ -59,16 +64,55 @@ function declaredObligations(scenarios, platformRunners) {
59
64
  );
60
65
  }
61
66
 
67
+ /**
68
+ * Precedence when the same test reports more than once.
69
+ *
70
+ * Retries and sharded runs legitimately report a test twice. Last-write-wins
71
+ * would let a `passed` retry bury the `failed` attempt that preceded it, and
72
+ * the whole point of the execution block is that it cannot understate
73
+ * failures. Higher wins.
74
+ */
75
+ const STATUS_PRECEDENCE = Object.freeze({
76
+ failed: 3,
77
+ skipped: 2,
78
+ passed: 1,
79
+ });
80
+
81
+ /**
82
+ * Rank a result status, treating anything unrecognized as the most severe.
83
+ *
84
+ * Unknown is ranked ABOVE failed on purpose: a status this gate does not
85
+ * understand must never be silently outranked by a `passed` from another
86
+ * shard (allowlist, never denylist).
87
+ * @param {string|undefined} status - A supplied result status.
88
+ * @returns {number} Its precedence.
89
+ */
90
+ function statusRank(status) {
91
+ return STATUS_PRECEDENCE[status] ?? 4;
92
+ }
93
+
62
94
  /**
63
95
  * Index supplied execution results by runner, file, and evidence.
96
+ *
97
+ * Exported so the matrix joins results exactly as the burndown does; two
98
+ * private copies of this rule would eventually disagree about whether a test
99
+ * failed.
64
100
  * @param {readonly object[]} runs - Parsed execution-result documents.
65
101
  * @returns {Map<string, object>} Lookup keyed by `runner|file|evidence`.
66
102
  */
67
- function indexResults(runs) {
103
+ export function indexResults(runs) {
68
104
  const index = new Map();
69
105
  for (const run of runs) {
70
106
  for (const result of run.results ?? []) {
71
- index.set(`${run.runner}|${result.file}|${result.evidence}`, {
107
+ const key = `${run.runner}|${result.file}|${result.evidence}`;
108
+ const existing = index.get(key);
109
+ if (
110
+ existing &&
111
+ statusRank(existing.status) >= statusRank(result.status)
112
+ ) {
113
+ continue;
114
+ }
115
+ index.set(key, {
72
116
  ...result,
73
117
  runner: run.runner,
74
118
  runId: run.runId ?? null,
@@ -152,36 +196,78 @@ function distinctTests(mappings) {
152
196
  );
153
197
  }
154
198
 
199
+ /**
200
+ * A declared floor value, or the reason it is not usable.
201
+ *
202
+ * Only a finite number in 0..100 is a floor. Anything else — a quoted
203
+ * `"19"`, a null, a NaN — is `invalid`, NOT "no floor": treating a bad value
204
+ * as absent is what let a one-character edit remove a platform from
205
+ * enforcement while producing no defect at all.
206
+ * @param {unknown} declared - The raw `coverageFloor` entry.
207
+ * @returns {{state: string, value: number|null}} The classified floor.
208
+ */
209
+ function classifyFloor(declared) {
210
+ if (declared === undefined) return { state: "unset", value: null };
211
+ if (typeof declared !== "number" || !Number.isFinite(declared)) {
212
+ return { state: "invalid", value: null };
213
+ }
214
+ if (declared < 0 || declared > 100) return { state: "invalid", value: null };
215
+ return { state: "declared", value: declared };
216
+ }
217
+
155
218
  /**
156
219
  * Evaluate the committed coverage floor per platform.
157
220
  * @param {object} contract - Parsed coverage map.
158
221
  * @param {object} byPlatform - Traceability summary per platform.
159
222
  * @param {ReadonlySet<string>} platforms - Declared platform vocabulary.
160
- * @returns {object} Floor evaluation, including which platforms declared none.
223
+ * @returns {object} Floor evaluation, naming unset and invalid platforms.
161
224
  */
162
225
  export function evaluateFloor(contract, byPlatform, platforms) {
163
226
  const floors = contract.coverageFloor ?? {};
164
227
  const entries = [...platforms].sort().map(platform => {
165
- const declared = floors[platform];
166
- const actual = byPlatform[platform]?.percentage ?? 100;
228
+ const declared = classifyFloor(floors[platform]);
229
+ // A platform with no obligations reports `null`, not 100. It cannot clear
230
+ // a positive floor by having nothing to measure.
231
+ const actual = byPlatform[platform]?.percentage ?? null;
167
232
  return [
168
233
  platform,
169
234
  {
170
- floor: typeof declared === "number" ? declared : null,
235
+ floor: declared.value,
236
+ state: declared.state,
171
237
  actual,
172
- ok: typeof declared !== "number" || actual + 1e-9 >= declared,
238
+ ok: isFloorMet(declared, actual),
173
239
  },
174
240
  ];
175
241
  });
176
242
  return {
177
243
  byPlatform: Object.fromEntries(entries),
178
244
  unset: entries
179
- .filter(([, value]) => value.floor === null)
245
+ .filter(([, value]) => value.state === "unset")
246
+ .map(([name]) => name),
247
+ invalid: entries
248
+ .filter(([, value]) => value.state === "invalid")
180
249
  .map(([name]) => name),
181
250
  ok: entries.every(([, value]) => value.ok),
182
251
  };
183
252
  }
184
253
 
254
+ /**
255
+ * Whether a platform clears its floor.
256
+ *
257
+ * An invalid floor is never "met" — a bad value must fail rather than quietly
258
+ * disable enforcement. An unset floor has nothing to clear here; the enforced
259
+ * mode reports its absence separately.
260
+ * @param {{state: string, value: number|null}} declared - The classified floor.
261
+ * @param {number|null} actual - Measured percentage, or null when there are no obligations.
262
+ * @returns {boolean} Whether the floor is satisfied.
263
+ */
264
+ function isFloorMet(declared, actual) {
265
+ if (declared.state === "invalid") return false;
266
+ if (declared.state === "unset") return true;
267
+ if (actual === null) return declared.value === 0;
268
+ return actual + 1e-9 >= declared.value;
269
+ }
270
+
185
271
  /**
186
272
  * Collect every tracker reference declared across the contract.
187
273
  * @param {readonly object[]} scenarios - Parsed scenarios.
@@ -218,7 +304,13 @@ export function buildTrackerIndex(scenarios, trackers) {
218
304
  * @param {object} input - Scenarios, contract, execution runs, and platforms.
219
305
  * @returns {object} The report envelope.
220
306
  */
221
- export function buildReport({ scenarios, contract, runs, platforms }) {
307
+ export function buildReport({
308
+ scenarios,
309
+ contract,
310
+ runs,
311
+ platforms,
312
+ unresolved = new Set(),
313
+ }) {
222
314
  const platformRunners = runnersByPlatform(contract.runnerPlatforms);
223
315
  const declared = declaredObligations(scenarios, platformRunners);
224
316
  const waivedKeys = new Set(
@@ -227,7 +319,7 @@ export function buildReport({ scenarios, contract, runs, platforms }) {
227
319
  )
228
320
  );
229
321
  const obligations = declared.filter(item => !waivedKeys.has(item.key));
230
- const coveredKeys = coverageKeys(scenarios, contract);
322
+ const coveredKeys = coverageKeys(scenarios, contract, unresolved);
231
323
  const byPlatform = Object.fromEntries(
232
324
  [...platforms].sort().map(platform => [
233
325
  platform,
@@ -242,7 +334,7 @@ export function buildReport({ scenarios, contract, runs, platforms }) {
242
334
  asOf: contract.asOf ?? null,
243
335
  scenarios: countScenarios(scenarios),
244
336
  traceability: {
245
- note: "Obligations whose aligned automation is mapped and whose evidence string still resolves. This is TRACEABILITY coverage, not execution coverage and not a pass rate.",
337
+ note: "Obligations whose aligned automation is mapped AND whose evidence string still resolves — a mapping whose test was renamed or deleted stops counting here immediately, even in bootstrap where the matching defect is only a warning. This is TRACEABILITY coverage, not execution coverage and not a pass rate.",
246
338
  overall: summarize(obligations, coveredKeys),
247
339
  byPlatform,
248
340
  byRunner: byRunnerSummary(contract, obligations, coveredKeys),
@@ -267,12 +359,19 @@ export function buildReport({ scenarios, contract, runs, platforms }) {
267
359
  }
268
360
 
269
361
  /**
270
- * Every scenario-platform key an existing mapping covers.
362
+ * Every scenario-platform key a mapping covers AND still proves.
363
+ *
364
+ * A mapping whose evidence string no longer resolves is excluded here, not
365
+ * merely reported. In bootstrap that defect is only a warning, so counting it
366
+ * as covered would let the headline keep claiming coverage the repo does not
367
+ * have — the precise "the manifest was lying" failure this gate exists to
368
+ * surface.
271
369
  * @param {readonly object[]} scenarios - Parsed scenarios.
272
370
  * @param {object} contract - Parsed coverage map.
371
+ * @param {ReadonlySet<string>} unresolved - Keys whose evidence no longer resolves.
273
372
  * @returns {Set<string>} Covered keys.
274
373
  */
275
- function coverageKeys(scenarios, contract) {
374
+ function coverageKeys(scenarios, contract, unresolved) {
276
375
  const required = new Set(
277
376
  scenarios.filter(scenario => scenario.required).map(scenario => scenario.id)
278
377
  );
@@ -280,7 +379,8 @@ function coverageKeys(scenarios, contract) {
280
379
  for (const mapping of contract.mappings ?? []) {
281
380
  if (!required.has(mapping.scenario)) continue;
282
381
  for (const platform of mapping.platforms ?? []) {
283
- covered.add(`${mapping.scenario}:${platform}`);
382
+ const key = `${mapping.scenario}:${platform}`;
383
+ if (!unresolved.has(key)) covered.add(key);
284
384
  }
285
385
  }
286
386
  return covered;
@@ -64,9 +64,29 @@ export function validateScenarios(scenarios, platforms) {
64
64
  );
65
65
  }
66
66
  defects.push(...missingSteps(scenario, at));
67
+ defects.push(...featureLevelId(scenario));
67
68
  if (scenario.id) defects.push(...recordId(scenario, at, seen));
68
69
  }
69
- return defects;
70
+ return [...new Map(defects.map(item => [item.message, item])).values()];
71
+ }
72
+
73
+ /**
74
+ * Reject a `@BDD-*` id placed on the Feature rather than a Scenario.
75
+ *
76
+ * Gherkin would inherit it into every scenario in the file, handing them all
77
+ * the same identity. IDs are per-behavior, so this is always a mistake — and
78
+ * inheriting it silently would produce a pile of duplicate-ID defects that
79
+ * never name the real cause.
80
+ * @param {object} scenario - Parsed scenario.
81
+ * @returns {object[]} Defects found.
82
+ */
83
+ function featureLevelId(scenario) {
84
+ return (scenario.featureIdTags ?? []).map(tag =>
85
+ defect(
86
+ "scenario-id",
87
+ `${scenario.file}:${scenario.featureLine} declares @${tag} on the Feature; a scenario ID identifies one behavior and is never inherited`
88
+ )
89
+ );
70
90
  }
71
91
 
72
92
  /**
@@ -166,7 +186,12 @@ function orphanReason(reference, { keys, repos, defaultRepo }) {
166
186
  * @param {object} input - Root, scenarios, and the parsed contract.
167
187
  * @returns {object[]} Defects found.
168
188
  */
169
- export function validateMappings({ root, scenarios, contract }) {
189
+ export function validateMappings({
190
+ root,
191
+ scenarios,
192
+ contract,
193
+ cache = new Map(),
194
+ }) {
170
195
  const byId = new Map(scenarios.map(scenario => [scenario.id, scenario]));
171
196
  const platformRunners = runnersByPlatform(contract.runnerPlatforms);
172
197
  const defects = [];
@@ -188,7 +213,7 @@ export function validateMappings({ root, scenarios, contract }) {
188
213
  }
189
214
  defects.push(...mappingDuplicates(mapping, at, seen));
190
215
  defects.push(...mappingPlatforms(mapping, scenario, platformRunners, at));
191
- defects.push(...mappingEvidence(root, mapping, at));
216
+ defects.push(...mappingEvidence(root, mapping, at, cache));
192
217
  }
193
218
  return defects;
194
219
  }
@@ -250,33 +275,81 @@ function mappingPlatforms(mapping, scenario, platformRunners, at) {
250
275
  }
251
276
 
252
277
  /**
253
- * Prove the mapped file still contains the exact evidence string.
278
+ * Whether a mapping's evidence string still resolves inside the repo.
254
279
  *
255
- * This is what makes a mapping falsifiable rather than an assertion: rename
256
- * or delete the test and the map breaks loudly instead of leaving a scenario
257
- * silently unguarded.
280
+ * The single source of truth for "does this mapping still prove anything",
281
+ * shared by the defect check and by the coverage count. Two separate answers
282
+ * to that question is how a stale mapping comes to fail validation while
283
+ * still counting as covered.
258
284
  * @param {string} root - Repo root.
259
285
  * @param {object} mapping - Raw mapping entry.
260
- * @param {string} at - Location label.
261
- * @returns {object[]} Defects found.
286
+ * @param {Map<string, string>} [cache] - Optional file-content cache.
287
+ * @returns {{ok: boolean, code?: string, detail?: string}} The verdict.
262
288
  */
263
- function mappingEvidence(root, mapping, at) {
289
+ export function evidenceResolves(root, mapping, cache = new Map()) {
264
290
  if (typeof mapping.evidence !== "string" || mapping.evidence.length === 0) {
265
- return [defect("mapping-evidence", `${at}: declares no evidence string`)];
291
+ return {
292
+ ok: false,
293
+ code: "mapping-evidence",
294
+ detail: "declares no evidence string",
295
+ };
266
296
  }
267
297
  const resolved = resolveInsideRepo(root, mapping.file);
268
298
  if (!resolved.path) {
269
- return [
270
- defect("mapping-file", `${at}: ${mapping.file} — ${resolved.error}`),
271
- ];
299
+ return {
300
+ ok: false,
301
+ code: "mapping-file",
302
+ detail: `${mapping.file} — ${resolved.error}`,
303
+ };
272
304
  }
273
- if (!fs.readFileSync(resolved.path, "utf8").includes(mapping.evidence)) {
274
- return [
275
- defect(
276
- "mapping-evidence",
277
- `${at}: ${mapping.file} no longer contains ${JSON.stringify(mapping.evidence)}`
278
- ),
279
- ];
305
+ if (!cache.has(resolved.path)) {
306
+ cache.set(resolved.path, fs.readFileSync(resolved.path, "utf8"));
280
307
  }
281
- return [];
308
+ if (!cache.get(resolved.path).includes(mapping.evidence)) {
309
+ return {
310
+ ok: false,
311
+ code: "mapping-evidence",
312
+ detail: `${mapping.file} no longer contains ${JSON.stringify(mapping.evidence)}`,
313
+ };
314
+ }
315
+ return { ok: true };
316
+ }
317
+
318
+ /**
319
+ * The scenario-platform keys whose mapping no longer proves anything.
320
+ *
321
+ * Coverage is counted from mappings that PASS this check, so a mapping whose
322
+ * test was renamed stops counting the moment it stops resolving — including
323
+ * in bootstrap, where the matching defect is only a warning. Otherwise the
324
+ * headline would keep claiming coverage the repo does not have, which is
325
+ * exactly the "manifest was lying" failure this gate exists to surface.
326
+ * @param {object} input - Root, contract, and an optional file cache.
327
+ * @returns {Set<string>} Keys that must not be counted as covered.
328
+ */
329
+ export function unresolvedEvidenceKeys({ root, contract, cache = new Map() }) {
330
+ const broken = new Set();
331
+ for (const mapping of contract.mappings ?? []) {
332
+ if (evidenceResolves(root, mapping, cache).ok) continue;
333
+ for (const platform of mapping.platforms ?? []) {
334
+ broken.add(`${mapping.scenario}:${platform}`);
335
+ }
336
+ }
337
+ return broken;
338
+ }
339
+
340
+ /**
341
+ * Prove the mapped file still contains the exact evidence string.
342
+ *
343
+ * This is what makes a mapping falsifiable rather than an assertion: rename
344
+ * or delete the test and the map breaks loudly instead of leaving a scenario
345
+ * silently unguarded.
346
+ * @param {string} root - Repo root.
347
+ * @param {object} mapping - Raw mapping entry.
348
+ * @param {string} at - Location label.
349
+ * @param {Map<string, string>} cache - File-content cache.
350
+ * @returns {object[]} Defects found.
351
+ */
352
+ function mappingEvidence(root, mapping, at, cache) {
353
+ const verdict = evidenceResolves(root, mapping, cache);
354
+ return verdict.ok ? [] : [defect(verdict.code, `${at}: ${verdict.detail}`)];
282
355
  }
@@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
22
22
 
23
23
  import { declaredPlatforms, trackerUrl } from "./bdd/contract.mjs";
24
24
  import { loadScenarios } from "./bdd/parse.mjs";
25
+ import { indexResults } from "./bdd/report.mjs";
25
26
  import { loadExecutionResults } from "./check-bdd-coverage.mjs";
26
27
 
27
28
  const PACKAGE_ROOT = path.resolve(
@@ -46,37 +47,6 @@ function mappingsByScenario(contract) {
46
47
  return index;
47
48
  }
48
49
 
49
- /**
50
- * Index execution results by runner, file, and evidence.
51
- * @param {readonly object[]} runs - Parsed execution-result documents.
52
- * @returns {Map<string, object>} Lookup keyed by `runner|file|evidence`.
53
- */
54
- function resultsIndex(runs) {
55
- const index = new Map();
56
- for (const run of runs) {
57
- for (const result of run.results ?? []) {
58
- index.set(`${run.runner}|${result.file}|${result.evidence}`, result);
59
- }
60
- }
61
- return index;
62
- }
63
-
64
- /**
65
- * Every `SCENARIO:platform` a waiver removes from the denominator.
66
- *
67
- * The matrix must know about waivers or it contradicts the burndown: a
68
- * waived obligation is not an uncovered one, it is a dated IOU.
69
- * @param {object} contract - Parsed coverage map.
70
- * @returns {Set<string>} Waived keys.
71
- */
72
- function waivedKeys(contract) {
73
- return new Set(
74
- (contract.platformWaivers ?? []).flatMap(waiver =>
75
- (waiver.platforms ?? []).map(platform => `${waiver.scenario}:${platform}`)
76
- )
77
- );
78
- }
79
-
80
50
  /**
81
51
  * Render one scenario's mapped-automation cell for one runner.
82
52
  * @param {readonly string[]} required - Platforms the scenario demands this runner cover.
@@ -152,7 +122,10 @@ export function renderMatrix(root, resultFiles) {
152
122
  const platforms = declaredPlatforms(contract.runnerPlatforms);
153
123
  const scenarios = loadScenarios(root, platforms);
154
124
  const execution = loadExecutionResults(root, resultFiles);
155
- const results = resultsIndex(execution.runs);
125
+ // Shared with the burndown so the two can never disagree about whether a
126
+ // test failed — including the retry precedence that keeps a `failed`
127
+ // attempt from being buried by a later `passed`.
128
+ const results = indexResults(execution.runs);
156
129
  const byScenario = mappingsByScenario(contract);
157
130
  const waived = waivedKeys(contract);
158
131
  const runners = Object.keys(contract.runnerPlatforms ?? {}).sort();