@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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +36 -1
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +224 -0
- package/expo/copy-overwrite/scripts/bdd/contract.mjs +121 -0
- package/expo/copy-overwrite/scripts/bdd/envelope.mjs +231 -0
- package/expo/copy-overwrite/scripts/bdd/parse.mjs +220 -0
- package/expo/copy-overwrite/scripts/bdd/render.mjs +170 -0
- package/expo/copy-overwrite/scripts/bdd/report.mjs +361 -0
- package/expo/copy-overwrite/scripts/bdd/validate.mjs +282 -0
- package/expo/copy-overwrite/scripts/bdd/waivers.mjs +210 -0
- package/expo/copy-overwrite/scripts/bdd-matrix.mjs +278 -0
- package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +657 -0
- package/expo/create-only/bdd/coverage-map.json +38 -0
- package/expo/create-only/bdd/features/.keep +5 -0
- package/expo/github-rulesets/bdd-coverage.json +33 -0
- package/expo/package-lisa/package.lisa.json +3 -0
- 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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-revision comparisons: the coverage-floor ratchet and the
|
|
3
|
+
* scenario-deletion check.
|
|
4
|
+
*
|
|
5
|
+
* Both answer the same question — "did this change make the number look
|
|
6
|
+
* better by lowering the bar instead of raising the work?" — and both need a
|
|
7
|
+
* base revision to answer it, so they share one git read.
|
|
8
|
+
*
|
|
9
|
+
* @module scripts/bdd/baseline
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
import { scenarioIdsIn } from "./parse.mjs";
|
|
14
|
+
|
|
15
|
+
const defect = (code, message) => ({ code, message });
|
|
16
|
+
|
|
17
|
+
/** The maintainer-applied PR label that authorizes a floor reduction. */
|
|
18
|
+
export const BASELINE_LABEL = "bdd-floor-baseline";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read one path at a git revision.
|
|
22
|
+
* @param {string} root - Repo root.
|
|
23
|
+
* @param {string} revision - Commit-ish.
|
|
24
|
+
* @param {string} relative - Repo-relative path.
|
|
25
|
+
* @returns {string|null} File contents, or null when absent at that revision.
|
|
26
|
+
*/
|
|
27
|
+
export function showAtRevision(root, revision, relative) {
|
|
28
|
+
const result = spawnSync("git", ["show", `${revision}:${relative}`], {
|
|
29
|
+
cwd: root,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
return result.status === 0 ? result.stdout : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* List the feature files that existed at a revision.
|
|
38
|
+
* @param {string} root - Repo root.
|
|
39
|
+
* @param {string} revision - Commit-ish.
|
|
40
|
+
* @returns {string[]} Repo-relative feature paths.
|
|
41
|
+
*/
|
|
42
|
+
function featureFilesAt(root, revision) {
|
|
43
|
+
const result = spawnSync(
|
|
44
|
+
"git",
|
|
45
|
+
["ls-tree", "-r", "--name-only", revision, "--", "bdd/features"],
|
|
46
|
+
{ cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
|
|
47
|
+
);
|
|
48
|
+
if (result.status !== 0) return [];
|
|
49
|
+
return result.stdout
|
|
50
|
+
.split("\n")
|
|
51
|
+
.map(line => line.trim())
|
|
52
|
+
.filter(line => line.endsWith(".feature"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load the base revision's contract and scenario IDs.
|
|
57
|
+
* @param {string} root - Repo root.
|
|
58
|
+
* @param {string} revision - Base commit-ish.
|
|
59
|
+
* @returns {{available: boolean, contract: object|null, scenarioIds: Set<string>}} Base state.
|
|
60
|
+
*/
|
|
61
|
+
export function loadBaseline(root, revision) {
|
|
62
|
+
const raw = showAtRevision(root, revision, "bdd/coverage-map.json");
|
|
63
|
+
let contract = null;
|
|
64
|
+
if (raw !== null) {
|
|
65
|
+
try {
|
|
66
|
+
contract = JSON.parse(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
contract = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const sources = featureFilesAt(root, revision)
|
|
72
|
+
.map(file => showAtRevision(root, revision, file))
|
|
73
|
+
.filter(source => source !== null);
|
|
74
|
+
return {
|
|
75
|
+
available: raw !== null || sources.length > 0,
|
|
76
|
+
contract,
|
|
77
|
+
scenarioIds: scenarioIdsIn(sources),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The coverage-floor ratchet: a floor may rise, and may never fall.
|
|
83
|
+
*
|
|
84
|
+
* Lowering it takes TWO artifacts that one author cannot produce by editing
|
|
85
|
+
* one file: a `coverageFloorBaseline` record naming the exact drop, its
|
|
86
|
+
* reason, ticket, approver and authorizing run, AND the maintainer-applied
|
|
87
|
+
* `bdd-floor-baseline` label on the pull request. Either alone fails.
|
|
88
|
+
* @param {object} input - Base and head contracts plus the PR labels.
|
|
89
|
+
* @returns {object[]} Defects found.
|
|
90
|
+
*/
|
|
91
|
+
export function checkRatchet({ baseContract, contract, labels }) {
|
|
92
|
+
const before = baseContract?.coverageFloor ?? {};
|
|
93
|
+
const after = contract.coverageFloor ?? {};
|
|
94
|
+
const drops = Object.keys(before)
|
|
95
|
+
.filter(platform => typeof before[platform] === "number")
|
|
96
|
+
.filter(platform => (after[platform] ?? -Infinity) < before[platform])
|
|
97
|
+
.map(platform => ({
|
|
98
|
+
platform,
|
|
99
|
+
from: before[platform],
|
|
100
|
+
to: typeof after[platform] === "number" ? after[platform] : null,
|
|
101
|
+
}));
|
|
102
|
+
return drops.flatMap(drop => ratchetDefects(drop, contract, labels));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Defects for one attempted floor reduction.
|
|
107
|
+
* @param {object} drop - The observed reduction.
|
|
108
|
+
* @param {object} contract - Head contract.
|
|
109
|
+
* @param {readonly string[]} labels - PR labels.
|
|
110
|
+
* @returns {object[]} Defects found.
|
|
111
|
+
*/
|
|
112
|
+
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}`;
|
|
117
|
+
const record = (contract.coverageFloorBaseline ?? []).find(
|
|
118
|
+
entry =>
|
|
119
|
+
entry.platform === drop.platform &&
|
|
120
|
+
entry.from === drop.from &&
|
|
121
|
+
entry.to === drop.to
|
|
122
|
+
);
|
|
123
|
+
const defects = [];
|
|
124
|
+
if (!record) {
|
|
125
|
+
defects.push(
|
|
126
|
+
defect(
|
|
127
|
+
"floor-ratchet",
|
|
128
|
+
`${what}: the floor is a ratchet. A reduction needs a coverageFloorBaseline record naming this exact change (platform, from, to, reason, ticket, approvedBy, runUrl).`
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
defects.push(...baselineRecordDefects(record, what));
|
|
133
|
+
}
|
|
134
|
+
if (!labels.includes(BASELINE_LABEL)) {
|
|
135
|
+
defects.push(
|
|
136
|
+
defect(
|
|
137
|
+
"floor-ratchet",
|
|
138
|
+
`${what}: requires the maintainer-applied "${BASELINE_LABEL}" label. Changing the floor in the same pull request that changes the code is not an authorization.`
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return defects;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Completeness of a baseline-update record. `runUrl` is validated for shape
|
|
147
|
+
* only — the gate never contacts a tracker or CI API, so a merge can never
|
|
148
|
+
* depend on one being reachable.
|
|
149
|
+
* @param {object} record - The coverageFloorBaseline entry.
|
|
150
|
+
* @param {string} what - Human description of the drop.
|
|
151
|
+
* @returns {object[]} Defects found.
|
|
152
|
+
*/
|
|
153
|
+
function baselineRecordDefects(record, what) {
|
|
154
|
+
const defects = ["reason", "ticket", "approvedBy", "runUrl", "recordedAt"]
|
|
155
|
+
.filter(field => !record[field])
|
|
156
|
+
.map(field =>
|
|
157
|
+
defect(
|
|
158
|
+
"floor-ratchet",
|
|
159
|
+
`${what}: coverageFloorBaseline record has no ${field}`
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
if (record.runUrl && !/^https:\/\/\S+$/.test(String(record.runUrl))) {
|
|
163
|
+
defects.push(
|
|
164
|
+
defect(
|
|
165
|
+
"floor-ratchet",
|
|
166
|
+
`${what}: coverageFloorBaseline.runUrl must be an https URL`
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return defects;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Refuse scenario deletion used to shrink the denominator.
|
|
175
|
+
*
|
|
176
|
+
* The contract's answer to a retired behavior is `@superseded`, which keeps
|
|
177
|
+
* the audit trail. Deleting the scenario instead makes coverage look better
|
|
178
|
+
* by describing less of the product.
|
|
179
|
+
* @param {object} input - Base IDs, head scenarios, and the PR labels.
|
|
180
|
+
* @returns {object[]} Defects found.
|
|
181
|
+
*/
|
|
182
|
+
export function checkDeletions({ baseIds, scenarios, contract, labels }) {
|
|
183
|
+
const present = new Set(scenarios.map(scenario => scenario.id));
|
|
184
|
+
const retired = new Map(
|
|
185
|
+
(contract.retirements ?? []).map(entry => [entry.scenario, entry])
|
|
186
|
+
);
|
|
187
|
+
return [...baseIds]
|
|
188
|
+
.filter(id => !present.has(id))
|
|
189
|
+
.sort()
|
|
190
|
+
.flatMap(id => deletionDefects(id, retired.get(id), labels));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Defects for one deleted scenario.
|
|
195
|
+
* @param {string} id - The scenario ID that disappeared.
|
|
196
|
+
* @param {object|undefined} record - Its retirement record, if any.
|
|
197
|
+
* @param {readonly string[]} labels - PR labels.
|
|
198
|
+
* @returns {object[]} Defects found.
|
|
199
|
+
*/
|
|
200
|
+
function deletionDefects(id, record, labels) {
|
|
201
|
+
if (!record) {
|
|
202
|
+
return [
|
|
203
|
+
defect(
|
|
204
|
+
"scenario-deleted",
|
|
205
|
+
`${id} was deleted from the contract. Retiring a behavior is @superseded, not deletion — deleting it shrinks the denominator instead of the gap. A genuine removal needs a retirements record and the "${BASELINE_LABEL}" label.`
|
|
206
|
+
),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
const missing = ["reason", "ticket", "approvedBy", "recordedAt"].filter(
|
|
210
|
+
field => !record[field]
|
|
211
|
+
);
|
|
212
|
+
const defects = missing.map(field =>
|
|
213
|
+
defect("scenario-deleted", `${id}: retirements record has no ${field}`)
|
|
214
|
+
);
|
|
215
|
+
if (!labels.includes(BASELINE_LABEL)) {
|
|
216
|
+
defects.push(
|
|
217
|
+
defect(
|
|
218
|
+
"scenario-deleted",
|
|
219
|
+
`${id}: a retirement needs the maintainer-applied "${BASELINE_LABEL}" label`
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return defects;
|
|
224
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary for the BDD behavior contract gate.
|
|
3
|
+
*
|
|
4
|
+
* Everything a project may vary — platform names, runner names, tracker
|
|
5
|
+
* prefixes — is read from `bdd/coverage-map.json`. Nothing in this module
|
|
6
|
+
* names a test runner or a tracker vendor: that is the point of the contract
|
|
7
|
+
* (see Lisa rule `bdd-e2e-coverage`).
|
|
8
|
+
*
|
|
9
|
+
* @module scripts/bdd/contract
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Report/envelope schema emitted by the gate. See docs/bdd-coverage-schema.md. */
|
|
13
|
+
export const REPORT_SCHEMA_VERSION = 2;
|
|
14
|
+
|
|
15
|
+
/** Coverage-map schema versions this gate can read. */
|
|
16
|
+
export const SUPPORTED_MAP_SCHEMA_VERSIONS = [1, 2];
|
|
17
|
+
|
|
18
|
+
/** Stable scenario identity. Never renumbered, never reused. */
|
|
19
|
+
export const ID_PATTERN = /^BDD-[A-Z][A-Z0-9]*-\d{3,}$/;
|
|
20
|
+
|
|
21
|
+
/** Lifecycle tags that remove a scenario from the coverage denominator. */
|
|
22
|
+
export const LIFECYCLE_TAGS = ["blocked", "reference-only", "superseded"];
|
|
23
|
+
|
|
24
|
+
/** Provenance tags tying a scenario to the authority it came from. */
|
|
25
|
+
export const PROVENANCE_PATTERN = /^(?:figma-|ratified-)/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* THE portfolio tracker-tag grammar (one grammar, two schemes).
|
|
29
|
+
*
|
|
30
|
+
* 1. Key style — `@<KEY>-<number>`, for key-based trackers (Jira, Linear).
|
|
31
|
+
* `@TUN-123`, `@SE-6833`. KEY is 2-10 uppercase alphanumerics starting
|
|
32
|
+
* with a letter.
|
|
33
|
+
* 2. Repo-issue style — `@gh-<number>` for an issue in this repo, or
|
|
34
|
+
* `@gh-<repo-slug>-<number>` for a sibling repo in the same org.
|
|
35
|
+
* `@gh-2394`, `@gh-wiki-124`.
|
|
36
|
+
*
|
|
37
|
+
* The allowed KEYs and repo slugs are PER-REPO CONFIGURATION (`trackers` in
|
|
38
|
+
* the coverage map), never a list baked into this script: a global
|
|
39
|
+
* enumeration would mean a new project key could not be referenced until a
|
|
40
|
+
* new Lisa release shipped, which inverts the pinning contract (Lisa A5).
|
|
41
|
+
*
|
|
42
|
+
* Validation is SYNTACTIC plus membership in the repo's own declared sets.
|
|
43
|
+
* The gate never contacts a tracker — issue liveness must not be a merge
|
|
44
|
+
* dependency.
|
|
45
|
+
*/
|
|
46
|
+
export const TRACKER_KEY_PATTERN = /^([A-Z][A-Z0-9]{1,9})-(\d+)$/;
|
|
47
|
+
|
|
48
|
+
/** Repo-issue style tracker tag. Group 1 is an optional repo slug. */
|
|
49
|
+
export const TRACKER_GH_PATTERN = /^gh-(?:([a-z0-9]+(?:-[a-z0-9]+)*)-)?(\d+)$/;
|
|
50
|
+
|
|
51
|
+
/** Adoption states. The ruleset context is required ONLY in `enforced`. */
|
|
52
|
+
export const ADOPTION_STATES = ["not-adopted", "bootstrap", "enforced"];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Classify a raw tag as a tracker reference, if it is one.
|
|
56
|
+
*
|
|
57
|
+
* A tag is *shaped like* a tracker reference independently of whether the
|
|
58
|
+
* repo declared it — that is what makes an orphan tag detectable rather than
|
|
59
|
+
* silently ignored as "some other tag".
|
|
60
|
+
* @param {string} tag - Raw tag text with the leading `@` already stripped.
|
|
61
|
+
* @returns {{scheme: string, key?: string, repo: string|null, number: number}|null} Parsed reference, or null when the tag is not tracker-shaped.
|
|
62
|
+
*/
|
|
63
|
+
export function parseTrackerTag(tag) {
|
|
64
|
+
const gh = TRACKER_GH_PATTERN.exec(tag);
|
|
65
|
+
if (gh) {
|
|
66
|
+
return { scheme: "gh", repo: gh[1] ?? null, number: Number(gh[2]) };
|
|
67
|
+
}
|
|
68
|
+
const key = TRACKER_KEY_PATTERN.exec(tag);
|
|
69
|
+
if (key) {
|
|
70
|
+
return { scheme: "key", key: key[1], repo: null, number: Number(key[2]) };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Render a tracker reference as a URL from the repo's declared templates.
|
|
77
|
+
*
|
|
78
|
+
* Emitting the link is a convenience; it is never fetched.
|
|
79
|
+
* @param {object} reference - Parsed reference from {@link parseTrackerTag}.
|
|
80
|
+
* @param {object} trackers - The `trackers` block of the coverage map.
|
|
81
|
+
* @returns {string|null} An absolute URL, or null when no template is declared.
|
|
82
|
+
*/
|
|
83
|
+
export function trackerUrl(reference, trackers) {
|
|
84
|
+
if (reference.scheme === "gh") {
|
|
85
|
+
const org = trackers?.github?.org;
|
|
86
|
+
const repo = reference.repo ?? trackers?.github?.defaultRepo;
|
|
87
|
+
if (!org || !repo) return null;
|
|
88
|
+
return `https://github.com/${org}/${repo}/issues/${reference.number}`;
|
|
89
|
+
}
|
|
90
|
+
const template = trackers?.keyUrlTemplate;
|
|
91
|
+
if (!template) return null;
|
|
92
|
+
return template
|
|
93
|
+
.replace("{key}", reference.key)
|
|
94
|
+
.replace("{number}", String(reference.number))
|
|
95
|
+
.replace("{id}", `${reference.key}-${reference.number}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Invert `runnerPlatforms` into platform → runners.
|
|
100
|
+
* @param {Record<string, readonly string[]>} runnerPlatforms - Declared runner coverage.
|
|
101
|
+
* @returns {Map<string, string[]>} Platform to the runners configured for it.
|
|
102
|
+
*/
|
|
103
|
+
export function runnersByPlatform(runnerPlatforms) {
|
|
104
|
+
const index = new Map();
|
|
105
|
+
for (const [runner, platforms] of Object.entries(runnerPlatforms ?? {})) {
|
|
106
|
+
for (const platform of platforms ?? []) {
|
|
107
|
+
index.set(platform, [...(index.get(platform) ?? []), runner]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const runners of index.values()) runners.sort();
|
|
111
|
+
return index;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The project's own platform vocabulary, derived from its runner declaration.
|
|
116
|
+
* @param {Record<string, readonly string[]>} runnerPlatforms - Declared runner coverage.
|
|
117
|
+
* @returns {Set<string>} Every platform some configured runner covers.
|
|
118
|
+
*/
|
|
119
|
+
export function declaredPlatforms(runnerPlatforms) {
|
|
120
|
+
return new Set(runnersByPlatform(runnerPlatforms).keys());
|
|
121
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conform the BDD gate's result to Lisa's standard command envelope.
|
|
3
|
+
*
|
|
4
|
+
* The envelope contract is owned by `scripts/lisa-command-envelope.mjs` and
|
|
5
|
+
* its published schema; this module only maps the gate's vocabulary onto it.
|
|
6
|
+
* Inventing a second result shape is exactly what the envelope exists to
|
|
7
|
+
* prevent, so the envelope is BUILT BY that module whenever it is present —
|
|
8
|
+
* which also means its validator, not this file, decides conformance.
|
|
9
|
+
*
|
|
10
|
+
* @module scripts/bdd/envelope
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where the shared envelope module can legitimately live, as SOURCE
|
|
19
|
+
* CONSTANTS — an allowlist, never a search of whatever happens to be nearby.
|
|
20
|
+
*
|
|
21
|
+
* First entry is the adopter layout (`all/copy-overwrite/scripts/` and
|
|
22
|
+
* `expo/copy-overwrite/scripts/` both land in the project's `scripts/`).
|
|
23
|
+
* Second is Lisa's own tree, where the two live in different template
|
|
24
|
+
* directories and this file is exercised by Lisa's tests.
|
|
25
|
+
*/
|
|
26
|
+
const ENVELOPE_MODULE_PATHS = Object.freeze([
|
|
27
|
+
"./lisa-command-envelope.mjs",
|
|
28
|
+
"../../../all/copy-overwrite/scripts/lisa-command-envelope.mjs",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Defect codes bootstrap is PERMITTED to downgrade to a warning.
|
|
33
|
+
*
|
|
34
|
+
* This is an allowlist and it is load-bearing: an unrecognized code — a new
|
|
35
|
+
* check, a typo, a future contributor's addition — is treated as FATAL. A
|
|
36
|
+
* denylist of "fatal codes" would fail OPEN on exactly the value nobody
|
|
37
|
+
* anticipated, which is how a gate quietly stops gating.
|
|
38
|
+
*
|
|
39
|
+
* Everything here is a contract-QUALITY defect: the manifest is legible and
|
|
40
|
+
* the adoption state is coherent, the contract just is not clean yet. Codes
|
|
41
|
+
* about adoption integrity itself (bootstrap-metadata, bootstrap-expired,
|
|
42
|
+
* adoption-drift, config-*) are deliberately absent, so they always fail.
|
|
43
|
+
*/
|
|
44
|
+
export const WARNABLE_DEFECT_CODES = Object.freeze([
|
|
45
|
+
"baseline",
|
|
46
|
+
"empty-contract",
|
|
47
|
+
"execution-results",
|
|
48
|
+
"floor-missing",
|
|
49
|
+
"floor-ratchet",
|
|
50
|
+
"floor-regression",
|
|
51
|
+
"mapping-duplicate",
|
|
52
|
+
"mapping-evidence",
|
|
53
|
+
"mapping-file",
|
|
54
|
+
"mapping-orphan",
|
|
55
|
+
"mapping-platform",
|
|
56
|
+
"mapping-runner",
|
|
57
|
+
"scenario-deleted",
|
|
58
|
+
"scenario-duplicate-id",
|
|
59
|
+
"scenario-id",
|
|
60
|
+
"scenario-lifecycle",
|
|
61
|
+
"scenario-platform",
|
|
62
|
+
"scenario-provenance",
|
|
63
|
+
"scenario-steps",
|
|
64
|
+
"tracker-missing",
|
|
65
|
+
"tracker-orphan",
|
|
66
|
+
"waiver-duplicate",
|
|
67
|
+
"waiver-excluded",
|
|
68
|
+
"waiver-expired",
|
|
69
|
+
"waiver-masks-mapping",
|
|
70
|
+
"waiver-metadata",
|
|
71
|
+
"waiver-orphan",
|
|
72
|
+
"waiver-platform",
|
|
73
|
+
"waiver-runner",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Envelope statuses that may exit 0.
|
|
78
|
+
*
|
|
79
|
+
* Mirrors `SUCCESS_STATUSES` in the shared envelope module. It is restated
|
|
80
|
+
* here because this gate must decide its exit code without waiting on a
|
|
81
|
+
* dynamic import — and a unit test asserts the two lists are identical, so the
|
|
82
|
+
* copy cannot drift silently.
|
|
83
|
+
*/
|
|
84
|
+
export const SUCCESS_STATUSES = Object.freeze([
|
|
85
|
+
"completed",
|
|
86
|
+
"no-op",
|
|
87
|
+
"not-adopted",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** Message prefixes that already name the subject a finding is about. */
|
|
91
|
+
const SUBJECT_PREFIX = /^(?:bdd\/|coverage-map\.|BDD-[A-Z]|coverageFloor\.)/;
|
|
92
|
+
|
|
93
|
+
/** Fallback subjects by code family, so every finding names something real. */
|
|
94
|
+
const SUBJECT_BY_FAMILY = Object.freeze([
|
|
95
|
+
["scenario-", "bdd/features"],
|
|
96
|
+
["tracker-", "bdd/features"],
|
|
97
|
+
["execution-results", "execution results"],
|
|
98
|
+
["baseline", "base revision"],
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The entity a finding is about.
|
|
103
|
+
*
|
|
104
|
+
* Every defect message already begins with its location, so the subject is
|
|
105
|
+
* read from there rather than restated — a second copy would drift.
|
|
106
|
+
* @param {{code: string, message: string, subject?: string}} item - A defect.
|
|
107
|
+
* @returns {string} A non-empty subject.
|
|
108
|
+
*/
|
|
109
|
+
export function subjectFor(item) {
|
|
110
|
+
if (item.subject) return item.subject;
|
|
111
|
+
const head = item.message.split(/\s+/)[0] ?? "";
|
|
112
|
+
if (SUBJECT_PREFIX.test(head)) return head.replace(/[:,]$/, "");
|
|
113
|
+
const family = SUBJECT_BY_FAMILY.find(([prefix]) =>
|
|
114
|
+
item.code.startsWith(prefix)
|
|
115
|
+
);
|
|
116
|
+
return family ? family[1] : "bdd/coverage-map.json";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether any defect must fail the run, given the adoption state.
|
|
121
|
+
*
|
|
122
|
+
* `enforced` fails on any defect at all. `bootstrap` fails on any defect
|
|
123
|
+
* whose code is NOT on the warnable allowlist. `not-adopted` reaches here
|
|
124
|
+
* only with adoption-integrity defects, which are never warnable.
|
|
125
|
+
* @param {string} adoptionState - not-adopted | bootstrap | enforced.
|
|
126
|
+
* @param {readonly object[]} defects - Defects found.
|
|
127
|
+
* @returns {boolean} True when the run must fail.
|
|
128
|
+
*/
|
|
129
|
+
export function hasFatalDefect(adoptionState, defects) {
|
|
130
|
+
if (defects.length === 0) return false;
|
|
131
|
+
if (adoptionState === "enforced") return true;
|
|
132
|
+
return defects.some(item => !WARNABLE_DEFECT_CODES.includes(item.code));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Load the shared envelope module from the allowlisted locations.
|
|
137
|
+
* @param {string} scriptDir - Directory holding this gate's scripts.
|
|
138
|
+
* @returns {Promise<object|null>} The module, or null when it is not installed.
|
|
139
|
+
*/
|
|
140
|
+
export async function loadEnvelopeModule(scriptDir) {
|
|
141
|
+
for (const relative of ENVELOPE_MODULE_PATHS) {
|
|
142
|
+
const candidate = path.resolve(scriptDir, relative);
|
|
143
|
+
if (!fs.existsSync(candidate)) continue;
|
|
144
|
+
return import(pathToFileURL(candidate).href);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A correlation id that is stable for an unchanged tree.
|
|
151
|
+
*
|
|
152
|
+
* CI supplies the run id. Off CI the id is derived from the result itself, so
|
|
153
|
+
* two runs over the same tree produce byte-identical output — determinism the
|
|
154
|
+
* gate's own tests assert.
|
|
155
|
+
* @param {string|undefined} supplied - `BDD_CORRELATION_ID` from the environment.
|
|
156
|
+
* @param {object} material - Values that identify this result.
|
|
157
|
+
* @returns {string} The correlation id.
|
|
158
|
+
*/
|
|
159
|
+
export function correlationId(supplied, material) {
|
|
160
|
+
if (supplied) return supplied;
|
|
161
|
+
const digest = createHash("sha256")
|
|
162
|
+
.update(JSON.stringify(material))
|
|
163
|
+
.digest("hex")
|
|
164
|
+
.slice(0, 16);
|
|
165
|
+
return `bdd-local-${digest}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The project's own contract version for the state this gate reads.
|
|
170
|
+
* @param {object|null} contract - Parsed coverage map, when readable.
|
|
171
|
+
* @returns {string} A non-empty contract version.
|
|
172
|
+
*/
|
|
173
|
+
export function contractVersion(contract) {
|
|
174
|
+
if (!contract) return "bdd-coverage-map-unavailable";
|
|
175
|
+
return `bdd-coverage-map-v${contract.schemaVersion ?? "unknown"}@${contract.asOf ?? "undated"}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Counters describing the run, plus the adoption state the counts belong to.
|
|
180
|
+
*
|
|
181
|
+
* The five facts stay separate here exactly as they do in the report: what is
|
|
182
|
+
* DECLARED, what is MAPPED (traceability), what RAN, what those runs
|
|
183
|
+
* RETURNED, and what is WAIVED. A reader comparing two runs must never have
|
|
184
|
+
* to infer which one a percentage refers to.
|
|
185
|
+
* @param {object} input - Adoption state, report, defects, and files written.
|
|
186
|
+
* @returns {object} The envelope's `summary`.
|
|
187
|
+
*/
|
|
188
|
+
export function buildSummary({ adoptionState, report, defects, filesWritten }) {
|
|
189
|
+
const counts = {
|
|
190
|
+
deleted: 0,
|
|
191
|
+
created: filesWritten,
|
|
192
|
+
preserved: 0,
|
|
193
|
+
adoptionState,
|
|
194
|
+
findingsError: defects.filter(item => item.severity === "error").length,
|
|
195
|
+
findingsWarning: defects.filter(item => item.severity === "warning").length,
|
|
196
|
+
};
|
|
197
|
+
if (!report) return counts;
|
|
198
|
+
return {
|
|
199
|
+
...counts,
|
|
200
|
+
scenariosDeclared: report.scenarios.declared,
|
|
201
|
+
scenariosRequired: report.scenarios.required,
|
|
202
|
+
scenariosExcluded: report.scenarios.excluded,
|
|
203
|
+
traceabilityCovered: report.traceability.overall.covered,
|
|
204
|
+
traceabilityTotal: report.traceability.overall.total,
|
|
205
|
+
traceabilityPercentage: report.traceability.overall.percentage,
|
|
206
|
+
executionEvidenceSupplied: report.execution.supplied,
|
|
207
|
+
mappedTests: report.execution.mappedTests,
|
|
208
|
+
...executionCounts(report.execution),
|
|
209
|
+
waivedObligations: report.waived.count,
|
|
210
|
+
floorOk: report.floor.ok,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Execution counters, present only when run evidence was supplied.
|
|
216
|
+
*
|
|
217
|
+
* Absent evidence emits NO counters rather than zeros: a zero here reads as
|
|
218
|
+
* "nothing passed", which is a different claim from "nobody told me".
|
|
219
|
+
* @param {object} execution - The report's execution block.
|
|
220
|
+
* @returns {object} Execution counters, or an empty object.
|
|
221
|
+
*/
|
|
222
|
+
function executionCounts(execution) {
|
|
223
|
+
if (!execution.supplied) return {};
|
|
224
|
+
return {
|
|
225
|
+
executed: execution.executed,
|
|
226
|
+
passed: execution.passed,
|
|
227
|
+
failed: execution.failed,
|
|
228
|
+
skipped: execution.skipped,
|
|
229
|
+
notRun: execution.notRun,
|
|
230
|
+
};
|
|
231
|
+
}
|