playwright-mutation-gate 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lex.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Shared single-pass lexer for the string-based spec analysis. Classifies
3
+ * every char of a source (a whole file or a single line) so the scanners in
4
+ * invert.ts and mutate.ts agree about what is code. One implementation on
5
+ * purpose: two hand-rolled lexers had already diverged once.
6
+ */
7
+ /** Char inside a string, template literal, block comment or regex literal. */
8
+ export const NON_CODE = 0;
9
+ /** Executable source. */
10
+ export const CODE = 1;
11
+ /** Char of a `//` comment that starts in code context. */
12
+ export const LINE_COMMENT = 2;
13
+ // Keywords after which a `/` starts a regex literal, not division.
14
+ const REGEX_PRECEDING_KEYWORDS = new Set([
15
+ 'return',
16
+ 'typeof',
17
+ 'instanceof',
18
+ 'in',
19
+ 'of',
20
+ 'new',
21
+ 'delete',
22
+ 'void',
23
+ 'do',
24
+ 'else',
25
+ 'case',
26
+ 'yield',
27
+ 'await',
28
+ ]);
29
+ const isWordChar = (c) => c !== undefined && /[\w$]/.test(c);
30
+ /**
31
+ * A `/` in code context is a regex literal when the last significant code
32
+ * token cannot end an expression: after a value (identifier, `)`, `]`,
33
+ * string) it is division.
34
+ */
35
+ function startsRegex(lastSig, lastWord) {
36
+ if (lastSig === '')
37
+ return true;
38
+ if (isWordChar(lastSig))
39
+ return REGEX_PRECEDING_KEYWORDS.has(lastWord);
40
+ return lastSig !== ')' && lastSig !== ']' && lastSig !== '"' && lastSig !== "'" && lastSig !== '`';
41
+ }
42
+ /**
43
+ * Classify every char of `src` as CODE, LINE_COMMENT or NON_CODE. Handles
44
+ * strings with escapes, template literals with nested `${}` expressions,
45
+ * `//` and block comments, and regex literals (heuristic: see startsRegex).
46
+ */
47
+ export function lex(src) {
48
+ const kind = new Uint8Array(src.length).fill(CODE);
49
+ // '`' = inside a template literal, '{' = inside a `${}` expression.
50
+ const stack = [];
51
+ // Last significant code char, its index, and the word it ends, for regex detection.
52
+ let lastSig = '';
53
+ let lastSigIndex = -2;
54
+ let lastWord = '';
55
+ let i = 0;
56
+ while (i < src.length) {
57
+ const c = src[i];
58
+ if (stack[stack.length - 1] === '`') {
59
+ kind[i] = NON_CODE;
60
+ if (c === '\\') {
61
+ if (i + 1 < src.length)
62
+ kind[i + 1] = NON_CODE;
63
+ i++;
64
+ }
65
+ else if (c === '`') {
66
+ stack.pop();
67
+ lastSig = '`';
68
+ lastWord = '';
69
+ }
70
+ else if (c === '$' && src[i + 1] === '{') {
71
+ kind[i + 1] = NON_CODE;
72
+ stack.push('{');
73
+ lastSig = '';
74
+ lastWord = '';
75
+ i++;
76
+ }
77
+ i++;
78
+ continue;
79
+ }
80
+ if (c === "'" || c === '"') {
81
+ kind[i] = NON_CODE;
82
+ i++;
83
+ while (i < src.length && src[i] !== '\n') {
84
+ kind[i] = NON_CODE;
85
+ if (src[i] === '\\' && i + 1 < src.length) {
86
+ kind[i + 1] = NON_CODE;
87
+ i += 2;
88
+ continue;
89
+ }
90
+ i++;
91
+ if (src[i - 1] === c)
92
+ break;
93
+ }
94
+ lastSig = c;
95
+ lastWord = '';
96
+ continue;
97
+ }
98
+ if (c === '`') {
99
+ kind[i] = NON_CODE;
100
+ stack.push('`');
101
+ i++;
102
+ continue;
103
+ }
104
+ if (c === '/' && src[i + 1] === '/') {
105
+ while (i < src.length && src[i] !== '\n')
106
+ kind[i++] = LINE_COMMENT;
107
+ continue;
108
+ }
109
+ if (c === '/' && src[i + 1] === '*') {
110
+ kind[i] = NON_CODE;
111
+ kind[i + 1] = NON_CODE;
112
+ i += 2;
113
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/'))
114
+ kind[i++] = NON_CODE;
115
+ if (i < src.length) {
116
+ kind[i] = NON_CODE;
117
+ kind[i + 1] = NON_CODE;
118
+ i += 2;
119
+ }
120
+ continue;
121
+ }
122
+ if (c === '/' && startsRegex(lastSig, lastWord)) {
123
+ kind[i] = NON_CODE;
124
+ i++;
125
+ let inClass = false;
126
+ while (i < src.length && src[i] !== '\n') {
127
+ kind[i] = NON_CODE;
128
+ if (src[i] === '\\' && i + 1 < src.length) {
129
+ kind[i + 1] = NON_CODE;
130
+ i += 2;
131
+ continue;
132
+ }
133
+ if (src[i] === '[')
134
+ inClass = true;
135
+ else if (src[i] === ']')
136
+ inClass = false;
137
+ else if (src[i] === '/' && !inClass) {
138
+ i++;
139
+ break;
140
+ }
141
+ i++;
142
+ }
143
+ while (i < src.length && /[a-z]/.test(src[i]))
144
+ kind[i++] = NON_CODE; // flags
145
+ lastSig = '/';
146
+ lastWord = '';
147
+ continue;
148
+ }
149
+ if (!/\s/.test(c)) {
150
+ if (isWordChar(c)) {
151
+ lastWord = isWordChar(lastSig) && lastSigIndex === i - 1 ? lastWord + c : c;
152
+ }
153
+ else {
154
+ lastWord = '';
155
+ }
156
+ lastSig = c;
157
+ lastSigIndex = i;
158
+ if (stack.length > 0) {
159
+ if (c === '{')
160
+ stack.push('{');
161
+ else if (c === '}')
162
+ stack.pop();
163
+ }
164
+ }
165
+ i++;
166
+ continue;
167
+ }
168
+ return kind;
169
+ }
170
+ /**
171
+ * Find the `)` matching the `(` at `open`, counting only CODE chars.
172
+ * Returns -1 when it does not close within `src`.
173
+ */
174
+ export function findCloseMasked(src, kind, open) {
175
+ let depth = 0;
176
+ for (let i = open; i < src.length; i++) {
177
+ if (kind[i] !== CODE)
178
+ continue;
179
+ if (src[i] === '(')
180
+ depth++;
181
+ else if (src[i] === ')' && --depth === 0)
182
+ return i;
183
+ }
184
+ return -1;
185
+ }
@@ -0,0 +1,66 @@
1
+ export declare const MARKER = "// @primary-assert";
2
+ export type Mutation = {
3
+ testTitle: string;
4
+ /** 1-based line of the test() declaration, for file:line targeting. */
5
+ testLine: number;
6
+ /** 1-based line of the `// @primary-assert` marker. */
7
+ markerLine: number;
8
+ /** 1-based line of the assertion that was inverted. */
9
+ assertLine: number;
10
+ /** Full spec source with exactly this one assertion inverted. */
11
+ mutatedSource: string;
12
+ } | {
13
+ testTitle: string;
14
+ testLine: number;
15
+ markerLine: number;
16
+ /** 1-based line of the assertion candidate; null when none was located. */
17
+ assertLine: number | null;
18
+ cannotMutate: string;
19
+ };
20
+ export interface PlanOptions {
21
+ /**
22
+ * Extra identifiers to treat as test builders, on top of `test` and the
23
+ * auto-detected `.extend` chains and renamed imports. For custom fixtures
24
+ * imported by their own name from another module (`import { authedTest }
25
+ * from './fixtures'`), which cannot be detected without resolving the import.
26
+ */
27
+ testIds?: string[];
28
+ }
29
+ export interface SpecPlan {
30
+ /** One entry per marked test: either a mutated copy or a cannot-mutate reason. */
31
+ mutations: Mutation[];
32
+ /** Runnable tests with no marker (sentinel accounting). skip/fixme/fail are exempt. */
33
+ unmarkedTests: {
34
+ title: string;
35
+ line: number;
36
+ }[];
37
+ /** 1-based lines of valid markers that sit outside every test() block. */
38
+ strayMarkerLines: number[];
39
+ /**
40
+ * 1-based lines of test.only() declarations. Any .only filters out every
41
+ * other test in the file, so the runner refuses the whole spec.
42
+ */
43
+ onlyTestLines: number[];
44
+ /** 1-based lines mentioning @primary-assert that do not form a valid marker. */
45
+ malformedMarkerLines: number[];
46
+ }
47
+ /**
48
+ * Parse a spec source and plan its mutations: one inverted copy per marked
49
+ * test. Anything the gate cannot honestly mutate is reported with a reason,
50
+ * never skipped silently.
51
+ */
52
+ export declare function planSpecMutations(source: string, opts?: PlanOptions): SpecPlan;
53
+ /**
54
+ * Name for a mutated copy: the marker segment goes right after the first
55
+ * dot-segment of the basename, so the ENTIRE original tail is preserved and
56
+ * the copy matches whatever testMatch convention picked up the original
57
+ * (`checkout.e2e.ts` -> `checkout.pmg-mutation-0.e2e.ts`).
58
+ */
59
+ export declare function mutationFilePath(specPath: string, index: number, tag?: string): string;
60
+ /**
61
+ * Write the mutated copy next to the original spec (same dir keeps relative
62
+ * imports and the Playwright testDir working) and return its path. The name
63
+ * carries the process id so overlapping gate runs in one checkout cannot
64
+ * collide. The caller owns cleanup.
65
+ */
66
+ export declare function writeMutationFile(specPath: string, index: number, mutatedSource: string): string;
package/dist/mutate.js ADDED
@@ -0,0 +1,266 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { invertAssertLine } from './invert.js';
4
+ import { CODE, LINE_COMMENT, findCloseMasked, lex } from './lex.js';
5
+ export const MARKER = '// @primary-assert';
6
+ /** Read the test title: the first argument when it is a literal without interpolation. */
7
+ function readTitle(src, open) {
8
+ let i = open + 1;
9
+ while (i < src.length && /\s/.test(src[i]))
10
+ i++;
11
+ const quote = src[i];
12
+ if (quote !== "'" && quote !== '"' && quote !== '`')
13
+ return '(dynamic title)';
14
+ let title = '';
15
+ for (i++; i < src.length; i++) {
16
+ const c = src[i];
17
+ if (c === '\\' && i + 1 < src.length) {
18
+ title += src[i + 1];
19
+ i++;
20
+ }
21
+ else if (quote === '`' && c === '$' && src[i + 1] === '{') {
22
+ return '(dynamic title)';
23
+ }
24
+ else if (c === quote) {
25
+ return title;
26
+ }
27
+ else {
28
+ title += c;
29
+ }
30
+ }
31
+ return title;
32
+ }
33
+ const IDENT = '[A-Za-z_$][\\w$]*';
34
+ /** True when the first argument at `open` (the call's `(`) is a string literal. */
35
+ function firstArgIsStringLiteral(src, open) {
36
+ let i = open + 1;
37
+ while (i < src.length && /\s/.test(src[i]))
38
+ i++;
39
+ const c = src[i];
40
+ return c === "'" || c === '"' || c === '`';
41
+ }
42
+ /**
43
+ * The set of identifiers that declare a test in this file. Always `test`, plus
44
+ * any passed in explicitly (`--test-id`), plus auto-detected custom fixtures:
45
+ * a local `const X = <known>.extend(...)` chain, or a renamed import
46
+ * (`import { test as X }`). Resolved to a fixpoint so chained extends and
47
+ * renames of just-discovered ids are picked up too.
48
+ */
49
+ function collectTestIds(src, kind, extra) {
50
+ const ids = new Set(['test', ...extra]);
51
+ const renameRe = new RegExp(`\\b(${IDENT})\\s+as\\s+(${IDENT})`, 'g');
52
+ const extendRe = new RegExp(`\\b(?:const|let|var)\\s+(${IDENT})\\s*=\\s*(${IDENT})\\s*\\.\\s*extend\\s*\\(`, 'g');
53
+ let changed = true;
54
+ while (changed) {
55
+ changed = false;
56
+ for (const m of src.matchAll(renameRe)) {
57
+ if (kind[m.index] !== CODE)
58
+ continue;
59
+ if (ids.has(m[1]) && !ids.has(m[2])) {
60
+ ids.add(m[2]);
61
+ changed = true;
62
+ }
63
+ }
64
+ for (const m of src.matchAll(extendRe)) {
65
+ if (kind[m.index] !== CODE)
66
+ continue;
67
+ if (ids.has(m[2]) && !ids.has(m[1])) {
68
+ ids.add(m[1]);
69
+ changed = true;
70
+ }
71
+ }
72
+ }
73
+ return ids;
74
+ }
75
+ function findTestBlocks(src, kind, ids) {
76
+ const alt = [...ids].sort((a, b) => b.length - a.length).join('|');
77
+ const anchor = new RegExp(`\\b(?:${alt})\\s*(?:\\.\\s*(only|skip|fixme|fail)\\s*)?\\(`, 'g');
78
+ const blocks = [];
79
+ for (const m of src.matchAll(anchor)) {
80
+ if (kind[m.index] !== CODE)
81
+ continue;
82
+ // Excludes member calls like `t.test(...)`; `test.describe(` and
83
+ // `test.step(` never match the regex in the first place.
84
+ if (m.index > 0 && src[m.index - 1] === '.')
85
+ continue;
86
+ const mode = (m[1] ?? '');
87
+ const open = m.index + m[0].length - 1;
88
+ // `test.skip('title', fn)` declares a skipped test; `test.skip(!cond)` is a
89
+ // runtime modifier statement inside a test body. Only the declaration form
90
+ // (string title) is a block; the modifier is left to sit inside its test.
91
+ if (UNGATEABLE_MODES.has(mode) && !firstArgIsStringLiteral(src, open))
92
+ continue;
93
+ const close = findCloseMasked(src, kind, open);
94
+ blocks.push({
95
+ title: readTitle(src, open),
96
+ mode,
97
+ anchor: m.index,
98
+ open,
99
+ close: close === -1 ? src.length : close,
100
+ markers: [],
101
+ });
102
+ }
103
+ return blocks;
104
+ }
105
+ // test.skip/fixme never run and test.fail inverts pass/fail semantics, so a
106
+ // mutation verdict on them would be a lie; they are also exempt from sentinel
107
+ // accounting. test.only runs and is treated like a plain test.
108
+ const UNGATEABLE_MODES = new Set(['skip', 'fixme', 'fail']);
109
+ /**
110
+ * Parse a spec source and plan its mutations: one inverted copy per marked
111
+ * test. Anything the gate cannot honestly mutate is reported with a reason,
112
+ * never skipped silently.
113
+ */
114
+ export function planSpecMutations(source, opts = {}) {
115
+ const kind = lex(source);
116
+ const ids = collectTestIds(source, kind, opts.testIds ?? []);
117
+ const blocks = findTestBlocks(source, kind, ids);
118
+ const lines = source.split('\n');
119
+ // Char offset of each line start, to map offsets to 1-based line numbers.
120
+ const starts = [0];
121
+ for (let i = 0; i < source.length; i++)
122
+ if (source[i] === '\n')
123
+ starts.push(i + 1);
124
+ const lineOf = (offset) => {
125
+ let n = 0;
126
+ while (n + 1 < starts.length && starts[n + 1] <= offset)
127
+ n++;
128
+ return n + 1;
129
+ };
130
+ // A line "has code" when any of its chars is CODE and not whitespace;
131
+ // blank, comment-only and string-interior lines do not.
132
+ const lineHasCode = (n) => {
133
+ for (let j = starts[n], end = starts[n] + lines[n].length; j < end; j++) {
134
+ if (kind[j] === CODE && !/\s/.test(source[j]))
135
+ return true;
136
+ }
137
+ return false;
138
+ };
139
+ // The `//` line comment on a line (in code context), or null when the line
140
+ // has none. `codeBefore` distinguishes a trailing marker (assertion code
141
+ // precedes the comment) from a whole-line one.
142
+ const lineComment = (n) => {
143
+ const start = starts[n];
144
+ const end = start + lines[n].length;
145
+ let c = -1;
146
+ for (let j = start; j < end; j++) {
147
+ if (kind[j] === LINE_COMMENT) {
148
+ c = j;
149
+ break;
150
+ }
151
+ }
152
+ if (c === -1)
153
+ return null;
154
+ let codeBefore = false;
155
+ for (let j = start; j < c; j++) {
156
+ if (kind[j] === CODE && !/\s/.test(source[j])) {
157
+ codeBefore = true;
158
+ break;
159
+ }
160
+ }
161
+ return { codeBefore, text: source.slice(c, end).trim() };
162
+ };
163
+ const strayMarkerLines = [];
164
+ const malformedMarkerLines = [];
165
+ for (let n = 0; n < lines.length; n++) {
166
+ const line = lines[n];
167
+ if (!line.includes('@primary-assert'))
168
+ continue;
169
+ // A valid marker is a `// @primary-assert` line comment in code context,
170
+ // either on its own line (assertion is the next code line) or trailing the
171
+ // assertion. The same text inside a block comment, string, template, or as
172
+ // a near-miss (`// @primary-assert: foo`) is not a marker.
173
+ const lc = lineComment(n);
174
+ if (!lc || lc.text !== MARKER) {
175
+ malformedMarkerLines.push(n + 1);
176
+ continue;
177
+ }
178
+ const firstNonWs = starts[n] + (line.length - line.trimStart().length);
179
+ const block = blocks.find((b) => firstNonWs > b.open && firstNonWs < b.close);
180
+ if (block)
181
+ block.markers.push({ line: n + 1, sameLine: lc.codeBefore });
182
+ else
183
+ strayMarkerLines.push(n + 1);
184
+ }
185
+ const mutations = [];
186
+ const unmarkedTests = [];
187
+ const onlyTestLines = [];
188
+ for (const block of blocks) {
189
+ const testLine = lineOf(block.anchor);
190
+ if (block.mode === 'only')
191
+ onlyTestLines.push(testLine);
192
+ if (block.markers.length === 0) {
193
+ if (!UNGATEABLE_MODES.has(block.mode)) {
194
+ unmarkedTests.push({ title: block.title, line: testLine });
195
+ }
196
+ continue;
197
+ }
198
+ const marker = block.markers[0];
199
+ const base = { testTitle: block.title, testLine, markerLine: marker.line };
200
+ if (UNGATEABLE_MODES.has(block.mode)) {
201
+ const why = block.mode === 'fail'
202
+ ? 'test.fail() expects failure, so a mutation verdict would be meaningless'
203
+ : `test.${block.mode}() never runs, so a mutation cannot be observed`;
204
+ mutations.push({ ...base, assertLine: null, cannotMutate: why });
205
+ continue;
206
+ }
207
+ if (block.markers.length > 1) {
208
+ mutations.push({
209
+ ...base,
210
+ assertLine: null,
211
+ cannotMutate: `found ${block.markers.length} @primary-assert markers in one test, expected exactly one`,
212
+ });
213
+ continue;
214
+ }
215
+ // A trailing marker sits on the assertion itself; a whole-line marker
216
+ // points at the next line that contains code (blank and comment-only lines
217
+ // are skipped, not mutated).
218
+ let target; // 0-based index of the assertion line
219
+ if (marker.sameLine) {
220
+ target = marker.line - 1;
221
+ }
222
+ else {
223
+ target = marker.line; // 0-based index of the line after the marker
224
+ while (target < lines.length && !lineHasCode(target))
225
+ target++;
226
+ if (target >= lines.length) {
227
+ mutations.push({ ...base, assertLine: null, cannotMutate: 'no code line follows the marker' });
228
+ continue;
229
+ }
230
+ }
231
+ const inverted = invertAssertLine(lines[target]);
232
+ if ('cannotMutate' in inverted) {
233
+ mutations.push({ ...base, assertLine: target + 1, cannotMutate: inverted.cannotMutate });
234
+ continue;
235
+ }
236
+ const mutatedLines = lines.slice();
237
+ mutatedLines[target] = inverted.ok;
238
+ mutations.push({ ...base, assertLine: target + 1, mutatedSource: mutatedLines.join('\n') });
239
+ }
240
+ return { mutations, unmarkedTests, strayMarkerLines, malformedMarkerLines, onlyTestLines };
241
+ }
242
+ /**
243
+ * Name for a mutated copy: the marker segment goes right after the first
244
+ * dot-segment of the basename, so the ENTIRE original tail is preserved and
245
+ * the copy matches whatever testMatch convention picked up the original
246
+ * (`checkout.e2e.ts` -> `checkout.pmg-mutation-0.e2e.ts`).
247
+ */
248
+ export function mutationFilePath(specPath, index, tag = '') {
249
+ const base = basename(specPath);
250
+ const dot = base.indexOf('.', 1); // a leading dot (dotfile) stays with the stem
251
+ const stem = dot === -1 ? base : base.slice(0, dot);
252
+ const tail = dot === -1 ? '' : base.slice(dot);
253
+ const marker = tag === '' ? `pmg-mutation-${index}` : `pmg-mutation-${index}-${tag}`;
254
+ return join(dirname(specPath), `${stem}.${marker}${tail}`);
255
+ }
256
+ /**
257
+ * Write the mutated copy next to the original spec (same dir keeps relative
258
+ * imports and the Playwright testDir working) and return its path. The name
259
+ * carries the process id so overlapping gate runs in one checkout cannot
260
+ * collide. The caller owns cleanup.
261
+ */
262
+ export function writeMutationFile(specPath, index, mutatedSource) {
263
+ const path = mutationFilePath(specPath, index, process.pid.toString(36));
264
+ writeFileSync(path, mutatedSource);
265
+ return path;
266
+ }
@@ -0,0 +1,31 @@
1
+ import type { SpecResult } from './run.js';
2
+ export interface GateOptions {
3
+ /** When true (default), tests without a @primary-assert marker fail the gate. */
4
+ requireSentinel: boolean;
5
+ }
6
+ export interface Summary {
7
+ killed: number;
8
+ survived: number;
9
+ cannotMutate: number;
10
+ errors: number;
11
+ unmarkedTests: number;
12
+ strayMarkers: number;
13
+ malformedMarkers: number;
14
+ /**
15
+ * Green means proven: every marked assertion was killed under inversion and
16
+ * nothing was left unverified. Any survived, cannot-mutate or error verdict
17
+ * fails the gate, as do marker mistakes (stray or malformed markers) and,
18
+ * under require-sentinel, tests with no marker at all.
19
+ */
20
+ gatePassed: boolean;
21
+ }
22
+ export declare function summarize(specs: SpecResult[], opts: GateOptions): Summary;
23
+ export declare function jsonReport(specs: SpecResult[], opts: GateOptions): string;
24
+ export interface RenderOptions extends GateOptions {
25
+ color: boolean;
26
+ }
27
+ /**
28
+ * Human-readable report: per spec, one row per verdict plus sentinel findings,
29
+ * then a one-line summary and the gate outcome.
30
+ */
31
+ export declare function renderTable(specs: SpecResult[], opts: RenderOptions): string;
package/dist/report.js ADDED
@@ -0,0 +1,99 @@
1
+ export function summarize(specs, opts) {
2
+ const s = {
3
+ killed: 0,
4
+ survived: 0,
5
+ cannotMutate: 0,
6
+ errors: 0,
7
+ unmarkedTests: 0,
8
+ strayMarkers: 0,
9
+ malformedMarkers: 0,
10
+ gatePassed: true,
11
+ };
12
+ for (const spec of specs) {
13
+ for (const m of spec.results) {
14
+ if (m.verdict === 'killed')
15
+ s.killed++;
16
+ else if (m.verdict === 'survived')
17
+ s.survived++;
18
+ else if (m.verdict === 'cannot-mutate')
19
+ s.cannotMutate++;
20
+ else
21
+ s.errors++;
22
+ }
23
+ s.unmarkedTests += spec.unmarkedTests.length;
24
+ s.strayMarkers += spec.strayMarkerLines.length;
25
+ s.malformedMarkers += spec.malformedMarkerLines.length;
26
+ }
27
+ s.gatePassed =
28
+ s.survived === 0 &&
29
+ s.cannotMutate === 0 &&
30
+ s.errors === 0 &&
31
+ s.strayMarkers === 0 &&
32
+ s.malformedMarkers === 0 &&
33
+ (!opts.requireSentinel || s.unmarkedTests === 0);
34
+ return s;
35
+ }
36
+ export function jsonReport(specs, opts) {
37
+ return JSON.stringify({ specs, summary: summarize(specs, opts) }, null, 2);
38
+ }
39
+ const RESET = '\x1b[0m';
40
+ const VERDICT_COLOR = {
41
+ killed: '\x1b[32m',
42
+ survived: '\x1b[31m',
43
+ 'cannot-mutate': '\x1b[33m',
44
+ error: '\x1b[33m',
45
+ };
46
+ const TITLE_WIDTH = 48;
47
+ function clip(title) {
48
+ return title.length <= TITLE_WIDTH ? title : title.slice(0, TITLE_WIDTH - 3) + '...';
49
+ }
50
+ /**
51
+ * Human-readable report: per spec, one row per verdict plus sentinel findings,
52
+ * then a one-line summary and the gate outcome.
53
+ */
54
+ export function renderTable(specs, opts) {
55
+ const paint = (color, text) => opts.color ? color + text + RESET : text;
56
+ const labelWidth = 'cannot-mutate'.length;
57
+ const titleWidth = Math.min(TITLE_WIDTH, Math.max(4, ...specs.flatMap((spec) => [
58
+ ...spec.results.map((m) => clip(m.testTitle).length),
59
+ ...spec.unmarkedTests.map((t) => clip(t.title).length),
60
+ ])));
61
+ const row = (label, color, title, line) => ' ' + paint(color, label.padEnd(labelWidth)) + ' ' + clip(title).padEnd(titleWidth) + ' ' + line;
62
+ const lines = [];
63
+ for (const spec of specs) {
64
+ lines.push(spec.specPath);
65
+ for (const m of spec.results) {
66
+ const at = m.assertLine === null ? ':?' : `:${m.assertLine}`;
67
+ lines.push(row(m.verdict, VERDICT_COLOR[m.verdict], m.testTitle, at));
68
+ if (m.reason !== undefined) {
69
+ lines.push(' ' + ' '.repeat(labelWidth) + ' ' + m.reason);
70
+ }
71
+ }
72
+ for (const t of spec.unmarkedTests) {
73
+ lines.push(row('unmarked', VERDICT_COLOR['cannot-mutate'], t.title, `:${t.line}`));
74
+ }
75
+ for (const line of spec.strayMarkerLines) {
76
+ lines.push(` marker outside any test() block at line ${line}`);
77
+ }
78
+ for (const line of spec.malformedMarkerLines) {
79
+ lines.push(` malformed @primary-assert marker at line ${line}`);
80
+ }
81
+ lines.push('');
82
+ }
83
+ const s = summarize(specs, opts);
84
+ const counts = [
85
+ `${s.killed} killed`,
86
+ `${s.survived} survived`,
87
+ `${s.cannotMutate} cannot-mutate`,
88
+ `${s.errors} errors`,
89
+ ];
90
+ if (s.unmarkedTests > 0) {
91
+ counts.push(`${s.unmarkedTests} unmarked ${s.unmarkedTests === 1 ? 'test' : 'tests'}` +
92
+ (opts.requireSentinel ? '' : ' (not enforced)'));
93
+ }
94
+ lines.push(counts.join(', '));
95
+ lines.push(s.gatePassed
96
+ ? paint('\x1b[1;32m', 'Gate passed: every marked assertion can fail.')
97
+ : paint('\x1b[1;31m', 'Gate FAILED: hollow, unverified or broken assertions above.'));
98
+ return lines.join('\n');
99
+ }