@codyswann/lisa 4.4.21 → 4.6.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/all/copy-overwrite/scripts/lisa-gates.mjs +125 -33
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +112 -4
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +3 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- 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
- package/scripts/check-workflow-package-paths.mjs +218 -57
- package/scripts/lib/workflow-contract-probe.mjs +445 -0
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +75 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove that the artifact at a workflow's package path still honours the
|
|
3
|
+
* contract that workflow expects of it (issue #2982).
|
|
4
|
+
*
|
|
5
|
+
* `scripts/check-workflow-package-paths.mjs` (#2960) proves a workflow's
|
|
6
|
+
* package paths EXIST in the released package. Its own docblock says why that
|
|
7
|
+
* is only half the question: "a file can keep its path and change its contract
|
|
8
|
+
* ... only the PATH is legible from a workflow, so a contract probe needs a
|
|
9
|
+
* declaration this gate does not have and does not invent." This module is that
|
|
10
|
+
* declaration's engine.
|
|
11
|
+
*
|
|
12
|
+
* ## Why a declaration, and not inference
|
|
13
|
+
*
|
|
14
|
+
* A workflow step gives up exactly one legible fact: the path it resolves. What
|
|
15
|
+
* the artifact must DO — the flags it accepts, the exit code the step branches
|
|
16
|
+
* on, whether a prover that reports success examined anything — appears nowhere
|
|
17
|
+
* in the workflow. A guessed contract is the same failure in a new costume: a
|
|
18
|
+
* check that passes because it asked a question too weak to fail. So the
|
|
19
|
+
* contract is DECLARED, in the same file as the compatibility floor, for the
|
|
20
|
+
* same reason the floor is declared rather than counted.
|
|
21
|
+
*
|
|
22
|
+
* ## The two probe shapes, and the non-vacuity signal each yields
|
|
23
|
+
*
|
|
24
|
+
* The hard question #2982 poses is what "it examined N things" means for an
|
|
25
|
+
* artifact that is not a prover. Measured against three released tarballs, two
|
|
26
|
+
* shapes cover every executed path in this repository's workflows:
|
|
27
|
+
*
|
|
28
|
+
* **Prove.** Run the artifact with the workflow's own arguments against a
|
|
29
|
+
* fixture that contains something to examine, and require the count it reports
|
|
30
|
+
* to exceed a declared minimum. `check-conflict-markers.mjs --root .` says "no
|
|
31
|
+
* leftover conflict markers in 2 tracked files"; a released copy that scans
|
|
32
|
+
* nothing says 0 and fails. That is #2951's defect, stated as an assertion.
|
|
33
|
+
*
|
|
34
|
+
* **Refuse.** Hand the artifact a deliberately invalid value for the very
|
|
35
|
+
* parameter the workflow supplies, and require it to reject the value AND
|
|
36
|
+
* enumerate the domain it would have accepted. `dispatch.mjs` answers "unknown
|
|
37
|
+
* executionEnv ... Supported: local, codex-cloud, claude-web"; the non-vacuity
|
|
38
|
+
* count is the size of that enumerated domain, and `contains` pins the exact
|
|
39
|
+
* token the workflow passes. A released copy that lost the flag prints a
|
|
40
|
+
* generic usage line, enumerates nothing, and fails. Refuse probes have no side
|
|
41
|
+
* effects at all, which is what makes it safe to run a dispatcher and a secret
|
|
42
|
+
* rotator out of a tarball.
|
|
43
|
+
*
|
|
44
|
+
* Both reduce to one thing: a regex with ONE capture group, whose captured
|
|
45
|
+
* domain must be large enough and must contain what the workflow depends on.
|
|
46
|
+
*
|
|
47
|
+
* ## Refusing to pass on nothing
|
|
48
|
+
*
|
|
49
|
+
* A probe that resolved no artifact, could not read its output, or matched its
|
|
50
|
+
* signal zero times FAILS. It does not report all-clear, because an empty
|
|
51
|
+
* inspection and a satisfied contract are otherwise the same green. A child
|
|
52
|
+
* killed at its deadline returns EMPTY streams — which reads exactly like an
|
|
53
|
+
* absent signal — so a timeout is reported as an operational failure and never
|
|
54
|
+
* as a contract violation.
|
|
55
|
+
* @module scripts/lib/workflow-contract-probe
|
|
56
|
+
*/
|
|
57
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
boundedExecFileSync,
|
|
62
|
+
boundedSpawnSync,
|
|
63
|
+
isChildTimeout,
|
|
64
|
+
} from "./bounded-spawn.mjs";
|
|
65
|
+
|
|
66
|
+
/** How a declaration may describe the artifact at a package path. */
|
|
67
|
+
export const CONTRACT_KINDS = new Set(["executed", "reference"]);
|
|
68
|
+
|
|
69
|
+
/** How a probe's captured domain is turned into a count. */
|
|
70
|
+
export const SIGNAL_SHAPES = new Set(["count", "list", "json-object-keys"]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What separates one token from the next inside a captured domain.
|
|
74
|
+
* @remarks
|
|
75
|
+
* The word alternatives precede the whitespace one deliberately: the leading
|
|
76
|
+
* `\s*` has already consumed the space before `and`, so `and` is matched as a
|
|
77
|
+
* separator rather than counted as a member of the domain it joins.
|
|
78
|
+
*/
|
|
79
|
+
const TOKEN_SEPARATOR = /\s*(?:,|\||\band\b|\bor\b|\s+)\s*/;
|
|
80
|
+
|
|
81
|
+
/** How long one probe may run before it is killed and reported as such. */
|
|
82
|
+
const PROBE_BUDGET_MS = 120_000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compare two `major.minor.patch` versions.
|
|
86
|
+
* @param {string} left - A version
|
|
87
|
+
* @param {string} right - Another version
|
|
88
|
+
* @returns {number} Negative when left is older, positive when newer, 0 equal
|
|
89
|
+
*/
|
|
90
|
+
export function compareVersions(left, right) {
|
|
91
|
+
const parse = version =>
|
|
92
|
+
version.split(".").map(part => Number.parseInt(part, 10) || 0);
|
|
93
|
+
const a = parse(left);
|
|
94
|
+
const b = parse(right);
|
|
95
|
+
for (let index = 0; index < 3; index += 1) {
|
|
96
|
+
if ((a[index] ?? 0) !== (b[index] ?? 0))
|
|
97
|
+
return (a[index] ?? 0) - (b[index] ?? 0);
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reject a probe declaration that could not fail.
|
|
104
|
+
* @param {string} where - The package path being validated
|
|
105
|
+
* @param {object} probe - One declared probe
|
|
106
|
+
* @returns {void}
|
|
107
|
+
* @throws {Error} When the probe is malformed
|
|
108
|
+
*/
|
|
109
|
+
function validateProbe(where, probe) {
|
|
110
|
+
if (!Array.isArray(probe.argv)) {
|
|
111
|
+
throw new Error(`contracts["${where}"]: a probe needs an "argv" array`);
|
|
112
|
+
}
|
|
113
|
+
if (!Number.isInteger(probe.expectExit)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`contracts["${where}"]: a probe needs an integer "expectExit"`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const signal = probe.signal;
|
|
119
|
+
if (!signal || typeof signal.pattern !== "string") {
|
|
120
|
+
throw new Error(`contracts["${where}"]: a probe needs signal.pattern`);
|
|
121
|
+
}
|
|
122
|
+
if (!SIGNAL_SHAPES.has(signal.shape)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`contracts["${where}"]: signal.shape must be one of ${[...SIGNAL_SHAPES].join(", ")}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isInteger(signal.min) || signal.min < 1) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`contracts["${where}"]: signal.min must be a positive integer — a probe whose minimum is zero cannot tell an empty inspection from a satisfied contract`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (typeof probe.why !== "string" || probe.why.length < 40) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`contracts["${where}"]: a probe needs a "why" recording what the workflow depends on`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read and validate the contract half of the floor declaration.
|
|
141
|
+
* @param {object} declared - Parsed `.github/workflow-package-floor.json`
|
|
142
|
+
* @returns {{contracts: Record<string, object>, fixtures: Record<string, object>}}
|
|
143
|
+
* @throws {Error} When the declaration is missing or malformed
|
|
144
|
+
*/
|
|
145
|
+
export function readContractDeclaration(declared) {
|
|
146
|
+
const contracts = declared.contracts;
|
|
147
|
+
const fixtures = declared.fixtures ?? {};
|
|
148
|
+
if (!contracts || typeof contracts !== "object" || Array.isArray(contracts)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'the declaration has no "contracts" object. Every package path a workflow resolves must declare what the artifact there does — an undeclared path is a path whose contract nobody checked, which is the gap #2982 exists to close.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
for (const [where, entry] of Object.entries(contracts)) {
|
|
154
|
+
if (!CONTRACT_KINDS.has(entry?.kind)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`contracts["${where}"]: kind must be one of ${[...CONTRACT_KINDS].join(", ")}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (typeof entry.why !== "string" || entry.why.length < 40) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`contracts["${where}"]: needs a "why" a later reader can argue with`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (entry.kind === "reference") continue;
|
|
165
|
+
if (!Array.isArray(entry.probes) || entry.probes.length === 0) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`contracts["${where}"]: kind "executed" needs at least one probe. Declaring an executed artifact with no probe is existence-only treatment wearing a contract's name.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
for (const probe of entry.probes) {
|
|
171
|
+
validateProbe(where, probe);
|
|
172
|
+
if (
|
|
173
|
+
probe.fixture !== undefined &&
|
|
174
|
+
fixtures[probe.fixture] === undefined
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`contracts["${where}"]: no fixture named "${probe.fixture}" is declared`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { contracts, fixtures };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Which referenced paths carry no declaration, and which declarations are stale.
|
|
187
|
+
* @param {readonly {workflow: string, step: string, paths: readonly string[]}[]} groups - Step claims
|
|
188
|
+
* @param {Record<string, object>} contracts - Declared contracts
|
|
189
|
+
* @returns {{undeclared: readonly string[], stale: readonly string[]}} Operator-readable lines
|
|
190
|
+
*/
|
|
191
|
+
export function declarationGaps(groups, contracts) {
|
|
192
|
+
const referenced = new Set(groups.flatMap(group => group.paths));
|
|
193
|
+
const undeclared = groups.flatMap(group =>
|
|
194
|
+
group.paths
|
|
195
|
+
.filter(candidate => contracts[candidate] === undefined)
|
|
196
|
+
.map(
|
|
197
|
+
candidate =>
|
|
198
|
+
`${group.workflow} step "${group.step}" resolves node_modules/@codyswann/lisa/${candidate}, which declares no contract. Add it to "contracts" in .github/workflow-package-floor.json — as "executed" with a probe if the step runs it, or "reference" with a reason if it does not.`
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
const stale = Object.keys(contracts)
|
|
202
|
+
.filter(candidate => !referenced.has(candidate))
|
|
203
|
+
.map(
|
|
204
|
+
candidate =>
|
|
205
|
+
`contracts["${candidate}"] is declared but no workflow references it. A declaration nobody reaches is a probe that never runs; delete it or restore the reference.`
|
|
206
|
+
);
|
|
207
|
+
return { undeclared: [...new Set(undeclared)], stale };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a probe's fixture on disk.
|
|
212
|
+
* @remarks
|
|
213
|
+
* `git: true` stages the files rather than committing them. `git ls-files`
|
|
214
|
+
* reads the index, so staging is enough, and it avoids needing a committer
|
|
215
|
+
* identity that a CI runner may not have configured. `GIT_*` variables are
|
|
216
|
+
* stripped so an ambient environment cannot reach into the fixture.
|
|
217
|
+
* @param {object} fixture - Declared fixture: `{git?: boolean, files: Record<string,string>}`
|
|
218
|
+
* @param {string} dir - Directory to build it in
|
|
219
|
+
* @returns {void}
|
|
220
|
+
*/
|
|
221
|
+
export function materialiseFixture(fixture, dir) {
|
|
222
|
+
mkdirSync(dir, { recursive: true });
|
|
223
|
+
for (const [relative, contents] of Object.entries(fixture.files ?? {})) {
|
|
224
|
+
const target = path.join(dir, relative);
|
|
225
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
226
|
+
writeFileSync(target, contents);
|
|
227
|
+
}
|
|
228
|
+
if (fixture.git !== true) return;
|
|
229
|
+
const env = Object.fromEntries(
|
|
230
|
+
Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_"))
|
|
231
|
+
);
|
|
232
|
+
boundedExecFileSync("git", ["init", "-q", dir], { env, stdio: "ignore" });
|
|
233
|
+
boundedExecFileSync("git", ["-C", dir, "add", "-A"], {
|
|
234
|
+
env,
|
|
235
|
+
stdio: "ignore",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The domain a probe's signal captured, and how big it is.
|
|
241
|
+
* @param {string} output - The artifact's combined stdout and stderr
|
|
242
|
+
* @param {object} signal - The declared signal
|
|
243
|
+
* @returns {{text: string|null, count: number}} Captured domain and its size
|
|
244
|
+
*/
|
|
245
|
+
export function capturedDomain(output, signal) {
|
|
246
|
+
if (signal.shape === "json-object-keys") {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(output.trim());
|
|
249
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
250
|
+
return { text: null, count: 0 };
|
|
251
|
+
}
|
|
252
|
+
const keys = Object.keys(parsed);
|
|
253
|
+
return { text: keys.join(", "), count: keys.length };
|
|
254
|
+
} catch {
|
|
255
|
+
return { text: null, count: 0 };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const match = new RegExp(signal.pattern, "m").exec(output);
|
|
259
|
+
const captured = match?.[1];
|
|
260
|
+
if (captured === undefined) return { text: null, count: 0 };
|
|
261
|
+
if (signal.shape === "count") {
|
|
262
|
+
const parsed = Number.parseInt(captured, 10);
|
|
263
|
+
return { text: captured, count: Number.isInteger(parsed) ? parsed : 0 };
|
|
264
|
+
}
|
|
265
|
+
const tokens = captured
|
|
266
|
+
.split(TOKEN_SEPARATOR)
|
|
267
|
+
.map(token => token.trim())
|
|
268
|
+
.filter(Boolean);
|
|
269
|
+
return { text: captured, count: tokens.length };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Judge one probe run against its declaration.
|
|
274
|
+
* @param {{status: number|null, output: string}} run - What the artifact did
|
|
275
|
+
* @param {object} probe - The declared probe
|
|
276
|
+
* @returns {{ok: boolean, count: number, reason: string|null}} The verdict
|
|
277
|
+
*/
|
|
278
|
+
export function evaluateProbe(run, probe) {
|
|
279
|
+
const domain = capturedDomain(run.output, probe.signal);
|
|
280
|
+
if (run.status !== probe.expectExit) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
count: domain.count,
|
|
284
|
+
reason: `exited ${run.status} where the workflow depends on ${probe.expectExit}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (domain.text === null) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
count: 0,
|
|
291
|
+
reason: `emitted no ${probe.signal.shape} signal matching ${probe.signal.pattern} — it ran, and proved nothing`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (domain.count < probe.signal.min) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
count: domain.count,
|
|
298
|
+
reason: `signal counted ${domain.count}, below the declared minimum of ${probe.signal.min} — an inspection this empty is indistinguishable from a satisfied contract`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const missing = (probe.signal.contains ?? []).filter(
|
|
302
|
+
token => !domain.text.includes(token)
|
|
303
|
+
);
|
|
304
|
+
if (missing.length > 0) {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
count: domain.count,
|
|
308
|
+
reason: `enumerated "${domain.text}", which is missing ${missing.join(", ")} — the workflow passes that value`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return { ok: true, count: domain.count, reason: null };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Run one probe against one released artifact.
|
|
316
|
+
* @param {object} input - `{artifact, probe, fixtures, workDir}`
|
|
317
|
+
* @returns {{status: number|null, output: string, timedOut: boolean}} What happened
|
|
318
|
+
*/
|
|
319
|
+
export function runProbe({ artifact, probe, fixtures, workDir }) {
|
|
320
|
+
if (probe.fixture !== undefined) {
|
|
321
|
+
materialiseFixture(fixtures[probe.fixture], workDir);
|
|
322
|
+
} else {
|
|
323
|
+
mkdirSync(workDir, { recursive: true });
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const result = boundedSpawnSync(
|
|
327
|
+
process.execPath,
|
|
328
|
+
[artifact, ...probe.argv],
|
|
329
|
+
{
|
|
330
|
+
cwd: workDir,
|
|
331
|
+
encoding: "utf8",
|
|
332
|
+
timeout: PROBE_BUDGET_MS,
|
|
333
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
334
|
+
env: { ...process.env, CI: "1", NO_COLOR: "1" },
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
status: result.status,
|
|
339
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
340
|
+
timedOut: false,
|
|
341
|
+
};
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (isChildTimeout(error))
|
|
344
|
+
return { status: null, output: "", timedOut: true };
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The path a step would actually EXECUTE in a given release.
|
|
351
|
+
* @remarks
|
|
352
|
+
* Mirrors the workflow's own `for candidate in ...; break` loop, with one
|
|
353
|
+
* refinement: a path declared `reference` is skipped. A directory named in a
|
|
354
|
+
* step's error prose exists in every release and would otherwise shadow the
|
|
355
|
+
* artifact the step really runs — which would quietly retire the probe.
|
|
356
|
+
* @param {readonly string[]} paths - The step's candidates, in workflow order
|
|
357
|
+
* @param {(candidate: string) => boolean} exists - Does this release carry it?
|
|
358
|
+
* @param {Record<string, object>} contracts - Declared contracts
|
|
359
|
+
* @returns {string|null} The executed path, or null when the step runs nothing
|
|
360
|
+
*/
|
|
361
|
+
export function resolveExecutedPath(paths, exists, contracts) {
|
|
362
|
+
return (
|
|
363
|
+
paths.find(
|
|
364
|
+
candidate =>
|
|
365
|
+
contracts[candidate]?.kind === "executed" && exists(candidate)
|
|
366
|
+
) ?? null
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Probe every executed path every step resolves, in every release.
|
|
372
|
+
* @remarks
|
|
373
|
+
* Deduplicated by release, path and probe: `lisa-gates.mjs` is resolved by more
|
|
374
|
+
* than forty steps, and running the same probe forty times per release would
|
|
375
|
+
* cost minutes and prove nothing extra.
|
|
376
|
+
* @param {object} input - `{groups, releases, contracts, fixtures, workRoot}`
|
|
377
|
+
* @returns {{executed: number, violations: readonly string[], deferred: readonly string[], operational: readonly string[], probed: readonly string[]}}
|
|
378
|
+
*/
|
|
379
|
+
export function probeReleases({
|
|
380
|
+
groups,
|
|
381
|
+
releases,
|
|
382
|
+
contracts,
|
|
383
|
+
fixtures,
|
|
384
|
+
workRoot,
|
|
385
|
+
}) {
|
|
386
|
+
const seen = new Set();
|
|
387
|
+
const violations = [];
|
|
388
|
+
const deferred = [];
|
|
389
|
+
const operational = [];
|
|
390
|
+
const probed = [];
|
|
391
|
+
for (const release of releases) {
|
|
392
|
+
for (const group of groups) {
|
|
393
|
+
const target = resolveExecutedPath(
|
|
394
|
+
group.paths,
|
|
395
|
+
release.contains,
|
|
396
|
+
contracts
|
|
397
|
+
);
|
|
398
|
+
if (target === null) continue;
|
|
399
|
+
contracts[target].probes.forEach((probe, index) => {
|
|
400
|
+
const key = `${release.version}::${target}::${index}`;
|
|
401
|
+
if (seen.has(key)) return;
|
|
402
|
+
seen.add(key);
|
|
403
|
+
const where = `${release.version}: node_modules/@codyswann/lisa/${target}`;
|
|
404
|
+
if (
|
|
405
|
+
probe.since !== undefined &&
|
|
406
|
+
compareVersions(release.version, probe.since) < 0
|
|
407
|
+
) {
|
|
408
|
+
deferred.push(
|
|
409
|
+
`${where} — probe requires ${probe.since} or newer; the workflow degrades instead: ${probe.degradation}`
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const artifact = path.join(release.root, target);
|
|
414
|
+
const run = runProbe({
|
|
415
|
+
artifact,
|
|
416
|
+
probe,
|
|
417
|
+
fixtures,
|
|
418
|
+
workDir: path.join(workRoot, key.replace(/[^A-Za-z0-9]+/g, "_")),
|
|
419
|
+
});
|
|
420
|
+
if (run.timedOut) {
|
|
421
|
+
operational.push(
|
|
422
|
+
`${where} — the probe was killed at its ${PROBE_BUDGET_MS}ms deadline. A killed child returns EMPTY streams, so this would otherwise read as an absent signal; it is reported as "could not look" instead.`
|
|
423
|
+
);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const verdict = evaluateProbe(run, probe);
|
|
427
|
+
probed.push(
|
|
428
|
+
`${where} — ${probe.why} [signal counted ${verdict.count}]`
|
|
429
|
+
);
|
|
430
|
+
if (!verdict.ok) {
|
|
431
|
+
violations.push(
|
|
432
|
+
`${where} exists, and ${verdict.reason}. Expected because ${probe.why}. The path check passes here; the contract does not.`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
executed: probed.length,
|
|
440
|
+
violations,
|
|
441
|
+
deferred,
|
|
442
|
+
operational,
|
|
443
|
+
probed,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
@@ -104,6 +104,14 @@
|
|
|
104
104
|
* score recomputed with timeouts NOT credited — and the recomputed one is
|
|
105
105
|
* judged against `thresholds.break`. That can only ever tighten: it is applied
|
|
106
106
|
* on top of Stryker's own verdict, and nothing here can turn a red run green.
|
|
107
|
+
*
|
|
108
|
+
* Every one of those verdicts names the floor it was judged against and that
|
|
109
|
+
* floor's value — the passing ones as well as the failing ones. Two mutation
|
|
110
|
+
* floors exist and deliberately differ, so "it cleared the floor" has two
|
|
111
|
+
* answers, and a bare pass is unfalsifiable by its reader: it cannot be told
|
|
112
|
+
* from one that cleared a much lower number, or from one judged against a
|
|
113
|
+
* default nobody chose. A run against no declared floor says NO floor was
|
|
114
|
+
* applied rather than reporting one as `0`.
|
|
107
115
|
* Raising `timeoutMS` is NOT the fix and is refused as one — it converts a
|
|
108
116
|
* timeout into a slow pass and hides the identical gap.
|
|
109
117
|
*
|
|
@@ -159,6 +167,8 @@ export const OUTCOMES = Object.freeze({
|
|
|
159
167
|
timeoutUnmeasured: "mutation-gate: timeout-share-unmeasured",
|
|
160
168
|
timeoutShareExceeded: "mutation-gate: timeout-share-exceeded",
|
|
161
169
|
inflatedByTimeouts: "mutation-gate: score-below-break-without-timeouts",
|
|
170
|
+
clearedBreakThreshold: "mutation-gate: cleared-break-threshold",
|
|
171
|
+
noFloorApplied: "mutation-gate: no-floor-applied",
|
|
162
172
|
});
|
|
163
173
|
|
|
164
174
|
/**
|
|
@@ -702,6 +712,62 @@ const unmeasuredBlock = () =>
|
|
|
702
712
|
' Add "clear-text" to `reporters` in your Stryker config to measure it, or set\n' +
|
|
703
713
|
" MUTATION_CAPTURE=0 to say out loud that this run is not being accounted for.";
|
|
704
714
|
|
|
715
|
+
/**
|
|
716
|
+
* The floor a completed run was judged against, named with its value.
|
|
717
|
+
*
|
|
718
|
+
* ## The defect this closes
|
|
719
|
+
*
|
|
720
|
+
* The failing verdict below names the floor it judged against and that floor's
|
|
721
|
+
* value. The passing one named neither: it printed the accounting block and
|
|
722
|
+
* stopped. Two mutation floors exist in this project and deliberately differ —
|
|
723
|
+
* the `thresholds.break` Stryker enforces, and the value the Lisa config
|
|
724
|
+
* declares — so "it cleared the floor" has two answers, and a report could pick
|
|
725
|
+
* the flattering one without ever lying. A reader could not tell a run that
|
|
726
|
+
* cleared 60 from one that cleared 5, nor a run judged against the intended
|
|
727
|
+
* floor from one judged against a default nobody chose.
|
|
728
|
+
*
|
|
729
|
+
* ## Where no floor was applied it says so, rather than inventing one
|
|
730
|
+
*
|
|
731
|
+
* Two arms reach this with nothing to name, and `0` would be a fabrication in
|
|
732
|
+
* both: a project that declared no `thresholds.break` has not asked for a floor
|
|
733
|
+
* (see {@link resolveBreakThreshold}, which returns null and not zero for
|
|
734
|
+
* exactly this reason), and a run whose tally produced no score has nothing to
|
|
735
|
+
* judge against the floor it did declare. Both say NO floor was applied, which
|
|
736
|
+
* is the same answer this gate's `nothing-to-mutate` and `no-diff-base` exits
|
|
737
|
+
* already give: nothing was measured, so nothing passed.
|
|
738
|
+
*
|
|
739
|
+
* This is reporting only. It changes no threshold and gates nothing — the arm
|
|
740
|
+
* that fails a run is below, and it is untouched.
|
|
741
|
+
* @param {{timedOut: number}} tally - The counts.
|
|
742
|
+
* @param {{withoutTimeouts: number}} accounting - From
|
|
743
|
+
* {@link timeoutAccounting}.
|
|
744
|
+
* @param {number|null} breakThreshold - `thresholds.break`, or null.
|
|
745
|
+
* @returns {string} The block, appended to the accounting report.
|
|
746
|
+
*/
|
|
747
|
+
const clearedFloorBlock = (tally, accounting, breakThreshold) => {
|
|
748
|
+
if (breakThreshold === null)
|
|
749
|
+
return (
|
|
750
|
+
`\n⚪ ${OUTCOMES.noFloorApplied}\n` +
|
|
751
|
+
' Your Stryker config declares no "thresholds.break", so NO floor was applied\n' +
|
|
752
|
+
" to this run. The scores above are reported, not cleared — nothing here says\n" +
|
|
753
|
+
" they are good enough, because nothing said what good enough is."
|
|
754
|
+
);
|
|
755
|
+
if (!Number.isFinite(accounting.withoutTimeouts))
|
|
756
|
+
return (
|
|
757
|
+
`\n⚪ ${OUTCOMES.noFloorApplied}\n` +
|
|
758
|
+
` A break threshold of ${breakThreshold} is declared, but this run produced no score to\n` +
|
|
759
|
+
" judge against it, so NO floor was applied. Nothing here is a verdict about\n" +
|
|
760
|
+
" your tests."
|
|
761
|
+
);
|
|
762
|
+
return (
|
|
763
|
+
`\n✅ ${OUTCOMES.clearedBreakThreshold}\n` +
|
|
764
|
+
` Without crediting the ${tally.timedOut} timed-out mutant(s), this run scores\n` +
|
|
765
|
+
` ${score(accounting.withoutTimeouts)} against a break threshold of ${breakThreshold} — "thresholds.break" in your\n` +
|
|
766
|
+
" Stryker config, and the only floor this gate applied. That is a statement\n" +
|
|
767
|
+
" about this number and nothing else the run did."
|
|
768
|
+
);
|
|
769
|
+
};
|
|
770
|
+
|
|
705
771
|
/**
|
|
706
772
|
* Judge a completed run on what it can prove, rather than on what it counted.
|
|
707
773
|
*
|
|
@@ -760,7 +826,15 @@ export const judgeTimeoutAccounting = (tally, breakThreshold, ceiling) => {
|
|
|
760
826
|
};
|
|
761
827
|
}
|
|
762
828
|
|
|
763
|
-
|
|
829
|
+
// A verdict that clears is stated with the floor it cleared and that floor's
|
|
830
|
+
// value, the same way the two failing arms above state theirs. A bare pass is
|
|
831
|
+
// unfalsifiable by a reader: while two floors exist, "it cleared the floor"
|
|
832
|
+
// has two answers.
|
|
833
|
+
return {
|
|
834
|
+
failed: false,
|
|
835
|
+
measured: true,
|
|
836
|
+
message: `${report}${clearedFloorBlock(tally, accounting, breakThreshold)}`,
|
|
837
|
+
};
|
|
764
838
|
};
|
|
765
839
|
|
|
766
840
|
/**
|