@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.
Files changed (62) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +125 -33
  2. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +112 -4
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +3 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +8 -4
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/package.json +1 -1
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  16. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/plugin.json +1 -1
  23. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  43. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-agy/plugin.json +1 -1
  48. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  53. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  58. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  60. package/scripts/check-workflow-package-paths.mjs +218 -57
  61. package/scripts/lib/workflow-contract-probe.mjs +445 -0
  62. 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
- return { failed: false, measured: true, message: report };
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
  /**