@codesema/speccov 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,166 @@
1
+ import ts from 'typescript';
2
+
3
+ export type Mutant = {
4
+ /** Stable identifier: same mutated code ⇒ same identifier, from one run to the next. */
5
+ readonly id: string;
6
+ readonly file: string;
7
+ readonly line: number;
8
+ readonly mutator: string;
9
+ readonly start: number;
10
+ readonly end: number;
11
+ readonly original: string;
12
+ readonly replacement: string;
13
+ };
14
+
15
+ /**
16
+ * The alteration operators, reimplemented on the TypeScript AST.
17
+ *
18
+ * The catalogue comes from the state of the art in mutation testing (Stryker, PIT): these are
19
+ * the alterations that empirically reveal the most verification holes. We reimplement it rather
20
+ * than depend on Stryker, so that the scope and the budget stay ours to choose.
21
+ */
22
+ const BINARY_SWAPS = new Map<ts.SyntaxKind, string>([
23
+ [ts.SyntaxKind.GreaterThanToken, '>='],
24
+ [ts.SyntaxKind.GreaterThanEqualsToken, '>'],
25
+ [ts.SyntaxKind.LessThanToken, '<='],
26
+ [ts.SyntaxKind.LessThanEqualsToken, '<'],
27
+ [ts.SyntaxKind.EqualsEqualsEqualsToken, '!=='],
28
+ [ts.SyntaxKind.ExclamationEqualsEqualsToken, '==='],
29
+ [ts.SyntaxKind.EqualsEqualsToken, '!='],
30
+ [ts.SyntaxKind.ExclamationEqualsToken, '=='],
31
+ [ts.SyntaxKind.AmpersandAmpersandToken, '||'],
32
+ [ts.SyntaxKind.BarBarToken, '&&'],
33
+ [ts.SyntaxKind.QuestionQuestionToken, '||'],
34
+ [ts.SyntaxKind.PlusToken, '-'],
35
+ [ts.SyntaxKind.MinusToken, '+'],
36
+ [ts.SyntaxKind.AsteriskToken, '/'],
37
+ [ts.SyntaxKind.SlashToken, '*'],
38
+ [ts.SyntaxKind.PercentToken, '*'],
39
+ ]);
40
+
41
+ const MUTATOR_OF = new Map<ts.SyntaxKind, string>([
42
+ [ts.SyntaxKind.GreaterThanToken, 'comparison'],
43
+ [ts.SyntaxKind.GreaterThanEqualsToken, 'comparison'],
44
+ [ts.SyntaxKind.LessThanToken, 'comparison'],
45
+ [ts.SyntaxKind.LessThanEqualsToken, 'comparison'],
46
+ [ts.SyntaxKind.EqualsEqualsEqualsToken, 'equality'],
47
+ [ts.SyntaxKind.ExclamationEqualsEqualsToken, 'equality'],
48
+ [ts.SyntaxKind.EqualsEqualsToken, 'equality'],
49
+ [ts.SyntaxKind.ExclamationEqualsToken, 'equality'],
50
+ [ts.SyntaxKind.AmpersandAmpersandToken, 'logic'],
51
+ [ts.SyntaxKind.BarBarToken, 'logic'],
52
+ [ts.SyntaxKind.QuestionQuestionToken, 'logic'],
53
+ ]);
54
+
55
+ export function findMutants(file: string, source: string): readonly Mutant[] {
56
+ const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
57
+ const mutants: Mutant[] = [];
58
+
59
+ const add = (
60
+ node: ts.Node,
61
+ replacement: string,
62
+ mutator: string,
63
+ start = node.getStart(parsed),
64
+ end = node.getEnd(),
65
+ ): void => {
66
+ const { line } = parsed.getLineAndCharacterOfPosition(start);
67
+ const original = source.slice(start, end);
68
+ if (original === replacement) {
69
+ return;
70
+ }
71
+ mutants.push({
72
+ id: `${file}:${start}:${mutator}`,
73
+ file,
74
+ line: line + 1,
75
+ mutator,
76
+ start,
77
+ end,
78
+ original,
79
+ replacement,
80
+ });
81
+ };
82
+
83
+ const visit = (node: ts.Node): void => {
84
+ if (isErased(node)) {
85
+ return;
86
+ }
87
+ if (ts.isBinaryExpression(node)) {
88
+ const swap = BINARY_SWAPS.get(node.operatorToken.kind);
89
+ if (swap !== undefined) {
90
+ const token = node.operatorToken;
91
+ add(token, swap, MUTATOR_OF.get(token.kind) ?? 'arithmetic', token.getStart(parsed), token.getEnd());
92
+ }
93
+ }
94
+ if (node.kind === ts.SyntaxKind.TrueKeyword) {
95
+ add(node, 'false', 'boolean');
96
+ }
97
+ if (node.kind === ts.SyntaxKind.FalseKeyword) {
98
+ add(node, 'true', 'boolean');
99
+ }
100
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
101
+ add(node, node.operand.getText(parsed), 'negation');
102
+ }
103
+ if (ts.isArrayLiteralExpression(node) && node.elements.length > 0) {
104
+ add(node, '[]', 'array emptied');
105
+ }
106
+ if (ts.isStringLiteral(node) && node.text !== '') {
107
+ add(node, '""', 'string emptied');
108
+ }
109
+ if (isOptionalChain(node)) {
110
+ add(
111
+ node.questionDotToken,
112
+ '.',
113
+ 'optional chaining',
114
+ node.questionDotToken.getStart(parsed),
115
+ node.questionDotToken.getEnd(),
116
+ );
117
+ }
118
+ if (isEmptiableBody(node, parsed)) {
119
+ add(node, '{}', 'body emptied');
120
+ }
121
+ ts.forEachChild(node, visit);
122
+ };
123
+
124
+ visit(parsed);
125
+ return mutants;
126
+ }
127
+
128
+ function isOptionalChain(node: ts.Node): node is ts.PropertyAccessExpression & { questionDotToken: ts.QuestionDotToken } {
129
+ return ts.isPropertyAccessExpression(node) && node.questionDotToken !== undefined;
130
+ }
131
+
132
+ /** Emptying a non-empty function body: the most brutal alteration, and so the most revealing. */
133
+ function isEmptiableBody(node: ts.Node, parsed: ts.SourceFile): node is ts.Block {
134
+ if (!ts.isBlock(node) || node.statements.length === 0) {
135
+ return false;
136
+ }
137
+ const parent = node.parent;
138
+ const isFunctionBody =
139
+ ts.isFunctionDeclaration(parent) ||
140
+ ts.isMethodDeclaration(parent) ||
141
+ ts.isArrowFunction(parent) ||
142
+ ts.isFunctionExpression(parent);
143
+ return isFunctionBody && node.getText(parsed) !== '{}';
144
+ }
145
+
146
+ /** Applies a mutant to the source text. Pure: touches no file. */
147
+ export function applyMutant(source: string, mutant: Mutant): string {
148
+ return source.slice(0, mutant.start) + mutant.replacement + source.slice(mutant.end);
149
+ }
150
+
151
+ /**
152
+ * What the compiler erases before execution: types, interfaces, aliases, type imports.
153
+ * Placing a mutant there yields a guaranteed survivor — the compiled code is identical,
154
+ * no test can object.
155
+ *
156
+ * These false survivors are not neutral: they drown the real ones in the report, and a
157
+ * report nobody can sort through is no longer a report anyone decides from.
158
+ */
159
+ function isErased(node: ts.Node): boolean {
160
+ return (
161
+ ts.isTypeNode(node) ||
162
+ ts.isTypeAliasDeclaration(node) ||
163
+ ts.isInterfaceDeclaration(node) ||
164
+ (ts.isImportDeclaration(node) && node.importClause?.isTypeOnly === true)
165
+ );
166
+ }
@@ -0,0 +1,52 @@
1
+ import type { MutateResult, Outcome } from './run';
2
+
3
+ const MAX_SHOWN = 8;
4
+
5
+ /**
6
+ * A surviving mutant is a question, not an accusation: the behaviour changed and nobody
7
+ * said a word. Whether somebody should have is what is left to settle.
8
+ */
9
+ /**
10
+ * `all` exists for triage: eight survivors are enough to alert a human, never enough to
11
+ * decide what to fix when there are a hundred. Truncating by default keeps the report readable;
12
+ * never being able to see it all would make it unusable.
13
+ */
14
+ export function reportMutants(result: MutateResult, all = false): string {
15
+ const survivors = result.outcomes.filter(({ fate }) => fate === 'survived');
16
+ const killed = result.outcomes.length - survivors.length;
17
+
18
+ const lines = ['', ` ${result.outcomes.length} mutants tried, ${killed} killed.`];
19
+ if (survivors.length === 0) {
20
+ lines.push('', ' No survivor: every alteration of the code was caught.');
21
+ } else {
22
+ lines.push(` ⚠ ${survivors.length} survivor(s) — that many behaviours nobody guarantees.`, '');
23
+ const shown = all ? survivors.length : MAX_SHOWN;
24
+ for (const [index, outcome] of survivors.slice(0, shown).entries()) {
25
+ lines.push(...describe(outcome, index + 1, survivors.length));
26
+ }
27
+ if (survivors.length > shown) {
28
+ lines.push(` … and ${survivors.length - shown} more survivor(s) — run again with --all to see them.`);
29
+ }
30
+ }
31
+ if (result.skipped > 0) {
32
+ lines.push('', ' ── Not measured ───────────────────────────────────────────');
33
+ lines.push(` ${result.skipped} mutant(s) generated but not tried, out of budget.`);
34
+ lines.push(' Raise --limit or narrow the scope.');
35
+ }
36
+ lines.push('');
37
+ return lines.join('\n');
38
+ }
39
+
40
+ function describe({ mutant }: Outcome, position: number, total: number): readonly string[] {
41
+ return [
42
+ ` survivor ${position}/${total} ${mutant.file}:${mutant.line} (${mutant.mutator})`,
43
+ ` “I replaced ${truncate(mutant.original)} with ${truncate(mutant.replacement)} and nothing`,
44
+ ' objected. Should this behaviour be guaranteed?”',
45
+ '',
46
+ ];
47
+ }
48
+
49
+ function truncate(text: string): string {
50
+ const single = text.replaceAll(/\s+/gu, ' ');
51
+ return single.length <= 40 ? single : `${single.slice(0, 40)}…`;
52
+ }
@@ -0,0 +1,79 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { applyMutant, findMutants, type Mutant } from './mutators';
4
+
5
+ export type Fate = 'killed' | 'survived';
6
+
7
+ export type Outcome = {
8
+ readonly mutant: Mutant;
9
+ readonly fate: Fate;
10
+ };
11
+
12
+ export type MutateOptions = {
13
+ readonly files: readonly string[];
14
+ /** Command that judges the mutant. Non-zero exit ⇒ the mutant is killed. */
15
+ readonly command: readonly string[];
16
+ readonly cwd: string;
17
+ /** Budget: maximum number of mutants tried. */
18
+ readonly limit: number;
19
+ readonly onProgress?: (done: number, total: number) => void;
20
+ };
21
+
22
+ export type MutateResult = {
23
+ readonly outcomes: readonly Outcome[];
24
+ /** Mutants generated but not tried, out of budget. Counted, never silenced. */
25
+ readonly skipped: number;
26
+ };
27
+
28
+ /**
29
+ * Tries every mutant: alter the file, run the command, restore.
30
+ *
31
+ * A **surviving** mutant is a gap in the strong sense: the behaviour of the code was changed
32
+ * and nothing — no rule, no test — objected.
33
+ *
34
+ * The restore is in a `finally` and the original file is held in memory: even a command that
35
+ * crashes leaves the source intact. On top of that the caller must require a clean repository,
36
+ * so that a `git checkout` always remains a way out.
37
+ */
38
+ export async function mutate(options: MutateOptions): Promise<MutateResult> {
39
+ const all = await generate(options.files);
40
+ const budgeted = all.slice(0, options.limit);
41
+ const outcomes: Outcome[] = [];
42
+
43
+ for (const [index, mutant] of budgeted.entries()) {
44
+ options.onProgress?.(index + 1, budgeted.length);
45
+ outcomes.push({ mutant, fate: await tryMutant(mutant, options) });
46
+ }
47
+ return { outcomes, skipped: all.length - budgeted.length };
48
+ }
49
+
50
+ export async function generate(files: readonly string[]): Promise<readonly Mutant[]> {
51
+ const mutants: Mutant[] = [];
52
+ for (const file of files) {
53
+ mutants.push(...findMutants(file, await readFile(file, 'utf8')));
54
+ }
55
+ return mutants;
56
+ }
57
+
58
+ async function tryMutant(mutant: Mutant, options: MutateOptions): Promise<Fate> {
59
+ const original = await readFile(mutant.file, 'utf8');
60
+ try {
61
+ await writeFile(mutant.file, applyMutant(original, mutant), 'utf8');
62
+ return (await run(options)) === 0 ? 'survived' : 'killed';
63
+ } finally {
64
+ await writeFile(mutant.file, original, 'utf8');
65
+ }
66
+ }
67
+
68
+ function run({ command, cwd }: MutateOptions): Promise<number> {
69
+ const [program, ...args] = command;
70
+ if (program === undefined) {
71
+ throw new Error('speccov: the command that judges a mutant is empty.');
72
+ }
73
+ return new Promise((settle, reject) => {
74
+ const child = spawn(program, args, { cwd, stdio: 'ignore' });
75
+ child.once('error', reject);
76
+ // No exit code means a signal ended the command: it did not pass, the mutant is killed.
77
+ child.once('exit', (code) => settle(code ?? 1));
78
+ });
79
+ }
package/src/report.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { Borderline, Infraction, Lacuna, Vacuous, Verdict } from './rule';
2
+
3
+ /**
4
+ * Makes the verdict readable. The report is dominated by what is not guaranteed: a success
5
+ * carries no information, it fits on a single line.
6
+ */
7
+ export function report(verdicts: readonly Verdict[], accepted: readonly Lacuna[] = []): string {
8
+ const held = verdicts.filter((verdict) => verdict.kind === 'held');
9
+ const infractions = verdicts.filter((verdict): verdict is Infraction => verdict.kind === 'infraction');
10
+ const lacunae = verdicts.filter((verdict): verdict is Lacuna => verdict.kind === 'lacuna');
11
+ const vacuous = verdicts.filter((verdict): verdict is Vacuous => verdict.kind === 'vacuous');
12
+ const borderline = verdicts.filter((verdict): verdict is Borderline => verdict.kind === 'borderline');
13
+
14
+ const lines = [''];
15
+ if (held.length > 0) {
16
+ // No tick when nothing is held: a ✓ in front of zero reads wrong.
17
+ lines.push(` ✓ ${count(held.length, 'rule held', 'rules held')}`);
18
+ }
19
+ if (infractions.length > 0) {
20
+ lines.push(` ✗ ${count(infractions.length, 'violation', 'violations')}`);
21
+ }
22
+ if (lacunae.length > 0) {
23
+ lines.push(` ⚠ ${count(lacunae.length, 'gap', 'gaps')}`);
24
+ }
25
+ if (vacuous.length > 0) {
26
+ lines.push(` ? ${count(vacuous.length, 'rule never exercised', 'rules never exercised')}`);
27
+ }
28
+ if (borderline.length > 0) {
29
+ lines.push(` ~ ${count(borderline.length, 'rule held with no margin', 'rules held with no margin')}`);
30
+ }
31
+ if (accepted.length > 0) {
32
+ // Counted and never hidden: a forgotten acceptance is a blind spot.
33
+ lines.push(` · ${count(accepted.length, 'gap already accepted', 'gaps already accepted')}`);
34
+ }
35
+
36
+ infractions.forEach((infraction, index) => {
37
+ lines.push('', ` violation ${index + 1}/${infractions.length} ${infraction.rule.name}`);
38
+ lines.push(indent(`“${infraction.rule.intention}” does not hold.`));
39
+ lines.push(indent(infraction.detail));
40
+ });
41
+
42
+ lacunae.forEach((lacuna, index) => {
43
+ lines.push('', ` gap ${index + 1}/${lacunae.length} ${lacuna.rule.name}`);
44
+ lines.push(indent(`“${lacuna.question}”`));
45
+ lines.push('', ' [y] this is a bug [n] this is intended');
46
+ });
47
+
48
+ vacuous.forEach((verdict, index) => {
49
+ lines.push('', ` never exercised ${index + 1}/${vacuous.length} ${verdict.rule.name}`);
50
+ lines.push(indent(`“${verdict.question}”`));
51
+ lines.push(indent('It is green because it saw nothing, not because it judged.'));
52
+ });
53
+
54
+ borderline.forEach((verdict, index) => {
55
+ lines.push('', ` no margin ${index + 1}/${borderline.length} ${verdict.rule.name}`);
56
+ lines.push(indent(`“${verdict.question}”`));
57
+ });
58
+
59
+ if (infractions.length === 0 && lacunae.length === 0 && vacuous.length === 0 && borderline.length === 0) {
60
+ lines.push('', ' Nothing to report.');
61
+ }
62
+
63
+ const notMeasured = measurementWindow(verdicts);
64
+ lines.push('', ' ── Not measured ──────────────────────────────────────────');
65
+ if (notMeasured.largest === null) {
66
+ lines.push(' No input size was exercised: nothing is said about how cost grows.');
67
+ } else {
68
+ lines.push(` Input sizes tried: ${notMeasured.sizes.join(', ')} — nothing beyond ${notMeasured.largest}.`);
69
+ lines.push(' A cost that only grows past that size would not have been seen.');
70
+ }
71
+ if (notMeasured.unobserved.length > 0) {
72
+ lines.push(
73
+ ` ${count(notMeasured.unobserved.length, 'rule observed nothing', 'rules observed nothing')}: ${notMeasured.unobserved.join(', ')}`,
74
+ );
75
+ }
76
+ lines.push(' The data comes from the rule file, not from production.');
77
+ lines.push('');
78
+ return lines.join('\n');
79
+ }
80
+
81
+ /** What the pass actually observed — and so, in negative, what it did not see. */
82
+ export type MeasurementWindow = {
83
+ /** Distinct input sizes actually tried, sorted. */
84
+ readonly sizes: readonly number[];
85
+ /** The largest size tried, or `null` if no rule measured anything. */
86
+ readonly largest: number | null;
87
+ /** Rules that observed no measurement: they bound nothing at all. */
88
+ readonly unobserved: readonly string[];
89
+ };
90
+
91
+ /**
92
+ * A rule concludes only about the sizes it tried. Keeping that bound quiet would make a
93
+ * green report read as "cost never grows", when it only says "not up to 16". That is the
94
+ * exact charge levelled at 90% coverage.
95
+ *
96
+ * Prose and JSON both start here so they cannot contradict each other.
97
+ */
98
+ export function measurementWindow(verdicts: readonly Verdict[]): MeasurementWindow {
99
+ const sizes = new Set<number>();
100
+ const unobserved: string[] = [];
101
+
102
+ for (const verdict of verdicts) {
103
+ if (verdict.measurements.length === 0) {
104
+ unobserved.push(verdict.rule.name);
105
+ }
106
+ for (const { size } of verdict.measurements) {
107
+ sizes.add(size);
108
+ }
109
+ }
110
+
111
+ const sorted = [...sizes].sort((a, b) => a - b);
112
+ return { sizes: sorted, largest: sorted.at(-1) ?? null, unobserved };
113
+ }
114
+
115
+ /** Only a violation fails the run: a gap is a question, not a refusal. */
116
+ export function exitCode(verdicts: readonly Verdict[]): number {
117
+ return verdicts.some((verdict) => verdict.kind === 'infraction') ? 1 : 0;
118
+ }
119
+
120
+ function count(value: number, singular: string, plural: string): string {
121
+ return `${value} ${value > 1 ? plural : singular}`;
122
+ }
123
+
124
+ const WIDTH = 88;
125
+
126
+ /** Wraps the text so a report stays readable in a narrow terminal. */
127
+ function indent(text: string): string {
128
+ const lines: string[] = [];
129
+ let current = '';
130
+ for (const word of text.split(/\s+/).filter((part) => part !== '')) {
131
+ const candidate = current === '' ? word : `${current} ${word}`;
132
+ if (candidate.length > WIDTH && current !== '') {
133
+ lines.push(current);
134
+ current = word;
135
+ } else {
136
+ current = candidate;
137
+ }
138
+ }
139
+ if (current !== '') {
140
+ lines.push(current);
141
+ }
142
+ return lines.map((line) => ` ${line}`).join('\n');
143
+ }
@@ -0,0 +1,76 @@
1
+ import type { Gauge } from './gauge';
2
+ import { requireIntention, type Judgement, type Measurement, type Origin, type Rule } from './rule';
3
+
4
+ export type ResourceOptions = {
5
+ readonly name: string;
6
+ readonly intention?: string;
7
+ /** Unit observed, PLURAL. */
8
+ readonly unit?: string;
9
+ /** Tolerated peak. Choosing it is writing a rule — hence `origin: 'declared'`. */
10
+ readonly ceiling: number;
11
+ readonly sizes?: readonly number[];
12
+ readonly gauge: Gauge;
13
+ readonly subject: (size: number) => unknown;
14
+ readonly origin?: Origin;
15
+ };
16
+
17
+ const DEFAULT_SIZES = [1, 4, 16] as const;
18
+
19
+ /**
20
+ * Resource rule: the observed peak stays under a ceiling, whatever the input.
21
+ *
22
+ * This is the one that catches an unbounded `Promise.all`: concurrency there follows the
23
+ * size of the collection, so it crosses any ceiling as soon as the data grows.
24
+ */
25
+ export function resource(options: ResourceOptions): Rule {
26
+ const unit = options.unit ?? 'concurrent operations';
27
+ const sizes = options.sizes ?? DEFAULT_SIZES;
28
+ const intention = requireIntention(options.name, options.intention ?? `Peak ${unit} never exceeds ${options.ceiling}`);
29
+
30
+ if (!Number.isFinite(options.ceiling) || options.ceiling < 1) {
31
+ throw new Error(`speccov: invalid ceiling (${options.ceiling}) for “${options.name}”, expected an integer of 1 or more.`);
32
+ }
33
+
34
+ return {
35
+ name: options.name,
36
+ intention,
37
+ origin: options.origin ?? 'declared',
38
+
39
+ // A ceiling of 1 does not tighten: 0 would forbid every operation.
40
+ tighten: () => (options.ceiling > 1 ? resource({ ...options, ceiling: options.ceiling - 1 }) : undefined),
41
+
42
+ async measure(): Promise<readonly Measurement[]> {
43
+ const measurements: Measurement[] = [];
44
+ for (const size of sizes) {
45
+ options.gauge.reset();
46
+ await options.subject(size);
47
+ measurements.push({ size, cost: options.gauge.peak });
48
+ }
49
+ return measurements;
50
+ },
51
+
52
+ judge(measurements): Judgement {
53
+ if (measurements.length === 0) {
54
+ throw new Error(`speccov: no measurement for “${options.name}”.`);
55
+ }
56
+ const peak = Math.max(...measurements.map(({ cost }) => cost));
57
+ const series = measurements.map(({ size, cost }) => `${size}→${cost}`).join(', ');
58
+ // A zero peak: nothing was consumed, so no ceiling was ever exercised.
59
+ const exercised = peak > 0;
60
+ if (peak <= options.ceiling) {
61
+ return {
62
+ held: true,
63
+ exercised,
64
+ magnitude: 0,
65
+ detail: `Peak of ${peak} ${unit}, ceiling ${options.ceiling} (${series})`,
66
+ };
67
+ }
68
+ return {
69
+ held: false,
70
+ exercised,
71
+ magnitude: peak - options.ceiling,
72
+ detail: `Peak of ${peak} ${unit} against a ceiling of ${options.ceiling} (${series})`,
73
+ };
74
+ },
75
+ };
76
+ }
package/src/rule.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * A *declared* rule was written by a human: breaking it is an infraction.
3
+ * A *default* rule was written by nobody: breaking it is not a proven bug,
4
+ * it is a question to ask — a gap.
5
+ */
6
+ export type Origin = 'declared' | 'default';
7
+
8
+ /** The cost observed for a given input size. */
9
+ export type Measurement = {
10
+ readonly size: number;
11
+ readonly cost: number;
12
+ };
13
+
14
+ export type Judgement = {
15
+ readonly held: boolean;
16
+ /** Factual sentence describing what was measured. Never a value judgement. */
17
+ readonly detail: string;
18
+ /**
19
+ * How far past the threshold, monotonic: the larger it is, the more the rule is violated.
20
+ *
21
+ * Mandatory, and that is a choice: a rule unable to quantify its own gap cannot be
22
+ * accepted at a threshold, so the human answer to it cannot be mechanised.
23
+ */
24
+ readonly magnitude: number;
25
+ /**
26
+ * True if the measurements held at least one observation able to make the rule fail.
27
+ * False when it "held" without ever being exercised.
28
+ *
29
+ * This is vacuity, described for twenty years in formal verification: a property
30
+ * satisfied for a trivial reason. About one specification in five passes that way on
31
+ * the first checks, and a vacuous pass always points at a real problem — in the code,
32
+ * in the rule, or in what was measured.
33
+ *
34
+ * Mandatory, like `magnitude`: a rule unable to say whether it was ever exercised cannot
35
+ * be told apart from a rule that is green by absence.
36
+ */
37
+ readonly exercised: boolean;
38
+ };
39
+
40
+ export type Rule = {
41
+ readonly name: string;
42
+ /** Human-readable sentence. Mandatory: a rule that says nothing is worth nothing. */
43
+ readonly intention: string;
44
+ readonly origin: Origin;
45
+ measure(): Promise<readonly Measurement[]>;
46
+ judge(measurements: readonly Measurement[]): Judgement;
47
+ /**
48
+ * The same rule, one notch stricter — or `undefined` when it has no threshold to
49
+ * tighten (a ban holds or it does not; it has no margin).
50
+ *
51
+ * This is rule mutation, the inverse of code mutation: coverage mutates the system,
52
+ * vacuity mutates the specification. And it is free — `judge()` is pure over the
53
+ * measurements, so the variant rejudges the same observations without running anything.
54
+ *
55
+ * Contract: the `judge()` of the returned rule must depend on its argument alone.
56
+ */
57
+ tighten?(): Rule | undefined;
58
+ };
59
+
60
+ type Outcome = {
61
+ readonly rule: Rule;
62
+ readonly measurements: readonly Measurement[];
63
+ readonly magnitude: number;
64
+ /** What was measured, in one sentence. */
65
+ readonly detail: string;
66
+ };
67
+
68
+ /** The rule held. Nothing to report. */
69
+ export type Held = {
70
+ readonly kind: 'held';
71
+ } & Outcome;
72
+
73
+ /**
74
+ * A rule is violated although a human had ruled on it. That is a bug.
75
+ *
76
+ * Two origins: a rule written by hand, or an accepted gap whose threshold has just been
77
+ * crossed — accepting is writing a rule.
78
+ */
79
+ export type Infraction = {
80
+ readonly kind: 'infraction';
81
+ } & Outcome;
82
+
83
+ /**
84
+ * The rule held without ever being exercised. Green by absence, not by proof.
85
+ *
86
+ * This is not a violation — nothing proves the code is at fault. It is a green light worth
87
+ * nothing, and keeping quiet about it would be exactly the lie speccov exists to avoid.
88
+ */
89
+ export type Vacuous = {
90
+ readonly kind: 'vacuous';
91
+ readonly question: string;
92
+ } & Outcome;
93
+
94
+ /**
95
+ * The rule holds, but with no margin at all: one notch stricter and it would fall.
96
+ *
97
+ * The observation sits exactly on the threshold. Either the code is at the limit and the
98
+ * next item crosses it, or — more likely — the threshold was chosen *after* the observation,
99
+ * to fit it. This is the "rule" version of "the test checks the constant against itself".
100
+ */
101
+ export type Borderline = {
102
+ readonly kind: 'borderline';
103
+ readonly question: string;
104
+ } & Outcome;
105
+
106
+ /** No written rule frames this behaviour, and it does this. Open question. */
107
+ export type Lacuna = {
108
+ readonly kind: 'lacuna';
109
+ readonly question: string;
110
+ } & Outcome;
111
+
112
+ /** True when the verdict is about a rule that did not hold, whatever the severity. */
113
+ export function isBroken(verdict: Verdict): verdict is Infraction | Lacuna {
114
+ return verdict.kind === 'infraction' || verdict.kind === 'lacuna';
115
+ }
116
+
117
+ export type Verdict = Held | Infraction | Lacuna | Vacuous | Borderline;
118
+
119
+ /**
120
+ * Refuses a rule with no intention sentence. It is the only rule speccov applies to itself:
121
+ * the field that makes the report readable cannot be optional.
122
+ */
123
+ export function requireIntention(name: string, intention: string | undefined): string {
124
+ const trimmed = intention?.trim() ?? '';
125
+ if (trimmed === '') {
126
+ throw new Error(`speccov: rule “${name}” has no intention. Write the sentence a human will read.`);
127
+ }
128
+ return trimmed;
129
+ }