@dzhechkov/harness-core 0.3.150 → 0.4.2

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 (111) hide show
  1. package/.dz-manifest.json +410 -62
  2. package/README.md +81 -3
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +10 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/backlog-embed.d.ts +94 -0
  7. package/dist/backlog-embed.d.ts.map +1 -0
  8. package/dist/backlog-embed.js +138 -0
  9. package/dist/backlog-embed.js.map +1 -0
  10. package/dist/backlog.d.ts +180 -7
  11. package/dist/backlog.d.ts.map +1 -1
  12. package/dist/backlog.js +429 -26
  13. package/dist/backlog.js.map +1 -1
  14. package/dist/challenge-panel.d.ts +3 -0
  15. package/dist/challenge-panel.d.ts.map +1 -1
  16. package/dist/challenge-panel.js +3 -0
  17. package/dist/challenge-panel.js.map +1 -1
  18. package/dist/export-holdout.d.ts +149 -0
  19. package/dist/export-holdout.d.ts.map +1 -0
  20. package/dist/export-holdout.js +198 -0
  21. package/dist/export-holdout.js.map +1 -0
  22. package/dist/feature-adr-checkpoints.d.ts +127 -0
  23. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  24. package/dist/feature-adr-checkpoints.js +199 -1
  25. package/dist/feature-adr-checkpoints.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +3 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +3 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +42 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +73 -1
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +28 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-blobs.generated.d.ts +33 -0
  39. package/dist/loop-blobs.generated.d.ts.map +1 -0
  40. package/dist/loop-blobs.generated.js +101 -0
  41. package/dist/loop-blobs.generated.js.map +1 -0
  42. package/dist/loop-lint.d.ts +63 -0
  43. package/dist/loop-lint.d.ts.map +1 -0
  44. package/dist/loop-lint.js +606 -0
  45. package/dist/loop-lint.js.map +1 -0
  46. package/dist/loop-plan.d.ts +416 -0
  47. package/dist/loop-plan.d.ts.map +1 -0
  48. package/dist/loop-plan.js +1151 -0
  49. package/dist/loop-plan.js.map +1 -0
  50. package/dist/loop-render.d.ts +104 -0
  51. package/dist/loop-render.d.ts.map +1 -0
  52. package/dist/loop-render.js +1068 -0
  53. package/dist/loop-render.js.map +1 -0
  54. package/dist/loop-trace.d.ts +229 -0
  55. package/dist/loop-trace.d.ts.map +1 -0
  56. package/dist/loop-trace.js +614 -0
  57. package/dist/loop-trace.js.map +1 -0
  58. package/dist/mutation-gate.d.ts +247 -0
  59. package/dist/mutation-gate.d.ts.map +1 -0
  60. package/dist/mutation-gate.js +535 -0
  61. package/dist/mutation-gate.js.map +1 -0
  62. package/dist/no-stubs.d.ts +53 -0
  63. package/dist/no-stubs.d.ts.map +1 -0
  64. package/dist/no-stubs.js +190 -0
  65. package/dist/no-stubs.js.map +1 -0
  66. package/dist/package-skill-layouts.d.ts +67 -0
  67. package/dist/package-skill-layouts.d.ts.map +1 -0
  68. package/dist/package-skill-layouts.js +81 -0
  69. package/dist/package-skill-layouts.js.map +1 -0
  70. package/dist/patterns.d.ts.map +1 -1
  71. package/dist/patterns.js +156 -75
  72. package/dist/patterns.js.map +1 -1
  73. package/dist/recall-domain-boost.d.ts.map +1 -1
  74. package/dist/recall-domain-boost.js +6 -0
  75. package/dist/recall-domain-boost.js.map +1 -1
  76. package/dist/statusline.d.ts +10 -2
  77. package/dist/statusline.d.ts.map +1 -1
  78. package/dist/statusline.js +122 -36
  79. package/dist/statusline.js.map +1 -1
  80. package/dist/store-lock.d.ts +108 -0
  81. package/dist/store-lock.d.ts.map +1 -0
  82. package/dist/store-lock.js +231 -0
  83. package/dist/store-lock.js.map +1 -0
  84. package/dist/workflows.d.ts +16 -22
  85. package/dist/workflows.d.ts.map +1 -1
  86. package/dist/workflows.js +17 -98
  87. package/dist/workflows.js.map +1 -1
  88. package/package.json +6 -4
  89. package/sbom.json +1073 -203
  90. package/src/agentdb-index.ts +10 -1
  91. package/src/backlog-embed.ts +156 -0
  92. package/src/backlog.ts +536 -28
  93. package/src/challenge-panel.ts +4 -0
  94. package/src/export-holdout.ts +235 -0
  95. package/src/feature-adr-checkpoints.ts +291 -1
  96. package/src/feature-adr-routing.ts +4 -0
  97. package/src/guard.ts +106 -1
  98. package/src/index.ts +62 -2
  99. package/src/loop-blobs.generated.ts +114 -0
  100. package/src/loop-lint.ts +643 -0
  101. package/src/loop-plan.ts +1419 -0
  102. package/src/loop-render.ts +1126 -0
  103. package/src/loop-trace.ts +727 -0
  104. package/src/mutation-gate.ts +701 -0
  105. package/src/no-stubs.ts +204 -0
  106. package/src/package-skill-layouts.ts +107 -0
  107. package/src/patterns.ts +135 -60
  108. package/src/recall-domain-boost.ts +6 -0
  109. package/src/statusline.ts +117 -30
  110. package/src/store-lock.ts +258 -0
  111. package/src/workflows.ts +18 -117
