@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.
- package/LICENSE +21 -0
- package/README.md +607 -2
- package/bin/speccov.mjs +80 -0
- package/package.json +63 -5
- package/src/adapters/typeorm.ts +90 -0
- package/src/answers.ts +126 -0
- package/src/cli.ts +417 -0
- package/src/complexity.ts +75 -0
- package/src/counter.ts +16 -0
- package/src/domain.ts +70 -0
- package/src/draft/domain.ts +138 -0
- package/src/draft/extract.ts +550 -0
- package/src/draft/harvest.ts +184 -0
- package/src/draft/index.ts +39 -0
- package/src/draft/render-forbidden.ts +84 -0
- package/src/draft/render.ts +530 -0
- package/src/draft/sweep.ts +81 -0
- package/src/draft/trace.ts +219 -0
- package/src/forbidden.ts +77 -0
- package/src/gauge.ts +48 -0
- package/src/growth.ts +64 -0
- package/src/index.ts +21 -0
- package/src/json.ts +121 -0
- package/src/metamorphic.ts +68 -0
- package/src/mutate/mutators.ts +166 -0
- package/src/mutate/report.ts +52 -0
- package/src/mutate/run.ts +79 -0
- package/src/report.ts +143 -0
- package/src/resource.ts +76 -0
- package/src/rule.ts +129 -0
- package/src/run.ts +70 -0
- package/src/scan/answers.ts +152 -0
- package/src/scan/climb.ts +26 -0
- package/src/scan/flow.ts +65 -0
- package/src/scan/guarantees.ts +234 -0
- package/src/scan/ignored.ts +40 -0
- package/src/scan/index.ts +213 -0
- package/src/scan/program.ts +137 -0
- package/src/scan/report.ts +267 -0
- package/src/scan/shapes.ts +1139 -0
- package/src/scan/speccovignore.ts +65 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { accept, applyAnswers, DEFAULT_ANSWERS_PATH, loadAnswers, saveAnswers, type Answer } from './answers';
|
|
8
|
+
import { exitCode, report } from './report';
|
|
9
|
+
import { draft, draftForbidden, type DraftResult } from './draft/index';
|
|
10
|
+
import { sweep } from './draft/sweep';
|
|
11
|
+
import { mutantsToJson, scanToJson, verdictsToJson } from './json';
|
|
12
|
+
import { reportMutants } from './mutate/report';
|
|
13
|
+
import { mutate } from './mutate/run';
|
|
14
|
+
import {
|
|
15
|
+
acceptSite,
|
|
16
|
+
applySiteAnswers,
|
|
17
|
+
DEFAULT_SCAN_ANSWERS_PATH,
|
|
18
|
+
loadSiteAnswers,
|
|
19
|
+
saveSiteAnswers,
|
|
20
|
+
type SiteAnswer,
|
|
21
|
+
} from './scan/answers';
|
|
22
|
+
import { collectSources, reportScan, scan } from './scan/index';
|
|
23
|
+
import type { Site } from './scan/shapes';
|
|
24
|
+
import type { Lacuna, Rule } from './rule';
|
|
25
|
+
import { run } from './run';
|
|
26
|
+
|
|
27
|
+
const USAGE = `speccov — finds the behaviours no rule guarantees
|
|
28
|
+
|
|
29
|
+
speccov scan <directory> [--tsconfig <path>] [--draft] [--write]
|
|
30
|
+
--draft passes every site found to the draft bridge and reports how
|
|
31
|
+
many became a rule file. --write creates them; an existing file is
|
|
32
|
+
never overwritten.
|
|
33
|
+
|
|
34
|
+
speccov scan <directory> [--tsconfig <path>]
|
|
35
|
+
Static pass: what is proven by construction, the rule candidates,
|
|
36
|
+
and what was never looked at. Executes nothing. A .speccovignore
|
|
37
|
+
(same syntax as a .gitignore) sets directories aside: the report
|
|
38
|
+
counts what it left out.
|
|
39
|
+
|
|
40
|
+
speccov scan <directory> --accept <file:line> [--why <text>]
|
|
41
|
+
Answers "this is not a defect" for a site the scan printed. What is
|
|
42
|
+
remembered is the shape of the code, not the line, so the answer
|
|
43
|
+
survives every insertion above it — and expires when that code
|
|
44
|
+
changes, because it is no longer the same question.
|
|
45
|
+
|
|
46
|
+
speccov mutate <directory|file> --run <command> [--limit N] [--all]
|
|
47
|
+
Alters the code and re-runs <command>. A mutant that survives is a
|
|
48
|
+
behaviour nobody guarantees. Requires a clean repository.
|
|
49
|
+
|
|
50
|
+
speccov draft <file.ts>:<line> [--write]
|
|
51
|
+
Harvests the wiring from the neighbouring spec and writes the rule
|
|
52
|
+
draft. Refuses rather than guesses. --write creates the file.
|
|
53
|
+
|
|
54
|
+
speccov draft <file.ts> --forbidden <Type> [--write]
|
|
55
|
+
Reads the type and writes every case of its finite domain into a
|
|
56
|
+
forbidden() draft. A field the type cannot bound is left as a call
|
|
57
|
+
that throws, never as a guess.
|
|
58
|
+
|
|
59
|
+
speccov <file.rules.ts> [options]
|
|
60
|
+
|
|
61
|
+
--accept <rule> Answers "this is intended": the measured threshold becomes a written
|
|
62
|
+
rule. Repeatable. A later overrun will be a violation.
|
|
63
|
+
--why <text> Reason attached to the acceptances of this invocation.
|
|
64
|
+
--answers <path> Answers file (default: ${DEFAULT_ANSWERS_PATH}, or
|
|
65
|
+
${DEFAULT_SCAN_ANSWERS_PATH} for scan).
|
|
66
|
+
--json Machine-readable output on stdout. Stable schema, no prose to parse.
|
|
67
|
+
|
|
68
|
+
The rules file default-exports an array of rules, or a function returning one.`;
|
|
69
|
+
|
|
70
|
+
/** Declared before the dispatch: the subcommands run higher up than the body of the file. */
|
|
71
|
+
const DEFAULT_LIMIT = 50;
|
|
72
|
+
|
|
73
|
+
const commandLine = process.argv.slice(2);
|
|
74
|
+
if (commandLine[0] === 'scan') {
|
|
75
|
+
await runScan(commandLine.slice(1));
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
if (commandLine[0] === 'mutate') {
|
|
79
|
+
process.exit(await runMutate(commandLine.slice(1)));
|
|
80
|
+
}
|
|
81
|
+
if (commandLine[0] === 'draft') {
|
|
82
|
+
process.exit(await runDraft(commandLine.slice(1)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const invocation = parseArguments(commandLine);
|
|
86
|
+
if (invocation === 'help') {
|
|
87
|
+
console.log(USAGE);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rules = await loadRules(invocation.target);
|
|
92
|
+
const applied = applyAnswers(await run(rules), await loadAnswers(invocation.answersPath));
|
|
93
|
+
const code = exitCode(applied.verdicts);
|
|
94
|
+
console.log(
|
|
95
|
+
commandLine.includes('--json')
|
|
96
|
+
? verdictsToJson(applied.verdicts, applied.accepted, code)
|
|
97
|
+
: report(applied.verdicts, applied.accepted),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (invocation.accepts.length > 0) {
|
|
101
|
+
await recordAcceptances(applied.verdicts, invocation);
|
|
102
|
+
}
|
|
103
|
+
process.exit(code);
|
|
104
|
+
|
|
105
|
+
async function runDraft(argv: readonly string[]): Promise<number> {
|
|
106
|
+
const target = argv[0];
|
|
107
|
+
const typeName = valueOf(argv, '--forbidden');
|
|
108
|
+
if (typeName !== undefined) {
|
|
109
|
+
if (target === undefined) {
|
|
110
|
+
throw new Error('speccov: name the file that declares the type.');
|
|
111
|
+
}
|
|
112
|
+
return emitDraft(draftForbidden(resolve(target), typeName), argv);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const match = target === undefined ? null : /^(?<file>.+):(?<line>\d+)$/u.exec(target);
|
|
116
|
+
const file = match?.groups?.['file'];
|
|
117
|
+
const line = Number(match?.groups?.['line']);
|
|
118
|
+
if (file === undefined || !Number.isInteger(line)) {
|
|
119
|
+
throw new Error('speccov: name a site as <file.ts>:<line>.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = await draft(resolve(file), line);
|
|
123
|
+
return emitDraft(result, argv);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function emitDraft(result: DraftResult, argv: readonly string[]): Promise<number> {
|
|
127
|
+
if (result.kind === 'refused') {
|
|
128
|
+
console.log(`\n Refused — ${result.reason}\n`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
const { path, content, todos } = result.draft;
|
|
132
|
+
if (argv.includes('--write')) {
|
|
133
|
+
await writeFile(path, content, 'utf8');
|
|
134
|
+
console.log(`\n Draft written: ${path}`);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(content);
|
|
137
|
+
}
|
|
138
|
+
console.log(`\n ${todos.length} decision(s) left to a human:`);
|
|
139
|
+
todos.forEach((todo, index) => console.log(` ${index + 1}. ${todo}`));
|
|
140
|
+
console.log('\n While one is left, the subject throws at runtime: never a misleading green light.\n');
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runMutate(argv: readonly string[]): Promise<number> {
|
|
145
|
+
const root = argv[0];
|
|
146
|
+
if (root === undefined || root.startsWith('--')) {
|
|
147
|
+
throw new Error('speccov: name a directory or file to mutate.');
|
|
148
|
+
}
|
|
149
|
+
const command = valueOf(argv, '--run');
|
|
150
|
+
if (command === undefined) {
|
|
151
|
+
throw new Error('speccov: --run <command> is required — something has to judge a mutant.');
|
|
152
|
+
}
|
|
153
|
+
const limit = Number(valueOf(argv, '--limit') ?? DEFAULT_LIMIT);
|
|
154
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
155
|
+
throw new Error('speccov: --limit expects an integer of 1 or more.');
|
|
156
|
+
}
|
|
157
|
+
assertCleanTree(root);
|
|
158
|
+
|
|
159
|
+
const files = await collectSources(resolve(root));
|
|
160
|
+
const result = await mutate({
|
|
161
|
+
files,
|
|
162
|
+
command: command.split(' '),
|
|
163
|
+
cwd: process.cwd(),
|
|
164
|
+
limit,
|
|
165
|
+
// A carriage return erases nothing in a file or a pipe: outside a terminal, stay quiet.
|
|
166
|
+
onProgress: (done, total) => {
|
|
167
|
+
if (process.stderr.isTTY === true) {
|
|
168
|
+
process.stderr.write(`\r mutant ${done}/${total} `);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
process.stderr.write('\r');
|
|
173
|
+
// A survivor is a question, not a refusal: the same semantics as a gap.
|
|
174
|
+
console.log(argv.includes('--json') ? mutantsToJson(result, 0) : reportMutants(result, argv.includes('--all')));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Mutation writes into the project's own files. Restoration is guaranteed in `finally`,
|
|
180
|
+
* but a clean repository is the last-resort net: `git checkout` must always be able to
|
|
181
|
+
* catch everything.
|
|
182
|
+
*/
|
|
183
|
+
function assertCleanTree(root: string): void {
|
|
184
|
+
const { status, stdout } = spawnSync('git', ['status', '--porcelain', '--', root], {
|
|
185
|
+
encoding: 'utf8',
|
|
186
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
187
|
+
maxBuffer: Infinity,
|
|
188
|
+
});
|
|
189
|
+
// Checked first: without git there is no status, and no output to read either.
|
|
190
|
+
if (status !== 0) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`speccov: ${root} is not inside a git repository. Mutation writes into your files: it refuses without a net.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (stdout.trim() !== '') {
|
|
196
|
+
throw new Error(`speccov: ${root} has uncommitted changes. Commit or stash them before mutating.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function valueOf(argv: readonly string[], flag: string): string | undefined {
|
|
201
|
+
const index = argv.indexOf(flag);
|
|
202
|
+
if (index === -1) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const value = argv[index + 1];
|
|
206
|
+
if (value === undefined || value.startsWith('--')) {
|
|
207
|
+
throw new Error(`speccov: ${flag} expects a value.`);
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runScan(argv: readonly string[]): Promise<void> {
|
|
213
|
+
const root = argv[0];
|
|
214
|
+
if (root === undefined || root.startsWith('--')) {
|
|
215
|
+
throw new Error('speccov: name a directory to scan.');
|
|
216
|
+
}
|
|
217
|
+
const flagIndex = argv.indexOf('--tsconfig');
|
|
218
|
+
const tsconfig = flagIndex === -1 ? undefined : argv[flagIndex + 1];
|
|
219
|
+
if (flagIndex !== -1 && (tsconfig === undefined || tsconfig.startsWith('--'))) {
|
|
220
|
+
throw new Error('speccov: --tsconfig expects a path.');
|
|
221
|
+
}
|
|
222
|
+
const result = await scan(tsconfig === undefined ? { root } : { root, tsconfig });
|
|
223
|
+
if (argv.includes('--draft')) {
|
|
224
|
+
await reportSweep(root, result.sites, argv.includes('--write'));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const answersPath = valueOf(argv, '--answers') ?? DEFAULT_SCAN_ANSWERS_PATH;
|
|
229
|
+
const known = await loadSiteAnswers(answersPath);
|
|
230
|
+
const accepts = allValuesOf(argv, '--accept');
|
|
231
|
+
const answered = applySiteAnswers(result.sites, known);
|
|
232
|
+
// Recorded from what this pass just found, so the answer bears on the code as it reads
|
|
233
|
+
// today — never on a line number a later commit would move.
|
|
234
|
+
if (accepts.length > 0) {
|
|
235
|
+
await recordSites(answersPath, known, answered.sites, accepts, valueOf(argv, '--why'));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const kept = { ...result, sites: answered.sites };
|
|
240
|
+
console.log(argv.includes('--json') ? scanToJson(kept, answered.accepted) : reportScan(kept, answered.accepted));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Writes down "this is not a defect" for the named sites.
|
|
245
|
+
*
|
|
246
|
+
* A site is named the way the report prints it, `file:line`, because that is what a human has
|
|
247
|
+
* in front of them. What gets stored is the shape at that line, not the line: the address is
|
|
248
|
+
* for the person, the memory is for the code.
|
|
249
|
+
*/
|
|
250
|
+
async function recordSites(
|
|
251
|
+
path: string,
|
|
252
|
+
known: readonly SiteAnswer[],
|
|
253
|
+
sites: readonly Site[],
|
|
254
|
+
accepts: readonly string[],
|
|
255
|
+
why: string | undefined,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
258
|
+
const found = accepts.map((address) => ({ address, site: sites.find((site) => `${site.file}:${site.line}` === address) }));
|
|
259
|
+
const unknown = found.filter(({ site }) => site === undefined).map(({ address }) => address);
|
|
260
|
+
if (unknown.length > 0) {
|
|
261
|
+
throw new Error(`speccov: no site to answer at ${unknown.join(', ')}. Run the scan first and copy an address from it.`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const merged = [...known];
|
|
265
|
+
for (const { site } of found) {
|
|
266
|
+
if (site !== undefined) {
|
|
267
|
+
merged.push(acceptSite(site, today, why));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await saveSiteAnswers(path, merged);
|
|
271
|
+
console.log(` ${accepts.length} site(s) answered in ${path}\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function allValuesOf(argv: readonly string[], flag: string): readonly string[] {
|
|
275
|
+
const values: string[] = [];
|
|
276
|
+
for (const [index, argument] of argv.entries()) {
|
|
277
|
+
if (argument !== flag) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const value = argv[index + 1];
|
|
281
|
+
if (value === undefined || value.startsWith('--')) {
|
|
282
|
+
throw new Error(`speccov: ${flag} expects a value.`);
|
|
283
|
+
}
|
|
284
|
+
values.push(value);
|
|
285
|
+
}
|
|
286
|
+
return values;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function reportSweep(root: string, sites: readonly Site[], write: boolean): Promise<void> {
|
|
290
|
+
const { drafted, refusedBy, tried } = await sweep(root, sites);
|
|
291
|
+
console.log(`\n ${tried} site(s) with a .map(), ${drafted.length} turned into a rule draft.\n`);
|
|
292
|
+
|
|
293
|
+
for (const { file, line, path, todos, content } of drafted) {
|
|
294
|
+
const decisions = todos.length === 0 ? 'nothing left to decide' : `${todos.length} decision(s) left`;
|
|
295
|
+
if (!write) {
|
|
296
|
+
console.log(` ${file}:${line} → ${path} (${decisions})`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (existsSync(path)) {
|
|
300
|
+
console.log(` ${file}:${line} → ${path} already exists, left untouched`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
await writeFile(path, content, 'utf8');
|
|
304
|
+
console.log(` ${file}:${line} → ${path} written (${decisions})`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (refusedBy.size > 0) {
|
|
308
|
+
console.log(`\n ── Not drafted ───────────────────────────────────────────`);
|
|
309
|
+
for (const [reason, count] of [...refusedBy].sort((left, right) => right[1] - left[1])) {
|
|
310
|
+
console.log(` ${String(count).padStart(4)} ${reason}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
console.log('\n A refusal is not a failure: it names what the bridge cannot read yet.\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
type Options = {
|
|
317
|
+
readonly target: string;
|
|
318
|
+
readonly answersPath: string;
|
|
319
|
+
readonly accepts: readonly string[];
|
|
320
|
+
readonly why: string | undefined;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function parseArguments(argv: readonly string[]): Options | 'help' {
|
|
324
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
325
|
+
return 'help';
|
|
326
|
+
}
|
|
327
|
+
let target: string | undefined;
|
|
328
|
+
let answersPath = DEFAULT_ANSWERS_PATH;
|
|
329
|
+
let why: string | undefined;
|
|
330
|
+
const accepts: string[] = [];
|
|
331
|
+
|
|
332
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
333
|
+
const argument = argv[index];
|
|
334
|
+
if (argument === '--accept' || argument === '--why' || argument === '--answers') {
|
|
335
|
+
const value = argv[index + 1];
|
|
336
|
+
if (value === undefined || value.startsWith('--')) {
|
|
337
|
+
throw new Error(`speccov: ${argument} expects a value.`);
|
|
338
|
+
}
|
|
339
|
+
if (argument === '--accept') {
|
|
340
|
+
accepts.push(value);
|
|
341
|
+
} else if (argument === '--why') {
|
|
342
|
+
why = value;
|
|
343
|
+
} else {
|
|
344
|
+
answersPath = value;
|
|
345
|
+
}
|
|
346
|
+
index += 1;
|
|
347
|
+
} else if (argument === '--json') {
|
|
348
|
+
// Valueless flag, read straight from argv at the moment of writing.
|
|
349
|
+
} else if (argument !== undefined && argument.startsWith('--')) {
|
|
350
|
+
throw new Error(`speccov: unknown option ${argument}.`);
|
|
351
|
+
} else if (target === undefined) {
|
|
352
|
+
target = argument;
|
|
353
|
+
} else {
|
|
354
|
+
throw new Error(`speccov: one rules file at a time (also received ${argument}).`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (target === undefined) {
|
|
358
|
+
throw new Error('speccov: name a rules file.');
|
|
359
|
+
}
|
|
360
|
+
return { target, answersPath, accepts, why };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function recordAcceptances(verdicts: readonly Verdictish[], options: Options): Promise<void> {
|
|
364
|
+
const lacunae = new Map(verdicts.filter(isLacuna).map((lacuna) => [lacuna.rule.name, lacuna]));
|
|
365
|
+
const unknown = options.accepts.filter((name) => !lacunae.has(name));
|
|
366
|
+
if (unknown.length > 0) {
|
|
367
|
+
throw new Error(`speccov: no open gap named ${unknown.map((name) => `"${name}"`).join(', ')}.`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
371
|
+
const existing = await loadAnswers(options.answersPath);
|
|
372
|
+
const merged = new Map(existing.map((answer): [string, Answer] => [answer.rule, answer]));
|
|
373
|
+
for (const name of options.accepts) {
|
|
374
|
+
const lacuna = lacunae.get(name);
|
|
375
|
+
if (lacuna !== undefined) {
|
|
376
|
+
merged.set(name, accept(lacuna, today, options.why));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
await saveAnswers(options.answersPath, [...merged.values()]);
|
|
380
|
+
console.log(` ${options.accepts.length} answer(s) recorded in ${options.answersPath}\n`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type Verdictish = Awaited<ReturnType<typeof run>>[number];
|
|
384
|
+
|
|
385
|
+
function isLacuna(verdict: Verdictish): verdict is Lacuna {
|
|
386
|
+
return verdict.kind === 'lacuna';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function loadRules(path: string): Promise<readonly Rule[]> {
|
|
390
|
+
const module: unknown = await import(pathToFileURL(resolve(path)).href);
|
|
391
|
+
const exported = (module as { default?: unknown }).default;
|
|
392
|
+
const value = typeof exported === 'function' ? await (exported as () => unknown)() : exported;
|
|
393
|
+
|
|
394
|
+
if (!Array.isArray(value)) {
|
|
395
|
+
throw new Error(`speccov: ${path} must default-export an array of rules (received ${describe(value)}).`);
|
|
396
|
+
}
|
|
397
|
+
value.forEach(assertRule);
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function assertRule(candidate: unknown, index: number): asserts candidate is Rule {
|
|
402
|
+
const rule = candidate as Partial<Rule> | null;
|
|
403
|
+
const valid =
|
|
404
|
+
rule !== null &&
|
|
405
|
+
typeof rule === 'object' &&
|
|
406
|
+
typeof rule.name === 'string' &&
|
|
407
|
+
typeof rule.intention === 'string' &&
|
|
408
|
+
typeof rule.measure === 'function' &&
|
|
409
|
+
typeof rule.judge === 'function';
|
|
410
|
+
if (!valid) {
|
|
411
|
+
throw new Error(`speccov: item ${index} is not a usable rule.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function describe(value: unknown): string {
|
|
416
|
+
return value === null ? 'null' : typeof value;
|
|
417
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Counter } from './counter';
|
|
2
|
+
import { EPSILON, measureGrowth } from './growth';
|
|
3
|
+
import { requireIntention, type Judgement, type Measurement, type Origin, type Rule } from './rule';
|
|
4
|
+
|
|
5
|
+
export type ComplexityOptions = {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
/** Readable sentence. Generated from `unit` when absent — a default rule carries its own. */
|
|
8
|
+
readonly intention?: string;
|
|
9
|
+
/** Successive input sizes. At least two distinct values. */
|
|
10
|
+
readonly sizes?: readonly number[];
|
|
11
|
+
/** Unit counted, PLURAL: it only ever appears after "the number of". */
|
|
12
|
+
readonly unit?: string;
|
|
13
|
+
/** Tolerated cost per item. Zero by default: the cost must stay constant. */
|
|
14
|
+
readonly perItemTolerance?: number;
|
|
15
|
+
readonly origin?: Origin;
|
|
16
|
+
readonly counter: Counter;
|
|
17
|
+
readonly subject: (size: number) => unknown;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SIZES = [1, 2, 4, 8, 16] as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Complexity rule: the cost does not grow with the input size.
|
|
24
|
+
*
|
|
25
|
+
* This is the rule nobody needs to write — hence `origin: 'default'`. It catches N+1s and
|
|
26
|
+
* any fan-out proportional to the data.
|
|
27
|
+
*/
|
|
28
|
+
export function complexity(options: ComplexityOptions): Rule {
|
|
29
|
+
const unit = options.unit ?? 'operations';
|
|
30
|
+
const sizes = options.sizes ?? DEFAULT_SIZES;
|
|
31
|
+
const tolerance = options.perItemTolerance ?? 0;
|
|
32
|
+
const intention = requireIntention(
|
|
33
|
+
options.name,
|
|
34
|
+
options.intention ?? `The number of ${unit} does not grow with the input size`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: options.name,
|
|
39
|
+
intention,
|
|
40
|
+
origin: options.origin ?? 'default',
|
|
41
|
+
|
|
42
|
+
// A zero tolerance is the default, not a decision: nothing to tighten.
|
|
43
|
+
tighten: () => (tolerance > 0 ? complexity({ ...options, perItemTolerance: 0 }) : undefined),
|
|
44
|
+
|
|
45
|
+
async measure(): Promise<readonly Measurement[]> {
|
|
46
|
+
const measurements: Measurement[] = [];
|
|
47
|
+
for (const size of sizes) {
|
|
48
|
+
options.counter.reset();
|
|
49
|
+
await options.subject(size);
|
|
50
|
+
measurements.push({ size, cost: options.counter.hits });
|
|
51
|
+
}
|
|
52
|
+
return measurements;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
judge(measurements): Judgement {
|
|
56
|
+
const { perItem, series } = measureGrowth(measurements);
|
|
57
|
+
// A series of zeros gives a zero slope. That is constancy by absence:
|
|
58
|
+
// the code did nothing, the rule saw nothing.
|
|
59
|
+
const exercised = measurements.some(({ cost }) => cost > 0);
|
|
60
|
+
if (perItem <= tolerance + EPSILON) {
|
|
61
|
+
return { held: true, exercised, magnitude: perItem, detail: `The number of ${unit} stays constant (${series})` };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
held: false,
|
|
65
|
+
exercised,
|
|
66
|
+
magnitude: perItem,
|
|
67
|
+
detail: `The number of ${unit} grows by ≈ ${round(perItem)} per item (${series})`,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function round(value: number): number {
|
|
74
|
+
return Math.round(value * 100) / 100;
|
|
75
|
+
}
|
package/src/counter.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Minimal probe: counts occurrences. Knows nothing about the domain it observes. */
|
|
2
|
+
export class Counter {
|
|
3
|
+
#hits = 0;
|
|
4
|
+
|
|
5
|
+
hit(): void {
|
|
6
|
+
this.#hits += 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
get hits(): number {
|
|
10
|
+
return this.#hits;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
reset(): void {
|
|
14
|
+
this.#hits = 0;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cartesian product of a finite domain, from the simplest case to the most complex.
|
|
3
|
+
*
|
|
4
|
+
* A ban that only tries a hand-written list finds nothing beyond what its author already
|
|
5
|
+
* suspected. The scope defect in `AreaScopeService` was found because someone had thought
|
|
6
|
+
* to write `{ role: 'org_admin', adminAreaId: null }` — not because the tool had gone
|
|
7
|
+
* looking for it.
|
|
8
|
+
*
|
|
9
|
+
* Four roles and two area states make eight actors. All eight are free: the predicate is
|
|
10
|
+
* pure, the loop runs in under a millisecond. There is no reason at all to pick a subset
|
|
11
|
+
* of them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** For each field, the values it can take. The first one is the simplest. */
|
|
15
|
+
export type Domain<T> = { readonly [K in keyof T]: readonly T[K][] };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A guard against the typo, not against exhaustiveness.
|
|
19
|
+
*
|
|
20
|
+
* Twenty values over eight fields make twenty-five billion cases: that is no longer a
|
|
21
|
+
* domain, it is a typing mistake. We throw and name it rather than sampling a share of it
|
|
22
|
+
* in silence — a ban exercised over a tenth of its domain announces a green light it does
|
|
23
|
+
* not have.
|
|
24
|
+
*/
|
|
25
|
+
const REFUSAL_THRESHOLD = 1_000_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every case of the domain, ordered by distance from the simplest combination.
|
|
29
|
+
*
|
|
30
|
+
* The ordering carries the whole job: the first counterexample met is the one that deviates
|
|
31
|
+
* least from the base case, hence **the smallest**. No shrinking to write, no second pass —
|
|
32
|
+
* `forbidden()` already keeps the first one, and it turns out to be the right one.
|
|
33
|
+
*/
|
|
34
|
+
export function allOf<T extends object>(domain: Domain<T>): readonly T[] {
|
|
35
|
+
const fields = Object.keys(domain) as (keyof T)[];
|
|
36
|
+
if (fields.length === 0) {
|
|
37
|
+
throw new Error('speccov: allOf() received no field. An empty domain proves nothing.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let total = 1;
|
|
41
|
+
for (const field of fields) {
|
|
42
|
+
const values = domain[field];
|
|
43
|
+
if (values.length === 0) {
|
|
44
|
+
throw new Error(`speccov: allOf() got no value for “${String(field)}”. A field without a value has no case.`);
|
|
45
|
+
}
|
|
46
|
+
total *= values.length;
|
|
47
|
+
if (total > REFUSAL_THRESHOLD) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`speccov: allOf() would build more than ${REFUSAL_THRESHOLD.toLocaleString('en-US')} cases. Narrow the domain — this is a typo, not a domain.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cases: { value: T; distance: number }[] = [];
|
|
55
|
+
const walk = (index: number, built: Partial<T>, distance: number): void => {
|
|
56
|
+
const field = fields[index];
|
|
57
|
+
if (field === undefined) {
|
|
58
|
+
cases.push({ value: { ...built } as T, distance });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const values = domain[field];
|
|
62
|
+
values.forEach((value, rank) => {
|
|
63
|
+
walk(index + 1, { ...built, [field]: value }, distance + (rank === 0 ? 0 : 1));
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
walk(0, {}, 0);
|
|
67
|
+
|
|
68
|
+
// Stable sort: at equal distance, the order the domain was written in is preserved.
|
|
69
|
+
return cases.sort((left, right) => left.distance - right.distance).map(({ value }) => value);
|
|
70
|
+
}
|