@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,361 @@
1
+ /**
2
+ * Build the BDD coverage report.
3
+ *
4
+ * The report keeps five different facts apart on purpose, because collapsing
5
+ * them is how a "100% BDD coverage" headline comes to mean nothing:
6
+ *
7
+ * 1. `scenarios` — behaviors DECLARED in Gherkin.
8
+ * 2. `traceability` — declared obligations MAPPED to an automated test
9
+ * that still contains its evidence string.
10
+ * 3. `execution` — mapped tests that actually RAN in a supplied run.
11
+ * 4. pass/fail/skip — what those runs RETURNED.
12
+ * 5. `waived` — obligations deliberately outside the denominator.
13
+ *
14
+ * Traceability coverage is NOT execution coverage and NOT a pass rate. Every
15
+ * label in this module says which one it is.
16
+ *
17
+ * @module scripts/bdd/report
18
+ */
19
+ import {
20
+ REPORT_SCHEMA_VERSION,
21
+ runnersByPlatform,
22
+ trackerUrl,
23
+ } from "./contract.mjs";
24
+
25
+ const percentage = (covered, total) =>
26
+ total === 0 ? 100 : (covered / total) * 100;
27
+
28
+ /**
29
+ * Summarize a subset of obligations against the covered set.
30
+ * @param {readonly object[]} subset - Obligations to count.
31
+ * @param {ReadonlySet<string>} coveredKeys - Keys with an aligned mapping.
32
+ * @returns {object} Covered/total/percentage.
33
+ */
34
+ function summarize(subset, coveredKeys) {
35
+ const covered = subset.filter(item => coveredKeys.has(item.key)).length;
36
+ return {
37
+ covered,
38
+ total: subset.length,
39
+ percentage: Number(percentage(covered, subset.length).toFixed(1)),
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Expand required scenarios into one obligation per scenario-platform pair.
45
+ * @param {readonly object[]} scenarios - Parsed scenarios.
46
+ * @param {Map<string, string[]>} platformRunners - Platform → configured runners.
47
+ * @returns {object[]} Declared obligations.
48
+ */
49
+ function declaredObligations(scenarios, platformRunners) {
50
+ return scenarios
51
+ .filter(scenario => scenario.required)
52
+ .flatMap(scenario =>
53
+ scenario.platforms.map(platform => ({
54
+ key: `${scenario.id}:${platform}`,
55
+ scenario,
56
+ platform,
57
+ runners: platformRunners.get(platform) ?? [],
58
+ }))
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Index supplied execution results by runner, file, and evidence.
64
+ * @param {readonly object[]} runs - Parsed execution-result documents.
65
+ * @returns {Map<string, object>} Lookup keyed by `runner|file|evidence`.
66
+ */
67
+ function indexResults(runs) {
68
+ const index = new Map();
69
+ for (const run of runs) {
70
+ for (const result of run.results ?? []) {
71
+ index.set(`${run.runner}|${result.file}|${result.evidence}`, {
72
+ ...result,
73
+ runner: run.runner,
74
+ runId: run.runId ?? null,
75
+ });
76
+ }
77
+ }
78
+ return index;
79
+ }
80
+
81
+ /**
82
+ * Join mappings against supplied execution results.
83
+ *
84
+ * When no run evidence is supplied this returns `supplied: false` and no
85
+ * counts at all — it never reports zeros that could be read as "nothing
86
+ * passed" or ones that could be read as "everything ran".
87
+ * @param {readonly object[]} mappings - Coverage-map mappings.
88
+ * @param {readonly object[]} runs - Parsed execution-result documents.
89
+ * @returns {object} The execution block of the report.
90
+ */
91
+ export function buildExecution(mappings, runs) {
92
+ if (runs.length === 0) {
93
+ return {
94
+ supplied: false,
95
+ note: "No execution evidence supplied. Traceability below proves aligned automation exists, not that it ran or passed.",
96
+ sources: [],
97
+ mappedTests: distinctTests(mappings).length,
98
+ };
99
+ }
100
+ const index = indexResults(runs);
101
+ const tests = distinctTests(mappings);
102
+ const outcomes = tests.map(test => ({
103
+ ...test,
104
+ result: index.get(`${test.runner}|${test.file}|${test.evidence}`) ?? null,
105
+ }));
106
+ const count = status =>
107
+ outcomes.filter(item => item.result?.status === status).length;
108
+ return {
109
+ supplied: true,
110
+ sources: runs
111
+ .map(run => ({
112
+ runner: run.runner,
113
+ runId: run.runId ?? null,
114
+ completedAt: run.completedAt ?? null,
115
+ resultCount: (run.results ?? []).length,
116
+ }))
117
+ .sort((a, b) => a.runner.localeCompare(b.runner)),
118
+ mappedTests: tests.length,
119
+ executed: outcomes.filter(item => item.result !== null).length,
120
+ passed: count("passed"),
121
+ failed: count("failed"),
122
+ skipped: count("skipped"),
123
+ notRun: outcomes.filter(item => item.result === null).length,
124
+ notRunTests: outcomes
125
+ .filter(item => item.result === null)
126
+ .map(item => `${item.runner} ${item.file} :: ${item.evidence}`)
127
+ .sort(),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * The distinct automated tests the map points at.
133
+ * @param {readonly object[]} mappings - Coverage-map mappings.
134
+ * @returns {object[]} Distinct runner/file/evidence triples, sorted.
135
+ */
136
+ function distinctTests(mappings) {
137
+ const seen = new Map();
138
+ for (const mapping of mappings ?? []) {
139
+ const key = `${mapping.runner}|${mapping.file}|${mapping.evidence}`;
140
+ if (!seen.has(key)) {
141
+ seen.set(key, {
142
+ runner: mapping.runner,
143
+ file: mapping.file,
144
+ evidence: mapping.evidence,
145
+ });
146
+ }
147
+ }
148
+ return [...seen.values()].sort((a, b) =>
149
+ `${a.runner}${a.file}${a.evidence}`.localeCompare(
150
+ `${b.runner}${b.file}${b.evidence}`
151
+ )
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Evaluate the committed coverage floor per platform.
157
+ * @param {object} contract - Parsed coverage map.
158
+ * @param {object} byPlatform - Traceability summary per platform.
159
+ * @param {ReadonlySet<string>} platforms - Declared platform vocabulary.
160
+ * @returns {object} Floor evaluation, including which platforms declared none.
161
+ */
162
+ export function evaluateFloor(contract, byPlatform, platforms) {
163
+ const floors = contract.coverageFloor ?? {};
164
+ const entries = [...platforms].sort().map(platform => {
165
+ const declared = floors[platform];
166
+ const actual = byPlatform[platform]?.percentage ?? 100;
167
+ return [
168
+ platform,
169
+ {
170
+ floor: typeof declared === "number" ? declared : null,
171
+ actual,
172
+ ok: typeof declared !== "number" || actual + 1e-9 >= declared,
173
+ },
174
+ ];
175
+ });
176
+ return {
177
+ byPlatform: Object.fromEntries(entries),
178
+ unset: entries
179
+ .filter(([, value]) => value.floor === null)
180
+ .map(([name]) => name),
181
+ ok: entries.every(([, value]) => value.ok),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Collect every tracker reference declared across the contract.
187
+ * @param {readonly object[]} scenarios - Parsed scenarios.
188
+ * @param {object} trackers - `trackers` block of the coverage map.
189
+ * @returns {object} Tag inventory with emitted links.
190
+ */
191
+ export function buildTrackerIndex(scenarios, trackers) {
192
+ const tags = new Map();
193
+ for (const scenario of scenarios) {
194
+ for (const reference of scenario.trackers) {
195
+ const entry = tags.get(reference.tag) ?? {
196
+ tag: reference.tag,
197
+ url: trackerUrl(reference, trackers),
198
+ scenarios: [],
199
+ };
200
+ entry.scenarios.push(scenario.id);
201
+ tags.set(reference.tag, entry);
202
+ }
203
+ }
204
+ return {
205
+ scenariosWithTag: scenarios.filter(scenario => scenario.trackers.length > 0)
206
+ .length,
207
+ scenariosWithoutTag: scenarios.filter(
208
+ scenario => scenario.trackers.length === 0
209
+ ).length,
210
+ tags: [...tags.values()]
211
+ .map(entry => ({ ...entry, scenarios: [...entry.scenarios].sort() }))
212
+ .sort((a, b) => a.tag.localeCompare(b.tag)),
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Build the whole machine-readable report.
218
+ * @param {object} input - Scenarios, contract, execution runs, and platforms.
219
+ * @returns {object} The report envelope.
220
+ */
221
+ export function buildReport({ scenarios, contract, runs, platforms }) {
222
+ const platformRunners = runnersByPlatform(contract.runnerPlatforms);
223
+ const declared = declaredObligations(scenarios, platformRunners);
224
+ const waivedKeys = new Set(
225
+ (contract.platformWaivers ?? []).flatMap(waiver =>
226
+ (waiver.platforms ?? []).map(platform => `${waiver.scenario}:${platform}`)
227
+ )
228
+ );
229
+ const obligations = declared.filter(item => !waivedKeys.has(item.key));
230
+ const coveredKeys = coverageKeys(scenarios, contract);
231
+ const byPlatform = Object.fromEntries(
232
+ [...platforms].sort().map(platform => [
233
+ platform,
234
+ summarize(
235
+ obligations.filter(item => item.platform === platform),
236
+ coveredKeys
237
+ ),
238
+ ])
239
+ );
240
+ return {
241
+ schemaVersion: REPORT_SCHEMA_VERSION,
242
+ asOf: contract.asOf ?? null,
243
+ scenarios: countScenarios(scenarios),
244
+ 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.",
246
+ overall: summarize(obligations, coveredKeys),
247
+ byPlatform,
248
+ byRunner: byRunnerSummary(contract, obligations, coveredKeys),
249
+ },
250
+ execution: buildExecution(contract.mappings ?? [], runs),
251
+ waived: waivedSummary(contract, scenarios, declared, waivedKeys),
252
+ floor: evaluateFloor(contract, byPlatform, platforms),
253
+ trackers: buildTrackerIndex(scenarios, contract.trackers),
254
+ gaps: obligations
255
+ .filter(item => !coveredKeys.has(item.key))
256
+ .map(item => ({
257
+ scenario: item.scenario.id,
258
+ name: item.scenario.name,
259
+ feature: item.scenario.feature,
260
+ platform: item.platform,
261
+ runners: item.runners,
262
+ }))
263
+ .sort((a, b) =>
264
+ `${a.scenario}${a.platform}`.localeCompare(`${b.scenario}${b.platform}`)
265
+ ),
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Every scenario-platform key an existing mapping covers.
271
+ * @param {readonly object[]} scenarios - Parsed scenarios.
272
+ * @param {object} contract - Parsed coverage map.
273
+ * @returns {Set<string>} Covered keys.
274
+ */
275
+ function coverageKeys(scenarios, contract) {
276
+ const required = new Set(
277
+ scenarios.filter(scenario => scenario.required).map(scenario => scenario.id)
278
+ );
279
+ const covered = new Set();
280
+ for (const mapping of contract.mappings ?? []) {
281
+ if (!required.has(mapping.scenario)) continue;
282
+ for (const platform of mapping.platforms ?? []) {
283
+ covered.add(`${mapping.scenario}:${platform}`);
284
+ }
285
+ }
286
+ return covered;
287
+ }
288
+
289
+ /**
290
+ * Count declared scenarios by lifecycle.
291
+ * @param {readonly object[]} scenarios - Parsed scenarios.
292
+ * @returns {object} Declaration counts.
293
+ */
294
+ function countScenarios(scenarios) {
295
+ const withTag = tag =>
296
+ scenarios.filter(s => s.lifecycle.includes(tag)).length;
297
+ return {
298
+ declared: scenarios.length,
299
+ required: scenarios.filter(scenario => scenario.required).length,
300
+ excluded: scenarios.filter(scenario => !scenario.required).length,
301
+ blocked: withTag("blocked"),
302
+ referenceOnly: withTag("reference-only"),
303
+ superseded: withTag("superseded"),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Traceability summary per configured runner.
309
+ * @param {object} contract - Parsed coverage map.
310
+ * @param {readonly object[]} obligations - Non-waived obligations.
311
+ * @param {ReadonlySet<string>} coveredKeys - Covered keys.
312
+ * @returns {object} Per-runner summaries.
313
+ */
314
+ function byRunnerSummary(contract, obligations, coveredKeys) {
315
+ return Object.fromEntries(
316
+ Object.keys(contract.runnerPlatforms ?? {})
317
+ .sort()
318
+ .map(runner => [
319
+ runner,
320
+ summarize(
321
+ obligations.filter(item => item.runners.includes(runner)),
322
+ coveredKeys
323
+ ),
324
+ ])
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Summarize waived obligations with the full IOU record.
330
+ * @param {object} contract - Parsed coverage map.
331
+ * @param {readonly object[]} scenarios - Parsed scenarios.
332
+ * @param {readonly object[]} declared - All declared obligations.
333
+ * @param {ReadonlySet<string>} waivedKeys - Waived keys.
334
+ * @returns {object} Waiver summary.
335
+ */
336
+ function waivedSummary(contract, scenarios, declared, waivedKeys) {
337
+ const byId = new Map(scenarios.map(scenario => [scenario.id, scenario]));
338
+ const effective = declared.filter(item => waivedKeys.has(item.key));
339
+ const effectiveKeys = new Set(effective.map(item => item.key));
340
+ return {
341
+ note: "A waiver is a dated IOU with a named owner and a retiring ticket. It is never coverage.",
342
+ count: effective.length,
343
+ entries: (contract.platformWaivers ?? [])
344
+ .map(waiver => ({
345
+ scenario: waiver.scenario,
346
+ name: byId.get(waiver.scenario)?.name ?? null,
347
+ feature: byId.get(waiver.scenario)?.feature ?? null,
348
+ platforms: (waiver.platforms ?? []).filter(platform =>
349
+ effectiveKeys.has(`${waiver.scenario}:${platform}`)
350
+ ),
351
+ runner: waiver.runner ?? null,
352
+ owner: waiver.owner ?? null,
353
+ reason: waiver.reason ?? null,
354
+ ticket: waiver.ticket ?? null,
355
+ recordedAt: waiver.recordedAt ?? null,
356
+ expiresAt: waiver.expiresAt ?? null,
357
+ }))
358
+ .filter(entry => entry.platforms.length > 0)
359
+ .sort((a, b) => a.scenario.localeCompare(b.scenario)),
360
+ };
361
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Contract validators for the BDD gate.
3
+ *
4
+ * Every function returns a flat list of defects — `{code, message}` — so the
5
+ * caller decides whether a defect is fatal (enforced) or advisory
6
+ * (bootstrap). No validator ever exits or logs.
7
+ *
8
+ * @module scripts/bdd/validate
9
+ */
10
+ import * as fs from "node:fs";
11
+
12
+ import { runnersByPlatform } from "./contract.mjs";
13
+ import { resolveInsideRepo } from "./parse.mjs";
14
+
15
+ /**
16
+ * Build one defect record.
17
+ * @param {string} code - Stable machine-readable defect code.
18
+ * @param {string} message - Operator-readable description.
19
+ * @returns {{code: string, message: string}} The defect.
20
+ */
21
+ const defect = (code, message) => ({ code, message });
22
+
23
+ /**
24
+ * Structural checks every scenario owes regardless of coverage.
25
+ * @param {readonly object[]} scenarios - Parsed scenarios.
26
+ * @param {ReadonlySet<string>} platforms - Declared platform vocabulary.
27
+ * @returns {object[]} Defects found.
28
+ */
29
+ export function validateScenarios(scenarios, platforms) {
30
+ const defects = [];
31
+ const seen = new Map();
32
+ for (const scenario of scenarios) {
33
+ const at = `${scenario.file}:${scenario.line}`;
34
+ if (scenario.ids.length !== 1) {
35
+ defects.push(
36
+ defect(
37
+ "scenario-id",
38
+ `${at} must carry exactly one @BDD-DOMAIN-NNN tag`
39
+ )
40
+ );
41
+ }
42
+ if (scenario.platforms.length === 0) {
43
+ defects.push(
44
+ defect(
45
+ "scenario-platform",
46
+ `${at} declares no platform; expected one of ${[...platforms].sort().join(", ") || "(none configured)"}`
47
+ )
48
+ );
49
+ }
50
+ if (scenario.provenance.length === 0) {
51
+ defects.push(
52
+ defect(
53
+ "scenario-provenance",
54
+ `${at} declares no @figma-*/@ratified-* provenance`
55
+ )
56
+ );
57
+ }
58
+ if (scenario.lifecycle.length > 1) {
59
+ defects.push(
60
+ defect(
61
+ "scenario-lifecycle",
62
+ `${at} carries ${scenario.lifecycle.length} lifecycle tags (${scenario.lifecycle.join(", ")}); at most one is allowed`
63
+ )
64
+ );
65
+ }
66
+ defects.push(...missingSteps(scenario, at));
67
+ if (scenario.id) defects.push(...recordId(scenario, at, seen));
68
+ }
69
+ return defects;
70
+ }
71
+
72
+ /**
73
+ * Require a primary Given/When/Then in each scenario.
74
+ * @param {object} scenario - Parsed scenario.
75
+ * @param {string} at - Source location.
76
+ * @returns {object[]} Defects found.
77
+ */
78
+ function missingSteps(scenario, at) {
79
+ return ["Given", "When", "Then"]
80
+ .filter(step => !scenario.primarySteps.includes(step))
81
+ .map(step => defect("scenario-steps", `${at} has no primary ${step} step`));
82
+ }
83
+
84
+ /**
85
+ * Reject duplicate scenario IDs across the whole contract.
86
+ * @param {object} scenario - Parsed scenario.
87
+ * @param {string} at - Source location.
88
+ * @param {Map<string, string>} seen - Already-claimed IDs.
89
+ * @returns {object[]} Defects found.
90
+ */
91
+ function recordId(scenario, at, seen) {
92
+ if (seen.has(scenario.id)) {
93
+ return [
94
+ defect(
95
+ "scenario-duplicate-id",
96
+ `${at} reuses ${scenario.id}, already declared at ${seen.get(scenario.id)}`
97
+ ),
98
+ ];
99
+ }
100
+ seen.set(scenario.id, at);
101
+ return [];
102
+ }
103
+
104
+ /**
105
+ * Validate tracker tags: syntax is universal, membership is per-repo.
106
+ *
107
+ * A tracker-shaped tag naming a key or repo the project never declared is an
108
+ * ORPHAN — it links nowhere and silently breaks traceability, so it fails
109
+ * rather than being ignored. Tracker liveness is never checked.
110
+ * @param {readonly object[]} scenarios - Parsed scenarios.
111
+ * @param {object} trackers - `trackers` block of the coverage map.
112
+ * @returns {object[]} Defects found.
113
+ */
114
+ export function validateTrackerTags(scenarios, trackers) {
115
+ const keys = new Set(trackers?.keys ?? []);
116
+ const repos = new Set(trackers?.github?.repos ?? []);
117
+ const defaultRepo = trackers?.github?.defaultRepo ?? null;
118
+ const required = trackers?.required === true;
119
+ const defects = [];
120
+ for (const scenario of scenarios) {
121
+ const at = `${scenario.file}:${scenario.line}`;
122
+ if (required && scenario.trackers.length === 0) {
123
+ defects.push(
124
+ defect(
125
+ "tracker-missing",
126
+ `${at} carries no tracker tag (trackers.required is true)`
127
+ )
128
+ );
129
+ }
130
+ for (const reference of scenario.trackers) {
131
+ const orphan = orphanReason(reference, { keys, repos, defaultRepo });
132
+ if (orphan) {
133
+ defects.push(
134
+ defect("tracker-orphan", `${at} @${reference.tag}: ${orphan}`)
135
+ );
136
+ }
137
+ }
138
+ }
139
+ return defects;
140
+ }
141
+
142
+ /**
143
+ * The reason a syntactically valid tracker tag points nowhere, if any.
144
+ * @param {object} reference - Parsed tracker reference.
145
+ * @param {object} declared - Declared keys, repos, and default repo.
146
+ * @returns {string|undefined} The orphan reason, or undefined when resolvable.
147
+ */
148
+ function orphanReason(reference, { keys, repos, defaultRepo }) {
149
+ if (reference.scheme === "key") {
150
+ return keys.has(reference.key)
151
+ ? undefined
152
+ : `unknown tracker key ${reference.key}; declare it in trackers.keys`;
153
+ }
154
+ const repo = reference.repo ?? defaultRepo;
155
+ if (!repo) {
156
+ return "no repo named and trackers.github.defaultRepo is unset";
157
+ }
158
+ return repos.has(repo)
159
+ ? undefined
160
+ : `unknown repo ${repo}; declare it in trackers.github.repos`;
161
+ }
162
+
163
+ /**
164
+ * Validate every scenario→test mapping, including that its evidence still
165
+ * exists inside the mapped file.
166
+ * @param {object} input - Root, scenarios, and the parsed contract.
167
+ * @returns {object[]} Defects found.
168
+ */
169
+ export function validateMappings({ root, scenarios, contract }) {
170
+ const byId = new Map(scenarios.map(scenario => [scenario.id, scenario]));
171
+ const platformRunners = runnersByPlatform(contract.runnerPlatforms);
172
+ const defects = [];
173
+ const seen = new Set();
174
+ for (const [index, mapping] of (contract.mappings ?? []).entries()) {
175
+ const at = `coverage-map.mappings[${index}] ${mapping.scenario ?? "(no scenario)"}`;
176
+ const scenario = byId.get(mapping.scenario);
177
+ if (!scenario) {
178
+ defects.push(
179
+ defect("mapping-orphan", `${at}: names a scenario that does not exist`)
180
+ );
181
+ continue;
182
+ }
183
+ if (!contract.runnerPlatforms?.[mapping.runner]) {
184
+ defects.push(
185
+ defect("mapping-runner", `${at}: unknown runner ${mapping.runner}`)
186
+ );
187
+ continue;
188
+ }
189
+ defects.push(...mappingDuplicates(mapping, at, seen));
190
+ defects.push(...mappingPlatforms(mapping, scenario, platformRunners, at));
191
+ defects.push(...mappingEvidence(root, mapping, at));
192
+ }
193
+ return defects;
194
+ }
195
+
196
+ /**
197
+ * Reject the same scenario+runner+platform claimed twice.
198
+ * @param {object} mapping - Raw mapping entry.
199
+ * @param {string} at - Location label.
200
+ * @param {Set<string>} seen - Already-claimed keys.
201
+ * @returns {object[]} Defects found.
202
+ */
203
+ function mappingDuplicates(mapping, at, seen) {
204
+ const defects = [];
205
+ for (const platform of mapping.platforms ?? []) {
206
+ const key = `${mapping.scenario}:${mapping.runner}:${platform}`;
207
+ if (seen.has(key)) {
208
+ defects.push(
209
+ defect("mapping-duplicate", `${at}: duplicate mapping for ${key}`)
210
+ );
211
+ }
212
+ seen.add(key);
213
+ }
214
+ return defects;
215
+ }
216
+
217
+ /**
218
+ * Check a mapping only claims platforms the scenario requires and its runner
219
+ * is configured to cover.
220
+ * @param {object} mapping - Raw mapping entry.
221
+ * @param {object} scenario - The scenario it names.
222
+ * @param {Map<string, string[]>} platformRunners - Platform → configured runners.
223
+ * @param {string} at - Location label.
224
+ * @returns {object[]} Defects found.
225
+ */
226
+ function mappingPlatforms(mapping, scenario, platformRunners, at) {
227
+ if (!Array.isArray(mapping.platforms) || mapping.platforms.length === 0) {
228
+ return [defect("mapping-platform", `${at}: claims no platforms`)];
229
+ }
230
+ const defects = [];
231
+ for (const platform of mapping.platforms) {
232
+ if (!scenario.platforms.includes(platform)) {
233
+ defects.push(
234
+ defect(
235
+ "mapping-platform",
236
+ `${at}: scenario does not require ${platform}`
237
+ )
238
+ );
239
+ }
240
+ if (!(platformRunners.get(platform) ?? []).includes(mapping.runner)) {
241
+ defects.push(
242
+ defect(
243
+ "mapping-runner",
244
+ `${at}: runner ${mapping.runner} is not configured for ${platform}`
245
+ )
246
+ );
247
+ }
248
+ }
249
+ return defects;
250
+ }
251
+
252
+ /**
253
+ * Prove the mapped file still contains the exact evidence string.
254
+ *
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.
258
+ * @param {string} root - Repo root.
259
+ * @param {object} mapping - Raw mapping entry.
260
+ * @param {string} at - Location label.
261
+ * @returns {object[]} Defects found.
262
+ */
263
+ function mappingEvidence(root, mapping, at) {
264
+ if (typeof mapping.evidence !== "string" || mapping.evidence.length === 0) {
265
+ return [defect("mapping-evidence", `${at}: declares no evidence string`)];
266
+ }
267
+ const resolved = resolveInsideRepo(root, mapping.file);
268
+ if (!resolved.path) {
269
+ return [
270
+ defect("mapping-file", `${at}: ${mapping.file} — ${resolved.error}`),
271
+ ];
272
+ }
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
+ ];
280
+ }
281
+ return [];
282
+ }