@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.
- package/dist/cli/doctor-learnings-ledger.d.ts +23 -0
- package/dist/cli/doctor-learnings-ledger.d.ts.map +1 -0
- package/dist/cli/doctor-learnings-ledger.js +67 -0
- package/dist/cli/doctor-learnings-ledger.js.map +1 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +6 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/learnings-file-safety.d.ts +12 -1
- package/dist/core/learnings-file-safety.d.ts.map +1 -1
- package/dist/core/learnings-file-safety.js +20 -1
- package/dist/core/learnings-file-safety.js.map +1 -1
- package/dist/core/learnings-location.d.ts +40 -0
- package/dist/core/learnings-location.d.ts.map +1 -0
- package/dist/core/learnings-location.js +89 -0
- package/dist/core/learnings-location.js.map +1 -0
- package/dist/core/learnings-stray-ledger.d.ts +24 -0
- package/dist/core/learnings-stray-ledger.d.ts.map +1 -0
- package/dist/core/learnings-stray-ledger.js +155 -0
- package/dist/core/learnings-stray-ledger.js.map +1 -0
- package/dist/core/project-config.d.ts +1 -13
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +5 -53
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +46 -7
- package/expo/copy-overwrite/scripts/bdd/parse.mjs +22 -2
- package/expo/copy-overwrite/scripts/bdd/render.mjs +34 -7
- package/expo/copy-overwrite/scripts/bdd/report.mjs +118 -18
- package/expo/copy-overwrite/scripts/bdd/validate.mjs +95 -22
- package/expo/copy-overwrite/scripts/bdd-matrix.mjs +5 -32
- package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +29 -3
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- 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 =>
|
|
96
|
-
.filter(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
235
|
+
floor: declared.value,
|
|
236
|
+
state: declared.state,
|
|
171
237
|
actual,
|
|
172
|
-
ok:
|
|
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.
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
*
|
|
278
|
+
* Whether a mapping's evidence string still resolves inside the repo.
|
|
254
279
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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}
|
|
261
|
-
* @returns {
|
|
286
|
+
* @param {Map<string, string>} [cache] - Optional file-content cache.
|
|
287
|
+
* @returns {{ok: boolean, code?: string, detail?: string}} The verdict.
|
|
262
288
|
*/
|
|
263
|
-
function
|
|
289
|
+
export function evidenceResolves(root, mapping, cache = new Map()) {
|
|
264
290
|
if (typeof mapping.evidence !== "string" || mapping.evidence.length === 0) {
|
|
265
|
-
return
|
|
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
|
-
|
|
271
|
-
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
code: "mapping-file",
|
|
302
|
+
detail: `${mapping.file} — ${resolved.error}`,
|
|
303
|
+
};
|
|
272
304
|
}
|
|
273
|
-
if (!
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|