@@ -0,0 +1,701 @@
1
+ // Mutation gate (feature ha-mutation-gate — features/ha-mutation-gate/SPEC.md).
2
+ //
3
+ // A test that passes proves the code works; it does NOT prove the test would have noticed the
4
+ // protection being deleted. Three QE rounds on @dzhechkov/health-advisor produced 53 → 14 → 14
5
+ // findings while the whole suite stayed green — including the exact exploit string a code comment
6
+ // records as MEASURED. The gate generalises the one measurement that proved a protection real in
7
+ // those rounds: DELETE the protection, run the suite, REQUIRE red.
8
+ //
9
+ // This module is PURE, in the house style of discrimination-gate.ts / challenge-panel.ts: it
10
+ // parses the declarative registry, applies a mutation to file TEXT, parses failing-test counts
11
+ // from runner output, and classifies observed outcomes. The copy-tree / run-suite / restore I/O is
12
+ // performed by the CLI executor (`dz mutation-gate` in harness-cli), which feeds observations back
13
+ // into `classifyMutationOutcome`. Deterministic and unit-testable without a filesystem or runner.
14
+ //
15
+ // The four rules the gate itself must obey (SPEC.md §"Four rules"):
16
+ // 1. A mutation that does not apply — `find` absent, or present more than once — is a FAILURE,
17
+ // never a skip (inconclusive ≠ pass, the dz skills-verify lesson).
18
+ // 2. A mutation whose suite stays GREEN is a FAILURE, and the report names the property.
19
+ // 3. Never mutate the working tree (enforced by the executor: scratch copy only).
20
+ // 4. The gate has its own discrimination proof (the deliberately-undefended fixture in
21
+ // harness-cli/test/fixtures/mutation-gate-undefended — excluded from any real registry).
22
+
23
+ /** One named safety property: mutate the protection, run the suite, require red. */
24
+ export interface MutationRegistryEntry {
25
+ /** stable kebab-case id, unique within the registry. */
26
+ readonly id: string;
27
+ /** the sentence the code claims — named so a failure report can say WHAT is undefended. */
28
+ readonly property: string;
29
+ /** package-relative path of the file carrying the protection. */
30
+ readonly file: string;
31
+ /** exact text surgery: `find` must occur EXACTLY ONCE in the file (rule 1). */
32
+ readonly mutation: { readonly find: string; readonly replace: string };
33
+ /** how many tests MUST go red under the mutation (default 1). */
34
+ readonly minFailing?: number;
35
+ /** how many actually did at the time of writing — makes a coverage DROP visible. */
36
+ readonly observed?: number;
37
+ /**
38
+ * the MOST tests that may go red before the redness stops being attributable to THIS protection
39
+ * (route-c guard — see effectiveMaxFailing for the default when absent). A structural blow-up
40
+ * (broken export surface, dead import) reddens the whole suite; that is not discrimination.
41
+ */
42
+ readonly maxFailing?: number;
43
+ }
44
+
45
+ export interface MutationRegistry {
46
+ /** optional suite command override for the whole registry (default `npm test`). */
47
+ readonly testCommand?: string;
48
+ readonly entries: readonly MutationRegistryEntry[];
49
+ }
50
+
51
+ export type MutationVerdict =
52
+ | 'PROVEN' // mutation applied, suite went red, failing count (when known) >= minFailing
53
+ | 'UNDEFENDED' // mutation applied, suite stayed GREEN — the property has no discriminating test
54
+ | 'NOT_APPLIED' // `find` occurred 0 or >1 times — code drifted; nothing was tested (rule 1)
55
+ | 'BELOW_MIN' // suite red, but a RELIABLE count came in under the entry's own minFailing contract
56
+ | 'MUTATION_UNPARSEABLE' // the MUTATED file no longer parses — a registry/setup error: whole-suite redness from a load failure proves nothing (route a)
57
+ | 'MUTATION_LOAD_FATAL' // THE SUITE RUN ITSELF reported a test FILE failing to LOAD under the mutation — structural redness, never PROVEN (route a′, round-6 rework)
58
+ | 'OVER_FAILING' // a RELIABLE count far ABOVE the entry's bound — the mutation broke much more than the protection's own tests; not attributable (route c)
59
+ | 'INCONCLUSIVE'; // no exit code (timeout / spawn failure), red output whose SHAPE no classifier recognises (runner-coverage gap), OR the restored baseline did not reproduce green (flaky suite, route b) — never a pass
60
+
61
+ export interface MutationObservation {
62
+ readonly entry: MutationRegistryEntry;
63
+ /** how many times `find` occurred in the file text (1 = applied). Missing file ⇒ 0. */
64
+ readonly occurrences: number;
65
+ /** the suite's exit code under the mutation; null when it produced none (killed / spawn error). */
66
+ readonly exitCode: number | null;
67
+ /** best-effort failing-test count parsed from runner output; null when unparseable. */
68
+ readonly failingCount: number | null;
69
+ /**
70
+ * set (to the parser's message) when the MUTATED file failed to parse as its own language —
71
+ * the executor checks BEFORE running the suite. Absent/undefined = parsed fine or not checked.
72
+ */
73
+ readonly parseError?: string;
74
+ /**
75
+ * set (to the classifier's evidence line) when THE SUITE RUN's own output reports a test FILE
76
+ * failing to LOAD under the mutation — the route-a′ guard, round-6 rework. The signal comes from
77
+ * the SAME run that produced the failing count (no isolated child, no environment mismatch — the
78
+ * round-5 isolated `import()` had three measured false-PASS routes precisely because an isolated
79
+ * import is a DIFFERENT environment than the test runner). A file-load failure means the redness
80
+ * is STRUCTURAL — the mutation broke the module, so the count is not attributable to the
81
+ * protection. Absent/undefined = the red output showed test ASSERTION failures (behavioural —
82
+ * redness IS the deleted protection), or the run was green / never ran.
83
+ */
84
+ readonly fileLoadFailure?: string;
85
+ /**
86
+ * set (to an explanation) when the suite went RED but the output SHAPE matched no runner the
87
+ * classifier knows (`classifyRunFailure` returned 'unrecognised') — a runner-coverage gap of
88
+ * THIS TOOL, not a property of the module under test. The verdict is INCONCLUSIVE (a loud
89
+ * failure), NEVER PROVEN: redness that cannot be attributed proves nothing.
90
+ */
91
+ readonly outputUnrecognised?: string;
92
+ /**
93
+ * exit code of the suite re-run on the RESTORED tree after this entry (the attribution check
94
+ * for flaky neighbours). undefined = not run; 0 = reproducibly green; any other value (or null)
95
+ * = the "green" baseline is not reproducible, so the red under mutation is not attributable.
96
+ */
97
+ readonly rebaselineExitCode?: number | null;
98
+ }
99
+
100
+ export interface MutationEntryResult {
101
+ readonly id: string;
102
+ readonly property: string;
103
+ readonly file: string;
104
+ readonly applied: boolean;
105
+ readonly occurrences: number;
106
+ readonly exitCode: number | null;
107
+ readonly failingCount: number | null;
108
+ readonly verdict: MutationVerdict;
109
+ /** true when the count is reliable, non-zero, and LOWER than `observed` — the early warning that
110
+ * arrives before the property breaks. A WARNING, not a failure — see classifyMutationOutcome. */
111
+ readonly drop: boolean;
112
+ /**
113
+ * QE round-7 honesty (loop-designer, cross-family reviewer): a coverage DROP can only ever be
114
+ * DETECTED for an entry that carries an `observed` anchor — `drop` is computed as
115
+ * `failingCount < observed`, so an entry with only a `minFailing` threshold is structurally
116
+ * incapable of reporting one. `"0 coverage drops"` therefore means *"0 drops among the entries
117
+ * where a drop is comparable at all"*, and the report must say which denominator that is instead
118
+ * of letting the reader assume it covers every entry.
119
+ */
120
+ readonly dropComparable: boolean;
121
+ /** human sentence for the report line — names the undefended property on a green suite. */
122
+ readonly detail: string;
123
+ }
124
+
125
+ // ── Registry parsing — declarative DATA, validated loudly ─────────────────────────────────────
126
+
127
+ const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
128
+ /** same shape as discrimination-gate's path rule: package-relative, no traversal, no metacharacters. */
129
+ const UNSAFE_FILE = /(^\/)|(^[A-Za-z]:)|(^~)|(^-)|(\/-)|(\.\.(\/|\\|$))|[\0`$;&|<>*?"'\n\r\t\\]/;
130
+
131
+ export interface ParsedRegistry {
132
+ readonly registry: MutationRegistry | null;
133
+ /** every defect found — ANY error makes the registry unusable (a half-valid registry that runs
134
+ * its valid half reports a partial scan as a full one). */
135
+ readonly errors: readonly string[];
136
+ }
137
+
138
+ /** Parse + validate a registry JSON text. Accepts a bare array of entries or `{testCommand, entries}`. */
139
+ export function parseMutationRegistry(text: string): ParsedRegistry {
140
+ let raw: unknown;
141
+ try {
142
+ raw = JSON.parse(text);
143
+ } catch (e) {
144
+ return { registry: null, errors: [`registry is not valid JSON: ${String((e as Error).message).slice(0, 120)}`] };
145
+ }
146
+
147
+ let entriesRaw: unknown;
148
+ let testCommand: string | undefined;
149
+ if (Array.isArray(raw)) {
150
+ entriesRaw = raw;
151
+ } else if (raw && typeof raw === 'object') {
152
+ const obj = raw as { testCommand?: unknown; entries?: unknown };
153
+ entriesRaw = obj.entries;
154
+ if (obj.testCommand !== undefined) {
155
+ if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
156
+ return { registry: null, errors: ['testCommand must be a non-empty string when present'] };
157
+ }
158
+ testCommand = obj.testCommand.trim();
159
+ }
160
+ }
161
+ if (!Array.isArray(entriesRaw)) {
162
+ return { registry: null, errors: ['registry must be an array of entries or {testCommand?, entries: [...]}'] };
163
+ }
164
+ if (entriesRaw.length === 0) {
165
+ // An empty registry "passes" by testing nothing — the same silent hole as a skipped mutation.
166
+ return { registry: null, errors: ['registry has no entries — an empty registry proves nothing and is refused'] };
167
+ }
168
+
169
+ const errors: string[] = [];
170
+ const entries: MutationRegistryEntry[] = [];
171
+ const seen = new Set<string>();
172
+ entriesRaw.forEach((e: unknown, i: number) => {
173
+ const at = `entries[${i}]`;
174
+ if (!e || typeof e !== 'object') { errors.push(`${at}: not an object`); return; }
175
+ const o = e as Record<string, unknown>;
176
+ const id = typeof o['id'] === 'string' ? o['id'] : '';
177
+ if (!SAFE_ID.test(id)) { errors.push(`${at}: id must be kebab-case [a-z0-9-], got ${JSON.stringify(o['id'])}`); return; }
178
+ if (seen.has(id)) { errors.push(`${at}: duplicate id '${id}'`); return; }
179
+ seen.add(id);
180
+ if (typeof o['property'] !== 'string' || o['property'].trim() === '') { errors.push(`${id}: property (the claimed sentence) is required`); return; }
181
+ const file = typeof o['file'] === 'string' ? o['file'] : '';
182
+ if (file === '' || UNSAFE_FILE.test(file)) { errors.push(`${id}: file must be a plain package-relative path, got ${JSON.stringify(o['file'])}`); return; }
183
+ // A registry file under node_modules/ is refused OUTRIGHT (F-2): the registry names protections
184
+ // in the package's OWN code — a dependency's file is not this package's protection — and the
185
+ // scratch copy intentionally SHARES node_modules with the real tree (symlinked for
186
+ // runnability), so such an entry could never be mutated without writing through the link into
187
+ // the real working tree (SPEC rule 3). The executor's realpath containment is the belt; this
188
+ // is the cheaper layer-1 refusal for the case that is always a mistake.
189
+ if (file.split('/').includes('node_modules')) {
190
+ errors.push(`${id}: file targets node_modules/ (${JSON.stringify(file)}) — refused: a dependency file is not this package's protection, and the scratch copy shares node_modules with the REAL tree (rule 3: never mutate the working tree)`);
191
+ return;
192
+ }
193
+ const mut = o['mutation'] as { find?: unknown; replace?: unknown } | undefined;
194
+ if (!mut || typeof mut !== 'object' || typeof mut.find !== 'string' || mut.find.length === 0 || typeof mut.replace !== 'string') {
195
+ errors.push(`${id}: mutation must be {find: <non-empty string>, replace: <string>}`);
196
+ return;
197
+ }
198
+ if (mut.find === mut.replace) { errors.push(`${id}: mutation.replace equals mutation.find — a no-op mutation tests nothing`); return; }
199
+ let minFailing = 1;
200
+ if (o['minFailing'] !== undefined) {
201
+ if (typeof o['minFailing'] !== 'number' || !Number.isInteger(o['minFailing']) || o['minFailing'] < 1) {
202
+ errors.push(`${id}: minFailing must be a positive integer`);
203
+ return;
204
+ }
205
+ minFailing = o['minFailing'];
206
+ }
207
+ let observed: number | undefined;
208
+ if (o['observed'] !== undefined) {
209
+ if (typeof o['observed'] !== 'number' || !Number.isInteger(o['observed']) || o['observed'] < 1) {
210
+ errors.push(`${id}: observed must be a positive integer when present`);
211
+ return;
212
+ }
213
+ observed = o['observed'];
214
+ }
215
+ let maxFailing: number | undefined;
216
+ if (o['maxFailing'] !== undefined) {
217
+ if (typeof o['maxFailing'] !== 'number' || !Number.isInteger(o['maxFailing']) || o['maxFailing'] < 1) {
218
+ errors.push(`${id}: maxFailing must be a positive integer when present`);
219
+ return;
220
+ }
221
+ if (o['maxFailing'] < minFailing) {
222
+ errors.push(`${id}: maxFailing (${o['maxFailing']}) must be >= minFailing (${minFailing}) — a contradictory bound can never pass`);
223
+ return;
224
+ }
225
+ maxFailing = o['maxFailing'];
226
+ }
227
+ const entry: MutationRegistryEntry = {
228
+ id,
229
+ property: o['property'].trim(),
230
+ file,
231
+ mutation: { find: mut.find, replace: mut.replace },
232
+ minFailing,
233
+ ...(observed !== undefined ? { observed } : {}),
234
+ ...(maxFailing !== undefined ? { maxFailing } : {}),
235
+ };
236
+ entries.push(entry);
237
+ });
238
+
239
+ if (errors.length > 0) return { registry: null, errors };
240
+ return testCommand !== undefined
241
+ ? { registry: { testCommand, entries }, errors: [] }
242
+ : { registry: { entries }, errors: [] };
243
+ }
244
+
245
+ // ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
246
+
247
+ export interface AppliedMutation {
248
+ readonly ok: boolean;
249
+ readonly occurrences: number;
250
+ /** the mutated text when ok. */
251
+ readonly text?: string;
252
+ }
253
+
254
+ /** Count NON-OVERLAPPING occurrences and apply only when the count is exactly 1. */
255
+ export function applyMutationToText(source: string, find: string, replace: string): AppliedMutation {
256
+ if (find.length === 0) return { ok: false, occurrences: 0 };
257
+ const occurrences = source.split(find).length - 1;
258
+ if (occurrences !== 1) return { ok: false, occurrences };
259
+ // The replacer FUNCTION makes the replacement LITERAL: with a plain string, `$&`/`` $` ``/`$'`/
260
+ // `$$` are substitution patterns and the gate would silently apply a DIFFERENT mutation than
261
+ // declared (G-3, MEASURED: replace 'X$&Y' on find 'GUARD' produced 'XGUARDY').
262
+ return { ok: true, occurrences, text: source.replace(find, () => replace) };
263
+ }
264
+
265
+ /**
266
+ * The route-c upper bound: the most failing tests a mutation may cause and still be read as
267
+ * BEHAVIOURAL redness attributable to this one protection. Explicit `maxFailing` wins; otherwise
268
+ * `max(anchor*5, anchor+10)` where anchor = `observed` (the measured blast radius) falling back to
269
+ * `minFailing`. k=5 / N=10 justification, MEASURED on the live data: every health-advisor registry
270
+ * entry has observed 1–4 ⇒ bounds 11–20, so legitimate growth (more tests covering the property —
271
+ * 5× proportional, or +10 absolute so tiny `observed` is not strangled) still passes, while the
272
+ * two measured structural blow-ups (route a: whole 484-test suite dead on a parse error; route c:
273
+ * 200+ failing vs observed 1) exceed the bound by an order of magnitude. NEVER unbounded — an
274
+ * entry with no `observed` anchors on its own minFailing contract.
275
+ */
276
+ export function effectiveMaxFailing(entry: MutationRegistryEntry): number {
277
+ if (entry.maxFailing !== undefined) return entry.maxFailing;
278
+ const anchor = entry.observed ?? entry.minFailing ?? 1;
279
+ return Math.max(anchor * 5, anchor + 10);
280
+ }
281
+
282
+ // ── Failing-test count — best-effort SECONDARY signal (never decides red/green) ───────────────
283
+
284
+ /**
285
+ * Parse a failing-test count from runner output. Order of preference:
286
+ * 1. node --test / TAP summary `# fail N` (authoritative for TAP);
287
+ * 2. vitest/jest summary `Tests N failed` / `N failed`;
288
+ * 3. top-level (unindented) `not ok` line count.
289
+ * Returns null when nothing parses — the verdict then rests on the exit code ALONE, by design:
290
+ * count parsing failure must never flip red/green (SPEC design decision).
291
+ */
292
+ export function countFailingTests(rawOutput: string): number | null {
293
+ // vitest colours its summary even when piped (MEASURED: `npm test` piped still carries SGR
294
+ // codes), which would hide `Tests N failed` from every regex below — strip SGR first.
295
+ const output = stripSgr(rawOutput);
296
+ const tap = /^#\s*fail(?:ing)?\s+(\d+)\s*$/m.exec(output);
297
+ if (tap && tap[1] !== undefined) return Number(tap[1]);
298
+ // the `Tests` line, NOT `Test Files` — a failed file count under-reports the failing tests.
299
+ const vitest = /^\s*Tests\s+[^|\n]*?(\d+)\s+failed/m.exec(output);
300
+ if (vitest && vitest[1] !== undefined) return Number(vitest[1]);
301
+ const jest = /^\s*Tests:\s+(?:[^,\n]+,\s*)?(\d+)\s+failed/m.exec(output);
302
+ if (jest && jest[1] !== undefined) return Number(jest[1]);
303
+ const notOk = output.match(/^not ok\b/gm);
304
+ if (notOk !== null && notOk.length > 0) return notOk.length;
305
+ return null;
306
+ }
307
+
308
+ // ── Run-failure classification — the route-a′ signal, from THE RUN ITSELF (round-6 rework) ────
309
+
310
+ /** ANSI SGR sequences (colour). vitest emits them even when piped. */
311
+ // eslint-disable-next-line no-control-regex
312
+ const SGR = /\x1b\[[0-9;]*m/g;
313
+
314
+ function stripSgr(s: string): string {
315
+ return s.replace(SGR, '');
316
+ }
317
+
318
+ export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
319
+
320
+ export interface RunFailureClassification {
321
+ /** which runner's output shape was recognised. */
322
+ readonly runner: RunnerKind;
323
+ /**
324
+ * 'file-load' — the run reported a test FILE failing to load: STRUCTURAL, the mutation broke
325
+ * the module and the failing count is not attributable to the protection;
326
+ * 'assertions' — the red is test ASSERTION failures inside running tests: BEHAVIOURAL, the
327
+ * redness IS the deleted protection;
328
+ * 'unrecognised' — the output shape matched no known runner (or a known runner's red output
329
+ * carried no classifiable failure): a runner-coverage gap of this tool — the
330
+ * verdict must be INCONCLUSIVE, never PROVEN.
331
+ */
332
+ readonly kind: 'file-load' | 'assertions' | 'unrecognised';
333
+ /** for 'file-load': the evidence line; for 'unrecognised': what could not be classified. */
334
+ readonly evidence?: string;
335
+ }
336
+
337
+ /**
338
+ * Classify a RED suite run's output: did a test FILE fail to LOAD (structural) or did test
339
+ * ASSERTIONS fail (behavioural)? This is the round-6 replacement for the isolated-child
340
+ * `import()` load-check, whose three false-PASS routes were ALL artifacts of the isolated import
341
+ * being a DIFFERENT environment than the test runner (env-dependent module goals, unsettled
342
+ * top-level await draining a child's event loop, non-deterministic load aborts dodging a second
343
+ * spawn). The correct signal comes from the SAME run that produced the failing count — no second
344
+ * child, no environment mismatch, nothing to disagree with itself.
345
+ *
346
+ * Runner shapes, both MEASURED (node v22.22.0, vitest 3.2.4 — reproducers in
347
+ * harness-cli/test/mutation-gate-cli.test.ts and this file's unit tests):
348
+ *
349
+ * node --test (flat TAP): every failure is a column-0 `not ok N - <name>` followed by an indented
350
+ * YAML diagnostic block. A test FILE that dies (throw at load, `process.exit` at import, unsettled
351
+ * top-level await ⇒ exit 13, dead require) is reported as a file-named test point whose block
352
+ * carries an `exitCode:` field (and `signal:` when killed) — `failureType: 'testCodeFailure'`,
353
+ * `error: 'test failed'`, `code: 'ERR_TEST_FAILURE'`. An ASSERTION failure — and equally a plain
354
+ * throw INSIDE a running test — NEVER carries `exitCode:`/`signal:`: those fields describe the
355
+ * spawned per-file process, which only appears when the file itself died. That asymmetry is the
356
+ * discriminator, and it is exactly the honest-mutation boundary: delete a guard clause and the
357
+ * file loads, assertions fire, no `exitCode:` field ⇒ behavioural.
358
+ *
359
+ * vitest (`vitest run`): a load/transform/collection error is reported under a `Failed Suites N`
360
+ * section as `FAIL <path> [ <path> ]` (the bracketed suite name repeats the path) with the error
361
+ * where a test name would be, and the summary counts it under `Test Files N failed` while `Tests`
362
+ * shows `no tests` for that file. Assertion failures appear under `Failed Tests N` as
363
+ * `FAIL <path> > <test name>` with `Tests N failed` in the summary. (An unsettled top-level await
364
+ * HANGS vitest rather than failing the file — the suite times out, exitCode null, INCONCLUSIVE —
365
+ * measured, and honestly out of scope for this classifier.)
366
+ *
367
+ * Runner detection is from the OUTPUT SHAPE, not the command (a `npm test` alias hides the
368
+ * runner): node --test requires the TAP header AND node's `# duration_ms` trailer (tape emits TAP
369
+ * + `# fail` but not `# duration_ms`); vitest requires its `RUN v<semver>` banner or `Test Files`
370
+ * summary line. Anything else — jest, mocha, tape, a bare script — is 'unknown'/'unrecognised':
371
+ * the honest, narrowed gap. It is about THIS TOOL's runner coverage, fails LOUD (INCONCLUSIVE),
372
+ * and never silently passes on output it cannot read.
373
+ */
374
+ export function classifyRunFailure(rawOutput: string): RunFailureClassification {
375
+ const output = stripSgr(rawOutput);
376
+
377
+ // node --test (flat TAP, node ≥ 20 shape measured on v22.22.0)
378
+ if (/^TAP version \d+/m.test(output) && /^#\s*duration_ms\s+[\d.]+/m.test(output)) {
379
+ const lines = output.split('\n');
380
+ let sawNotOk = false;
381
+ for (let i = 0; i < lines.length; i++) {
382
+ const point = /^not ok \d+ - (.*)$/.exec(lines[i] ?? '');
383
+ if (point === null) continue;
384
+ sawNotOk = true;
385
+ // Scan this point's YAML diagnostic block (indented, closed by ` ...`) for the per-file
386
+ // process fields. Nested subtest points are indented and never match the column-0 anchor.
387
+ for (let j = i + 1; j < lines.length; j++) {
388
+ const l = lines[j] ?? '';
389
+ if (!/^\s{2}/.test(l) || /^\s{2}\.\.\.\s*$/.test(l)) break;
390
+ const exit = /^\s+exitCode:\s*(-?\d+)/.exec(l);
391
+ const signal = /^\s+signal:\s*'([^']+)'/.exec(l);
392
+ if (exit !== null || signal !== null) {
393
+ const how = exit !== null ? `exitCode ${exit[1]}` : `signal ${signal?.[1]}`;
394
+ return {
395
+ runner: 'node-test',
396
+ kind: 'file-load',
397
+ evidence: `node --test reported the test FILE '${(point[1] ?? '').trim()}' failing as a whole (${how}) — the file (or a module it imports) died at load, not an assertion`,
398
+ };
399
+ }
400
+ }
401
+ }
402
+ const tapFail = /^#\s*fail(?:ing)?\s+(\d+)\s*$/m.exec(output);
403
+ if (sawNotOk || (tapFail !== null && Number(tapFail[1]) > 0)) {
404
+ return { runner: 'node-test', kind: 'assertions' };
405
+ }
406
+ return { runner: 'node-test', kind: 'unrecognised', evidence: 'red node --test run with no failing TAP test point — the redness has no classifiable source' };
407
+ }
408
+
409
+ // vitest (`vitest run`, shape measured on 3.2.4)
410
+ if (/^\s*RUN\s+v\d+\./m.test(output) || /^\s*Test Files\s/m.test(output)) {
411
+ const failedSuites = /\bFailed Suites\s+(\d+)\b/.exec(output);
412
+ const suiteFail = /^\s*FAIL\s+(\S+)\s+\[\s*\1\s*\]/m.exec(output);
413
+ if (failedSuites !== null || suiteFail !== null) {
414
+ const what = suiteFail !== null ? `'${suiteFail[1]}'` : `${failedSuites?.[1]} suite(s)`;
415
+ return {
416
+ runner: 'vitest',
417
+ kind: 'file-load',
418
+ evidence: `vitest reported ${what} failing to load (Failed Suites — a collection/import error, not a test assertion)`,
419
+ };
420
+ }
421
+ if (/^\s*Tests\s+[^|\n]*?\d+\s+failed/m.test(output) || /\bFailed Tests\s+\d+\b/.test(output)) {
422
+ return { runner: 'vitest', kind: 'assertions' };
423
+ }
424
+ return { runner: 'vitest', kind: 'unrecognised', evidence: 'red vitest run with neither Failed Suites nor failed tests in the output — the redness has no classifiable source (unhandled error outside any test?)' };
425
+ }
426
+
427
+ return {
428
+ runner: 'unknown',
429
+ kind: 'unrecognised',
430
+ evidence: 'output matches neither node --test (TAP + # duration_ms) nor vitest (RUN v / Test Files) — no classifier for this runner; structural-vs-behavioural redness cannot be told apart, so the verdict may not be PROVEN',
431
+ };
432
+ }
433
+
434
+ // ── Baseline (rule 3's runnability half) ──────────────────────────────────────────────────────
435
+
436
+ export interface BaselineResult {
437
+ readonly ok: boolean;
438
+ readonly detail: string;
439
+ }
440
+
441
+ /**
442
+ * A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
443
+ * "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
444
+ */
445
+ export function classifyBaseline(exitCode: number | null): BaselineResult {
446
+ if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
447
+ if (exitCode === null) {
448
+ return { ok: false, detail: 'baseline suite produced no exit code (timeout/spawn failure) — the copy is not runnable; fix the copy, do not read this as a mutation result' };
449
+ }
450
+ return { ok: false, detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — a broken copy cannot prove anything; fix the copy (node_modules link? path-dependent test?) before trusting any verdict` };
451
+ }
452
+
453
+ // ── Classification (rules 1 + 2, and the drop decision) ───────────────────────────────────────
454
+
455
+ /**
456
+ * Classify one entry's observation.
457
+ *
458
+ * PRECEDENCE LATTICE (checked top to bottom; each verdict is justified by what it invalidates
459
+ * BELOW it — a verdict may only outrank another when it makes that other's evidence meaningless):
460
+ * 1. NOT_APPLIED — nothing was mutated, so no run observation means anything;
461
+ * 2. MUTATION_UNPARSEABLE — the mutated file is not even syntactically a module; a non-parsing
462
+ * file never RUNS, so no load- or count-signal can exist (which is
463
+ * why it sits above MUTATION_LOAD_FATAL);
464
+ * 3. MUTATION_LOAD_FATAL — the run happened, but its own output says a test FILE died at
465
+ * load: the redness is structural and every count from that same run
466
+ * is non-attributable — so it outranks every count-based verdict AND
467
+ * the flaky-rebaseline check (a structurally-broken run needs no
468
+ * attribution analysis);
469
+ * 4. INCONCLUSIVE (no exit) — the run produced nothing to classify at all;
470
+ * 5. UNDEFENDED (exit 0) — the disjoint GREEN arm: mutually exclusive with every red-based
471
+ * verdict below;
472
+ * 6. INCONCLUSIVE (unrecognised output) — red, but the shape is unreadable: counts parsed out of
473
+ * unrecognised output must not reach BELOW_MIN/OVER_FAILING/PROVEN;
474
+ * 7. INCONCLUSIVE (flaky rebaseline) — red, readable, but not attributable;
475
+ * 8. BELOW_MIN → 9. OVER_FAILING — reliable-count contract checks, both failing;
476
+ * 10. PROVEN — applied, red, behavioural, attributable, within bounds.
477
+ *
478
+ * THE DROP DECISION (SPEC §Reporting, decided here + justified): `failing < observed` but still
479
+ * `>= minFailing` is a LOUD WARNING, not a failure. Two reasons, both load-bearing:
480
+ * • the count is a BEST-EFFORT secondary parsed from runner output — making it verdict-deciding
481
+ * would let a count-parse failure flip red/green, which the design decisions forbid;
482
+ * • `observed` is a historical measurement, `minFailing` is the entry's declared CONTRACT. A
483
+ * contract violation fails (BELOW_MIN — only ever on a reliable count); history drifting down
484
+ * while the contract still holds is the early warning, reported loudly so a human re-pins
485
+ * `observed` or investigates — silently normalising it would erase the signal.
486
+ */
487
+ export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
488
+ const e = obs.entry;
489
+ const minFailing = e.minFailing ?? 1;
490
+ const base = {
491
+ id: e.id,
492
+ property: e.property,
493
+ file: e.file,
494
+ occurrences: obs.occurrences,
495
+ exitCode: obs.exitCode,
496
+ failingCount: obs.failingCount,
497
+ // an `observed` anchor is the ONLY thing that makes a drop detectable at all (round-7 honesty)
498
+ dropComparable: e.observed !== undefined,
499
+ };
500
+
501
+ if (obs.occurrences !== 1) {
502
+ return {
503
+ ...base,
504
+ applied: false,
505
+ verdict: 'NOT_APPLIED',
506
+ drop: false,
507
+ detail: obs.occurrences === 0
508
+ ? `mutation did not apply: find-text absent from ${e.file} (code drifted?) — NOTHING was tested; a skipped mutation may not report the property as proven (rule 1)`
509
+ : `mutation did not apply: find-text occurs ${obs.occurrences} times in ${e.file} (must be exactly once) — ambiguous surgery is refused, not guessed (rule 1)`,
510
+ };
511
+ }
512
+
513
+ // Route (a) — STRUCTURAL redness: the mutated file does not parse, so the suite (or its import
514
+ // chain) dies wholesale. That says nothing about the named protection. A registry mutation must
515
+ // delete the protection while keeping the file loadable; this is a bad entry, and it wins even
516
+ // over a red exit + a count, because that red is exactly the false signal being refused.
517
+ if (obs.parseError !== undefined) {
518
+ return {
519
+ ...base,
520
+ applied: true,
521
+ verdict: 'MUTATION_UNPARSEABLE',
522
+ drop: false,
523
+ detail: `the MUTATED ${e.file} does not parse (${obs.parseError.slice(0, 160)}) — a SETUP/registry error, not a discrimination proof: whole-suite redness from a load failure is not attributable to the protection; rewrite the mutation to delete the protection while keeping the file loadable`,
524
+ };
525
+ }
526
+
527
+ // Route (a′) — the run-derived file-load signal (round-6 rework): THE SUITE RUN's own output
528
+ // reported a test FILE failing to load under the mutation. Structural redness — the mutation
529
+ // broke the module, every count the same run produced is not attributable to the protection —
530
+ // so this outranks every count-based verdict AND the rebaseline check, exactly like
531
+ // MUTATION_UNPARSEABLE does. Unlike the replaced isolated-import load-check there is no second
532
+ // environment to disagree: the signal and the count come from ONE run.
533
+ if (obs.fileLoadFailure !== undefined) {
534
+ return {
535
+ ...base,
536
+ applied: true,
537
+ verdict: 'MUTATION_LOAD_FATAL',
538
+ drop: false,
539
+ detail: `the suite run reports a test FILE failing to LOAD under the mutation (${obs.fileLoadFailure.slice(0, 220)}) — structural, not behavioural: the mutation broke the module, so the redness is not attributable to the protection; rewrite the mutation to delete the protection while keeping the module evaluable`,
540
+ };
541
+ }
542
+
543
+ if (obs.exitCode === null) {
544
+ return {
545
+ ...base,
546
+ applied: true,
547
+ verdict: 'INCONCLUSIVE',
548
+ drop: false,
549
+ detail: 'suite produced NO exit code under the mutation (timeout / spawn failure) — inconclusive is a FAILURE, never a pass',
550
+ };
551
+ }
552
+
553
+ if (obs.exitCode === 0) {
554
+ return {
555
+ ...base,
556
+ applied: true,
557
+ verdict: 'UNDEFENDED',
558
+ drop: false,
559
+ detail: `suite stayed GREEN with the protection deleted — property UNDEFENDED: "${e.property}" (${e.file}). The suite would not notice this protection regressing (rule 2).`,
560
+ };
561
+ }
562
+
563
+ // Unrecognised output shape on a RED run — a runner-coverage gap of THIS TOOL (jest, mocha, an
564
+ // opaque wrapper script…): structural-vs-behavioural cannot be told apart, so nothing below
565
+ // (counts, bounds, PROVEN) may run. INCONCLUSIVE, loud, before every count-based branch.
566
+ if (obs.outputUnrecognised !== undefined) {
567
+ return {
568
+ ...base,
569
+ applied: true,
570
+ verdict: 'INCONCLUSIVE',
571
+ drop: false,
572
+ detail: `suite red but the runner output is UNRECOGNISED (${obs.outputUnrecognised.slice(0, 220)}) — this tool has no classifier for the output shape, so file-load redness cannot be told from assertion redness; INCONCLUSIVE is a FAILURE, never a pass (run the gate with a node --test or vitest test command, or extend classifyRunFailure)`,
573
+ };
574
+ }
575
+
576
+ // Route (b) — SPURIOUS redness: the suite went red under the mutation, but the RESTORED tree did
577
+ // not come back green, so the suite is flaky and an unrelated neighbour may be what went red.
578
+ // Not attributable ⇒ INCONCLUSIVE (a failure, never a pass).
579
+ if (obs.rebaselineExitCode !== undefined && obs.rebaselineExitCode !== 0) {
580
+ return {
581
+ ...base,
582
+ applied: true,
583
+ verdict: 'INCONCLUSIVE',
584
+ drop: false,
585
+ detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? 'no exit code' : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
586
+ };
587
+ }
588
+
589
+ if (obs.failingCount !== null && obs.failingCount < minFailing) {
590
+ return {
591
+ ...base,
592
+ applied: true,
593
+ verdict: 'BELOW_MIN',
594
+ drop: false,
595
+ detail: `suite red, but only ${obs.failingCount} failing test(s) — below the entry's own minFailing contract of ${minFailing}`,
596
+ };
597
+ }
598
+
599
+ // Route (c) — the symmetric arm of the drop warning: a RELIABLE count wildly ABOVE the entry's
600
+ // bound means the mutation broke far more than the protection's own tests (a structural blow-up
601
+ // that still parsed — dead export surface, poisoned shared helper). Not attributable ⇒ FAILURE.
602
+ const bound = effectiveMaxFailing(e);
603
+ if (obs.failingCount !== null && obs.failingCount > bound) {
604
+ return {
605
+ ...base,
606
+ applied: true,
607
+ verdict: 'OVER_FAILING',
608
+ drop: false,
609
+ detail: `suite red with ${obs.failingCount} failing test(s) — far more than this protection's own tests (bound ${bound}${e.maxFailing !== undefined ? ', explicit maxFailing' : `, default from ${e.observed !== undefined ? `observed ${e.observed}` : `minFailing ${minFailing}`}`}); the redness is not attributable to the protection — narrow the mutation or pin maxFailing if the blast radius is genuinely this large`,
610
+ };
611
+ }
612
+
613
+ const drop = obs.failingCount !== null && e.observed !== undefined && obs.failingCount < e.observed;
614
+ return {
615
+ ...base,
616
+ applied: true,
617
+ verdict: 'PROVEN',
618
+ drop,
619
+ detail: drop
620
+ ? `suite red (${obs.failingCount} failing vs ${e.observed} observed when written) — COVERAGE DROP: still defended, but fewer tests notice; investigate or re-pin observed`
621
+ : `suite red under the mutation (${obs.failingCount === null ? 'failing count unavailable — exit code is the verdict' : `${obs.failingCount} failing`}) — the test discriminates`,
622
+ };
623
+ }
624
+
625
+ /** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
626
+ const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
627
+
628
+ /** Exit contract: 0 all proven · 1 any entry failed (or red baseline) · (2 = usage/setup, CLI-side). */
629
+ export function mutationGateExitCode(results: readonly MutationEntryResult[], baselineOk: boolean): number {
630
+ if (!baselineOk) return 1;
631
+ if (results.length === 0) return 1; // nothing ran ⇒ nothing proven
632
+ return results.some((r) => FAILING_VERDICTS.has(r.verdict)) ? 1 : 0;
633
+ }
634
+
635
+ // ── Report ────────────────────────────────────────────────────────────────────────────────────
636
+
637
+ export interface MutationGateSummary {
638
+ readonly total: number;
639
+ readonly proven: number;
640
+ readonly undefended: number;
641
+ readonly notApplied: number;
642
+ readonly belowMin: number;
643
+ readonly unparseable: number;
644
+ readonly loadFatal: number;
645
+ readonly overFailing: number;
646
+ readonly inconclusive: number;
647
+ readonly drops: number;
648
+ /** how many entries a drop is COMPARABLE for (they carry an `observed` anchor) — the honest
649
+ * denominator of `drops`. `drops: 0` over `dropComparable: 23` of `total: 62` says what it can
650
+ * and cannot see; a bare "0 coverage drops" over-reads as "all 62 checked" (QE round-7). */
651
+ readonly dropComparable: number;
652
+ }
653
+
654
+ export function summarizeMutationResults(results: readonly MutationEntryResult[]): MutationGateSummary {
655
+ return {
656
+ total: results.length,
657
+ proven: results.filter((r) => r.verdict === 'PROVEN').length,
658
+ undefended: results.filter((r) => r.verdict === 'UNDEFENDED').length,
659
+ notApplied: results.filter((r) => r.verdict === 'NOT_APPLIED').length,
660
+ belowMin: results.filter((r) => r.verdict === 'BELOW_MIN').length,
661
+ unparseable: results.filter((r) => r.verdict === 'MUTATION_UNPARSEABLE').length,
662
+ loadFatal: results.filter((r) => r.verdict === 'MUTATION_LOAD_FATAL').length,
663
+ overFailing: results.filter((r) => r.verdict === 'OVER_FAILING').length,
664
+ inconclusive: results.filter((r) => r.verdict === 'INCONCLUSIVE').length,
665
+ drops: results.filter((r) => r.drop).length,
666
+ dropComparable: results.filter((r) => r.dropComparable).length,
667
+ };
668
+ }
669
+
670
+ const VERDICT_MARK: Record<MutationVerdict, string> = {
671
+ PROVEN: '✓',
672
+ UNDEFENDED: '✗',
673
+ NOT_APPLIED: '✗',
674
+ BELOW_MIN: '✗',
675
+ MUTATION_UNPARSEABLE: '✗',
676
+ MUTATION_LOAD_FATAL: '✗',
677
+ OVER_FAILING: '✗',
678
+ INCONCLUSIVE: '✗',
679
+ };
680
+
681
+ export function renderMutationReport(
682
+ results: readonly MutationEntryResult[],
683
+ baseline: BaselineResult,
684
+ packageDir: string,
685
+ ): string {
686
+ const lines: string[] = [];
687
+ lines.push(`mutation-gate: ${packageDir}`);
688
+ lines.push(` baseline: ${baseline.ok ? 'GREEN' : 'ERROR'} — ${baseline.detail}`);
689
+ if (!baseline.ok) return lines.join('\n');
690
+ for (const r of results) {
691
+ const count = r.failingCount === null ? 'n/a' : String(r.failingCount);
692
+ lines.push(` ${VERDICT_MARK[r.verdict]} ${r.id} applied=${r.applied ? 'yes' : 'no'} failing=${count} ${r.verdict}${r.drop ? ' (COVERAGE DROP)' : ''}`);
693
+ if (r.verdict !== 'PROVEN' || r.drop) lines.push(` ${r.detail}`);
694
+ }
695
+ const s = summarizeMutationResults(results);
696
+ lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
697
+ lines.push(mutationGateExitCode(results, baseline.ok) === 0
698
+ ? ' verdict: PASS — every named protection has a test that goes red when the protection is deleted'
699
+ : ' verdict: FAIL — at least one named protection is undefended, unmutable, or unproven');
700
+ return lines.join('\n');
701
+ }