@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { Dirent } from 'node:fs';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { climbFor } from './climb';
|
|
5
|
+
import { readGuarantees } from './guarantees';
|
|
6
|
+
import { analyseScope } from './program';
|
|
7
|
+
import { dropIgnored } from './ignored';
|
|
8
|
+
import { loadIgnore, type Ignore } from './speccovignore';
|
|
9
|
+
import type { Scope } from './report';
|
|
10
|
+
import type { ScanResult } from './report';
|
|
11
|
+
import { type Site } from './shapes';
|
|
12
|
+
|
|
13
|
+
/** Directories never read: this is not the project's code. */
|
|
14
|
+
const IGNORED = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.turbo', '.next', '.nuxt']);
|
|
15
|
+
|
|
16
|
+
const SOURCE = /\.(?:ts|tsx|mts|cts)$/u;
|
|
17
|
+
const GENERATED = /\.d\.ts$/u;
|
|
18
|
+
|
|
19
|
+
/** Specs are not production code: their fixtures are not defects. */
|
|
20
|
+
const TEST = /\.(?:spec|test|e2e-spec)\.[cm]?tsx?$/u;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Code the pass cannot parse. Counted and declared, never skipped in silence: a
|
|
24
|
+
* `<script setup lang="ts">` holds real TypeScript that we are not looking at.
|
|
25
|
+
*/
|
|
26
|
+
const UNPARSED = /\.(vue|svelte|astro|jsx?|mjs|cjs)$/u;
|
|
27
|
+
|
|
28
|
+
export type ScanOptions = {
|
|
29
|
+
readonly root: string;
|
|
30
|
+
readonly tsconfig?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A purely static pass: reads, parses, runs nothing. */
|
|
34
|
+
export async function scan({ root, tsconfig }: ScanOptions): Promise<ScanResult> {
|
|
35
|
+
const configs = tsconfig === undefined ? await findConfigs(root) : [tsconfig];
|
|
36
|
+
const guarantees: readonly Scope[] = configs.map((path) => ({
|
|
37
|
+
path: relative(root, path) || path,
|
|
38
|
+
guarantees: readGuarantees(path),
|
|
39
|
+
}));
|
|
40
|
+
const isIgnored = await loadIgnore(root);
|
|
41
|
+
// Type checking happens per scope: one TypeScript program per tsconfig.
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
const sites: Site[] = [];
|
|
44
|
+
let ignoredSites = 0;
|
|
45
|
+
const refused: { path: string; reason: string }[] = [];
|
|
46
|
+
for (const path of configs) {
|
|
47
|
+
const analysis = analyseScope(root, path);
|
|
48
|
+
if (analysis.kind === 'refused') {
|
|
49
|
+
refused.push({ path: relative(root, path) || path, reason: analysis.reason });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// A file belonging to two scopes would be counted twice.
|
|
53
|
+
for (const site of analysis.sites) {
|
|
54
|
+
// A tsconfig found by climbing covers more than the directory asked for.
|
|
55
|
+
// Type checking benefits; the report does not: those sites were not asked for.
|
|
56
|
+
if (site.file.startsWith('..')) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// The excerpt is part of the key: a signature carries one question per parameter, all
|
|
60
|
+
// on the same line. The same site seen through two scopes yields the same excerpt, so
|
|
61
|
+
// the duplicate is still dropped.
|
|
62
|
+
const key = `${site.kind}\u0000${site.file}\u0000${site.line}\u0000${site.excerpt}`;
|
|
63
|
+
if (seen.has(key)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
seen.add(key);
|
|
67
|
+
// Still type-checked with the rest of its scope: only its questions are left out.
|
|
68
|
+
if (isIgnored(join(root, site.file))) {
|
|
69
|
+
ignoredSites += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
sites.push(site);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { ignoredFiles, ...accounted } = await account(root, isIgnored);
|
|
77
|
+
return { guarantees, sites, ...accounted, ignored: { files: ignoredFiles, sites: ignoredSites }, refused };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Accounting = Pick<ScanResult, 'filesScanned' | 'unreadable' | 'skipped' | 'testsExcluded'> & {
|
|
81
|
+
readonly ignoredFiles: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The walk only serves honest accounting: what was read, and what was not looked at. */
|
|
85
|
+
async function account(root: string, isIgnored: Ignore): Promise<Accounting> {
|
|
86
|
+
const skipped = new Map<string, number>();
|
|
87
|
+
let filesScanned = 0;
|
|
88
|
+
let testsExcluded = 0;
|
|
89
|
+
let ignoredFiles = 0;
|
|
90
|
+
|
|
91
|
+
const { paths, unreadable } = await walkTracked(root);
|
|
92
|
+
for (const path of paths) {
|
|
93
|
+
// Asked first: a file the project set aside is neither "not parsed" nor "a test".
|
|
94
|
+
if (isIgnored(path)) {
|
|
95
|
+
ignoredFiles += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const unparsed = UNPARSED.exec(path)?.[1];
|
|
99
|
+
if (unparsed !== undefined) {
|
|
100
|
+
skipped.set(unparsed, (skipped.get(unparsed) ?? 0) + 1);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (TEST.test(path)) {
|
|
104
|
+
testsExcluded += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
filesScanned += 1;
|
|
108
|
+
}
|
|
109
|
+
return { filesScanned, unreadable, skipped, testsExcluded, ignoredFiles };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Everything the walk finds, minus what the repository sets aside, plus what it could not open. */
|
|
113
|
+
async function walkTracked(root: string): Promise<{ readonly paths: readonly string[]; readonly unreadable: readonly string[] }> {
|
|
114
|
+
const found: string[] = [];
|
|
115
|
+
const unreadable: string[] = [];
|
|
116
|
+
for await (const path of walk(root, unreadable)) {
|
|
117
|
+
found.push(path);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
paths: await dropIgnored(root, found),
|
|
121
|
+
unreadable: unreadable.map((path) => relative(root, path) || path),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A monorepo has one tsconfig per app: construction guarantees are evaluated scope by scope. */
|
|
126
|
+
async function* walkConfigs(directory: string): AsyncGenerator<string> {
|
|
127
|
+
// Unreadable here means one thing only: no scope was found under it. The accounting walk
|
|
128
|
+
// covers the same tree and is the one that reports the path, so it is not named twice.
|
|
129
|
+
const entries = await readEntries(directory);
|
|
130
|
+
if (entries === undefined) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// The order the filesystem lists entries in is not a contract: a scope comes before the
|
|
134
|
+
// ones nested in it, and siblings come by name, so the report reads the same everywhere.
|
|
135
|
+
if (entries.some((entry) => entry.isFile() && entry.name === 'tsconfig.json')) {
|
|
136
|
+
yield join(directory, 'tsconfig.json');
|
|
137
|
+
}
|
|
138
|
+
const directories = entries.filter((entry) => entry.isDirectory() && !IGNORED.has(entry.name));
|
|
139
|
+
for (const entry of directories.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
140
|
+
yield* walkConfigs(join(directory, entry.name));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function findConfigs(root: string): Promise<readonly string[]> {
|
|
145
|
+
const found: string[] = [];
|
|
146
|
+
for await (const path of walkConfigs(root)) {
|
|
147
|
+
found.push(path);
|
|
148
|
+
}
|
|
149
|
+
const kept = await dropIgnored(root, found);
|
|
150
|
+
return kept.length > 0 ? kept : climbForConfig(root);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The first `tsconfig.json` found by climbing, when the descent gave nothing.
|
|
155
|
+
*
|
|
156
|
+
* Aiming at a subdirectory is the common gesture — `scan src/context/identity`. There is
|
|
157
|
+
* no tsconfig in there, but the app's covers it: without this climb, the pass answered
|
|
158
|
+
* "no scope" on a perfectly analysable directory, in silence.
|
|
159
|
+
*
|
|
160
|
+
* We stop at the first one found, and never above the repository root: a tsconfig two
|
|
161
|
+
* levels above the project no longer describes it.
|
|
162
|
+
*/
|
|
163
|
+
function climbForConfig(root: string): readonly string[] {
|
|
164
|
+
const found = climbFor('tsconfig.json', root);
|
|
165
|
+
return found === undefined ? [] : [found];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The production TypeScript files, tests excluded. Shared with mutation. */
|
|
169
|
+
export async function collectSources(root: string): Promise<readonly string[]> {
|
|
170
|
+
// A single file is a legitimate scope: that is how you iterate on one module.
|
|
171
|
+
if ((await stat(root)).isFile()) {
|
|
172
|
+
return SOURCE.test(root) && !GENERATED.test(root) ? [root] : [];
|
|
173
|
+
}
|
|
174
|
+
const { paths } = await walkTracked(root);
|
|
175
|
+
return paths.filter((path) => !UNPARSED.test(path) && !TEST.test(path));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The entries of a directory, or nothing when it does not open.
|
|
180
|
+
*
|
|
181
|
+
* A directory the process cannot open used to abort the whole scan: one folder with the wrong
|
|
182
|
+
* mode, and a repository came back with no answer at all. Both walks step over it instead,
|
|
183
|
+
* because a scan that stops has nothing to say about the rest of the tree.
|
|
184
|
+
*/
|
|
185
|
+
async function readEntries(directory: string): Promise<readonly Dirent[] | undefined> {
|
|
186
|
+
try {
|
|
187
|
+
return await readdir(directory, { withFileTypes: true });
|
|
188
|
+
} catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function* walk(directory: string, unreadable: string[]): AsyncGenerator<string> {
|
|
194
|
+
const entries = await readEntries(directory);
|
|
195
|
+
if (entries === undefined) {
|
|
196
|
+
unreadable.push(directory);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const path = join(directory, entry.name);
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
if (!IGNORED.has(entry.name)) {
|
|
203
|
+
yield* walk(path, unreadable);
|
|
204
|
+
}
|
|
205
|
+
} else if ((SOURCE.test(entry.name) || UNPARSED.test(entry.name)) && !GENERATED.test(entry.name)) {
|
|
206
|
+
yield path;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { reportScan, type ScanResult } from './report';
|
|
212
|
+
export type { Guarantee, Guarantees } from './guarantees';
|
|
213
|
+
export { findShapes, type ShapeKind, type Site } from './shapes';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { relative, resolve } from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { firstBlocking } from './guarantees';
|
|
4
|
+
import { findShapes, type Site } from './shapes';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a scope gave: its sites, or the reason for its silence.
|
|
8
|
+
*
|
|
9
|
+
* The second case is not a detail. A program that does not build — dependencies missing,
|
|
10
|
+
* an `extends` that cannot be found, a generated file absent — must not make the tool fall
|
|
11
|
+
* back on a guess: it would then produce exactly the false positives that type checking
|
|
12
|
+
* removes, under a look of rigour.
|
|
13
|
+
*/
|
|
14
|
+
export type Analysis =
|
|
15
|
+
| { readonly kind: 'analysed'; readonly sites: readonly Site[]; readonly filesTyped: number }
|
|
16
|
+
| { readonly kind: 'refused'; readonly reason: string };
|
|
17
|
+
|
|
18
|
+
/** A file that carries no project code: neither test, nor installed dependency. */
|
|
19
|
+
const TEST = /\.(?:spec|test|e2e-spec)\.[cm]?tsx?$/u;
|
|
20
|
+
const DECLARATION = /\.d\.ts$/u;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The files speccov writes itself: they carry **the answer**, not the question.
|
|
24
|
+
*
|
|
25
|
+
* A `.rules.ts` holds the doubles that measure the site — among them a
|
|
26
|
+
* `mockResolvedValue({ items, total })` that looks like a paginated return. Without this
|
|
27
|
+
* exclusion, `scan` re-reads its own drafts and reports its own fixture: measured on a
|
|
28
|
+
* real-world monorepo, `list-admin-users.rules.ts:84` came out a `metamorphic()` candidate.
|
|
29
|
+
*/
|
|
30
|
+
const RULES = /\.rules\.[cm]?ts$/u;
|
|
31
|
+
|
|
32
|
+
export function analyseScope(root: string, tsconfigPath: string): Analysis {
|
|
33
|
+
const { config, error } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
34
|
+
if (error !== undefined) {
|
|
35
|
+
return { kind: 'refused', reason: flatten(error) };
|
|
36
|
+
}
|
|
37
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, dirOf(tsconfigPath));
|
|
38
|
+
|
|
39
|
+
// Same discipline as the flag read. Without it, speccov built a program on a broken
|
|
40
|
+
// `extends`: TypeScript falls back to its defaults, the types degrade, and the sites
|
|
41
|
+
// came out presented as sound.
|
|
42
|
+
const blocking = firstBlocking(parsed.errors);
|
|
43
|
+
if (blocking !== undefined) {
|
|
44
|
+
return { kind: 'refused', reason: flatten(blocking) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A "solution" tsconfig — `files: []` plus `references` — compiles nothing itself.
|
|
48
|
+
// Stopping there silenced four scopes out of nineteen in a real-world monorepo.
|
|
49
|
+
const references = parsed.projectReferences ?? [];
|
|
50
|
+
if (parsed.fileNames.length === 0 && references.length > 0) {
|
|
51
|
+
return delegate(root, references);
|
|
52
|
+
}
|
|
53
|
+
if (parsed.fileNames.length === 0) {
|
|
54
|
+
return { kind: 'refused', reason: 'no input file in this scope' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Asked before the program is built, not after. A generated scope declares two files, both
|
|
58
|
+
// of them declarations, and building its program still costs a second and a half: measured
|
|
59
|
+
// on codesema, six `.nuxt/tsconfig.*` spent 15s of a 35s scan to type four files between
|
|
60
|
+
// them. The loop below would have skipped every one of them anyway.
|
|
61
|
+
// Analysed and empty, never refused: there is nothing here to read, which is not the same
|
|
62
|
+
// as being unable to read it. Refusing would propagate through `delegate` and silence the
|
|
63
|
+
// whole app the generated scope belongs to — 14s saved against two scopes lost.
|
|
64
|
+
if (!parsed.fileNames.some(isWorthTyping)) {
|
|
65
|
+
return { kind: 'analysed', sites: [], filesTyped: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const program = ts.createProgram(parsed.fileNames, parsed.options);
|
|
69
|
+
const checker = program.getTypeChecker();
|
|
70
|
+
const sites: Site[] = [];
|
|
71
|
+
let filesTyped = 0;
|
|
72
|
+
|
|
73
|
+
for (const source of program.getSourceFiles()) {
|
|
74
|
+
const path = source.fileName;
|
|
75
|
+
if (source.isDeclarationFile || !isWorthTyping(path)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
filesTyped += 1;
|
|
79
|
+
sites.push(...findShapes(source, checker, relative(root, path) || path));
|
|
80
|
+
}
|
|
81
|
+
return { kind: 'analysed', sites, filesTyped };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is this file one the pass would read?
|
|
86
|
+
*
|
|
87
|
+
* The same question decides twice: whether a scope is worth a program, and whether a source is
|
|
88
|
+
* worth walking. Two lists would drift, and the first one would then pay for scopes the second
|
|
89
|
+
* throws away.
|
|
90
|
+
*/
|
|
91
|
+
function isWorthTyping(path: string): boolean {
|
|
92
|
+
return !DECLARATION.test(path) && !TEST.test(path) && !RULES.test(path) && !path.includes('/node_modules/');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Analyses each referenced project and joins their sites. One unreadable refuses them all. */
|
|
96
|
+
function delegate(root: string, references: readonly ts.ProjectReference[]): Analysis {
|
|
97
|
+
const sites: Site[] = [];
|
|
98
|
+
let filesTyped = 0;
|
|
99
|
+
for (const reference of references) {
|
|
100
|
+
const resolved = resolveReference(reference.path);
|
|
101
|
+
if (resolved === undefined) {
|
|
102
|
+
return { kind: 'refused', reason: `referenced project ${reference.path} was not found` };
|
|
103
|
+
}
|
|
104
|
+
const part = analyseScope(root, resolved);
|
|
105
|
+
if (part.kind === 'refused') {
|
|
106
|
+
return { kind: 'refused', reason: `${reference.path}: ${part.reason}` };
|
|
107
|
+
}
|
|
108
|
+
sites.push(...part.sites);
|
|
109
|
+
filesTyped += part.filesTyped;
|
|
110
|
+
}
|
|
111
|
+
return { kind: 'analysed', sites, filesTyped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveReference(path: string): string | undefined {
|
|
115
|
+
if (ts.sys.directoryExists(path)) {
|
|
116
|
+
const inside = `${path}/tsconfig.json`;
|
|
117
|
+
return ts.sys.fileExists(inside) ? inside : undefined;
|
|
118
|
+
}
|
|
119
|
+
return ts.sys.fileExists(path) ? path : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The tsconfig's directory, **absolute**.
|
|
124
|
+
*
|
|
125
|
+
* `ts.sys.readDirectory` does not resolve a relative `basePath` against the current
|
|
126
|
+
* directory: it yields zero files, with no diagnostic other than TS18003. A
|
|
127
|
+
* `speccov scan ../other-repo` then refused all nineteen scopes of a real-world monorepo —
|
|
128
|
+
* a green report on zero lines read.
|
|
129
|
+
*/
|
|
130
|
+
function dirOf(path: string): string {
|
|
131
|
+
const cut = path.lastIndexOf('/');
|
|
132
|
+
return resolve(cut === -1 ? '.' : path.slice(0, cut));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function flatten(diagnostic: ts.Diagnostic): string {
|
|
136
|
+
return ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
|
|
137
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { version as tsVersion } from 'typescript';
|
|
2
|
+
import type { Guarantees } from './guarantees';
|
|
3
|
+
import type { ShapeKind, Site } from './shapes';
|
|
4
|
+
|
|
5
|
+
/** A tsconfig and what it proves. A monorepo has several, of differing strictness. */
|
|
6
|
+
export type Scope = {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly guarantees: Guarantees;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ScanResult = {
|
|
12
|
+
/** Empty when no tsconfig was found or given. */
|
|
13
|
+
readonly guarantees: readonly Scope[];
|
|
14
|
+
readonly sites: readonly Site[];
|
|
15
|
+
readonly filesScanned: number;
|
|
16
|
+
/** Paths the walk could not open, a directory the mode refuses among them. Counted, never silenced. */
|
|
17
|
+
readonly unreadable: readonly string[];
|
|
18
|
+
/** Code files left unparsed, by extension. Declared, never silenced. */
|
|
19
|
+
readonly skipped: ReadonlyMap<string, number>;
|
|
20
|
+
/** Test files deliberately set aside. */
|
|
21
|
+
readonly testsExcluded: number;
|
|
22
|
+
/** What the project's `.speccovignore` left out. Counted, never silenced. */
|
|
23
|
+
readonly ignored: { readonly files: number; readonly sites: number };
|
|
24
|
+
/** Scopes whose program could not be built. Declared, never guessed at. */
|
|
25
|
+
readonly refused: readonly { readonly path: string; readonly reason: string }[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Candidate = { readonly kind: ShapeKind; readonly rule: string; readonly question: string };
|
|
29
|
+
|
|
30
|
+
const UNBOUNDED_PRIMITIVE: Candidate = {
|
|
31
|
+
kind: 'unbounded-primitive',
|
|
32
|
+
rule: 'forbidden()',
|
|
33
|
+
question: 'this parameter takes the whole primitive domain (empty string, NaN, negative, unbounded): what is the limit?',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const CANDIDATES: readonly Candidate[] = [
|
|
37
|
+
{
|
|
38
|
+
kind: 'unbounded-fanout',
|
|
39
|
+
rule: 'resource()',
|
|
40
|
+
question: 'outgoing concurrency follows the size of the collection — what is the ceiling?',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
kind: 'async-per-item',
|
|
44
|
+
rule: 'complexity()',
|
|
45
|
+
question: 'one asynchronous call per item — should their number grow?',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
kind: 'awaited-in-loop',
|
|
49
|
+
rule: 'complexity()',
|
|
50
|
+
question: 'one awaited call per item, in series — should the wait grow with the collection?',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
kind: 'paginated-return',
|
|
54
|
+
rule: 'metamorphic()',
|
|
55
|
+
question: 'should `total` stay the same whatever the page?',
|
|
56
|
+
},
|
|
57
|
+
UNBOUNDED_PRIMITIVE,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const MAX_SITES_SHOWN = 6;
|
|
61
|
+
|
|
62
|
+
function isEnabled(scope: Scope, flag: string): boolean {
|
|
63
|
+
return scope.guarantees.kind === 'read' && (scope.guarantees.flags.find((entry) => entry.flag === flag)?.enabled ?? false);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAX_LISTED = 3;
|
|
67
|
+
|
|
68
|
+
function list(values: readonly string[]): string {
|
|
69
|
+
const shown = values.slice(0, MAX_LISTED).join(', ');
|
|
70
|
+
return values.length > MAX_LISTED ? `${shown} … and ${values.length - MAX_LISTED} more` : shown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* For each flag: what it proves, and the scopes read where it is off.
|
|
75
|
+
*
|
|
76
|
+
* Extracted so that the prose and the JSON start from the same source. Two parallel
|
|
77
|
+
* aggregations would end up diverging, and a report that contradicts itself is worth nothing.
|
|
78
|
+
*/
|
|
79
|
+
export function flagCoverage(result: ScanResult): readonly FlagCoverage[] {
|
|
80
|
+
const readable = result.guarantees.flatMap((scope) => (scope.guarantees.kind === 'read' ? [scope] : []));
|
|
81
|
+
const [first] = readable;
|
|
82
|
+
const flags = first?.guarantees.kind === 'read' ? first.guarantees.flags : [];
|
|
83
|
+
|
|
84
|
+
return flags.map(({ flag, proves }) => ({
|
|
85
|
+
flag,
|
|
86
|
+
proves,
|
|
87
|
+
scopesEvaluated: readable.length,
|
|
88
|
+
offIn: readable.filter((scope) => !isEnabled(scope, flag)).map(({ path }) => path),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type FlagCoverage = {
|
|
93
|
+
readonly flag: string;
|
|
94
|
+
readonly proves: string;
|
|
95
|
+
readonly scopesEvaluated: number;
|
|
96
|
+
readonly offIn: readonly string[];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export { CANDIDATES };
|
|
100
|
+
|
|
101
|
+
export function reportScan(result: ScanResult, accepted: readonly Site[] = []): string {
|
|
102
|
+
const lines = ['', ` ${result.filesScanned} files read, nothing executed.`, ''];
|
|
103
|
+
|
|
104
|
+
const readable = result.guarantees.flatMap((scope) => (scope.guarantees.kind === 'read' ? [scope] : []));
|
|
105
|
+
const everywhere: string[] = [];
|
|
106
|
+
const partial: string[] = [];
|
|
107
|
+
|
|
108
|
+
for (const { flag, proves, offIn: missing } of flagCoverage(result)) {
|
|
109
|
+
const scope = readable.length > 1 ? `, ${readable.length} scopes evaluated` : '';
|
|
110
|
+
if (missing.length === 0) {
|
|
111
|
+
everywhere.push(` ✓ ${proves} (${flag}${scope})`);
|
|
112
|
+
} else if (missing.length === readable.length) {
|
|
113
|
+
// Never "everywhere": the conclusion bears only on the scopes read.
|
|
114
|
+
partial.push(` ⚠ ${proves} — ${flag} is off in all ${readable.length} scope(s) evaluated`);
|
|
115
|
+
} else {
|
|
116
|
+
partial.push(` ⚠ ${proves} — ${flag} is off in ${list(missing)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bounded = result.sites.filter(({ kind }) => kind === 'bounded-fanout');
|
|
121
|
+
const drains = result.sites.filter(({ kind }) => kind === 'in-flight-drain');
|
|
122
|
+
lines.push(' ── Proven by construction ─────────────────────────────────');
|
|
123
|
+
lines.push(...everywhere);
|
|
124
|
+
if (bounded.length > 0) {
|
|
125
|
+
const plural = bounded.length > 1 ? 's' : '';
|
|
126
|
+
lines.push(
|
|
127
|
+
` ✓ ${bounded.length} fan-out${plural} bounded in writing (array literal or a constant written somewhere)`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// Counted and said out loud, like everything this pass sets aside. A silenced drain would
|
|
131
|
+
// read as a site never looked at.
|
|
132
|
+
if (drains.length > 0) {
|
|
133
|
+
const plural = drains.length > 1 ? 's' : '';
|
|
134
|
+
lines.push(` ✓ ${drains.length} wait${plural} on promises already in flight (started elsewhere, opens nothing)`);
|
|
135
|
+
}
|
|
136
|
+
if (everywhere.length === 0 && bounded.length === 0 && drains.length === 0) {
|
|
137
|
+
lines.push(' nothing to declare');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lines.push('', ' ── Guaranteed by nobody ───────────────────────────────────');
|
|
141
|
+
const unread = result.guarantees.length - readable.length;
|
|
142
|
+
if (partial.length > 0 && unread > 0) {
|
|
143
|
+
lines.push(` (reach: ${readable.length} of ${result.guarantees.length} scope(s) — see “Not measured”)`);
|
|
144
|
+
}
|
|
145
|
+
lines.push(...partial);
|
|
146
|
+
for (const { kind, rule, question } of CANDIDATES) {
|
|
147
|
+
const sites = result.sites.filter((site) => site.kind === kind);
|
|
148
|
+
if (sites.length === 0 || kind === UNBOUNDED_PRIMITIVE.kind) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
lines.push('', ` ⚠ ${sites.length} site(s) → ${rule}: ${question}`);
|
|
152
|
+
for (const site of sites.slice(0, MAX_SITES_SHOWN)) {
|
|
153
|
+
lines.push(` ${site.file}:${site.line} ${site.excerpt}`);
|
|
154
|
+
}
|
|
155
|
+
if (sites.length > MAX_SITES_SHOWN) {
|
|
156
|
+
lines.push(` … and ${sites.length - MAX_SITES_SHOWN} more`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Counted and never hidden, exactly as in `report()`: a forgotten acceptance is the blind
|
|
161
|
+
// spot this whole tool exists to refuse.
|
|
162
|
+
if (accepted.length > 0) {
|
|
163
|
+
const plural = accepted.length > 1 ? 's' : '';
|
|
164
|
+
lines.push('', ` · ${accepted.length} site${plural} already answered (see the scan answers file)`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push(...typedNotBounded(result.sites));
|
|
168
|
+
|
|
169
|
+
lines.push('', ' ── Not measured ───────────────────────────────────────────');
|
|
170
|
+
lines.push(' This pass is static: no behaviour was observed.');
|
|
171
|
+
if (result.guarantees.length === 0) {
|
|
172
|
+
lines.push(' No tsconfig found: nothing is said about construction guarantees.');
|
|
173
|
+
}
|
|
174
|
+
const unresolved = result.guarantees.flatMap((scope) =>
|
|
175
|
+
scope.guarantees.kind === 'unresolved' ? [`${scope.path} (${brief(scope.guarantees.reason)})`] : [],
|
|
176
|
+
);
|
|
177
|
+
const unrecognised = result.guarantees.flatMap((scope) =>
|
|
178
|
+
scope.guarantees.kind === 'read' && scope.guarantees.unrecognised.length > 0
|
|
179
|
+
? [`${scope.path} (${scope.guarantees.unrecognised.map((option) => `--${option}`).join(', ')})`]
|
|
180
|
+
: [],
|
|
181
|
+
);
|
|
182
|
+
if (unrecognised.length > 0) {
|
|
183
|
+
lines.push(` ${unrecognised.length} scope(s) with an unrecognised option: ${list(unrecognised)}`);
|
|
184
|
+
lines.push(` speccov reads tsconfig with TypeScript ${tsVersion}. Those values are unknown`);
|
|
185
|
+
lines.push(' to it — newer, most likely. The flags above stay correct: the file was');
|
|
186
|
+
lines.push(' resolved, only the value of those options was not.');
|
|
187
|
+
}
|
|
188
|
+
if (unresolved.length > 0) {
|
|
189
|
+
lines.push(` ${unresolved.length} unresolved tsconfig(s): ${list(unresolved)}`);
|
|
190
|
+
lines.push(' Nothing is concluded for those scopes: an unresolved "extends" makes');
|
|
191
|
+
lines.push(' TypeScript fall back to its defaults in silence, and concluding here');
|
|
192
|
+
lines.push(' would produce false alarms.');
|
|
193
|
+
lines.push(' An "extends" pointing at a package assumes its dependencies are installed:');
|
|
194
|
+
lines.push(' run again after installing to populate "Proven by construction".');
|
|
195
|
+
}
|
|
196
|
+
if (result.refused.length > 0) {
|
|
197
|
+
lines.push(
|
|
198
|
+
` ${result.refused.length} scope(s) whose program could not be built: ${list(
|
|
199
|
+
result.refused.map(({ path, reason }) => `${path} (${brief(reason)})`),
|
|
200
|
+
)}`,
|
|
201
|
+
);
|
|
202
|
+
lines.push(' No site is reported for those scopes. speccov reads types now, and');
|
|
203
|
+
lines.push(' falling back to guessing by method name would reintroduce exactly the');
|
|
204
|
+
lines.push(' false positives the type checker removes — under a look of rigour.');
|
|
205
|
+
}
|
|
206
|
+
lines.push(' The sites above are candidates, not proven defects.');
|
|
207
|
+
for (const [extension, count] of [...result.skipped].sort(([, a], [, b]) => b - a)) {
|
|
208
|
+
lines.push(` ${count} .${extension} file(s) not read: speccov only parses TypeScript.`);
|
|
209
|
+
}
|
|
210
|
+
if (result.testsExcluded > 0) {
|
|
211
|
+
lines.push(` ${result.testsExcluded} test file(s) deliberately excluded.`);
|
|
212
|
+
}
|
|
213
|
+
if (result.ignored.files > 0) {
|
|
214
|
+
lines.push(
|
|
215
|
+
` ${result.ignored.files} file(s) set aside by .speccovignore, ${result.ignored.sites} site(s) with them.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (result.unreadable.length > 0) {
|
|
219
|
+
lines.push(` ${result.unreadable.length} unreadable path(s): ${result.unreadable.slice(0, 3).join(', ')}`);
|
|
220
|
+
}
|
|
221
|
+
lines.push('');
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The parameters whose type states no bound, in a block of their own.
|
|
227
|
+
*
|
|
228
|
+
* The other candidates describe a behaviour: calls that grow with a collection. This one
|
|
229
|
+
* describes a type, and there are many more of them: listed alongside, a few hundred
|
|
230
|
+
* signatures bury the handful of fan-outs. So the block gives the size of the question and
|
|
231
|
+
* where it concentrates, file by file. Nothing is dropped: every site is in the JSON.
|
|
232
|
+
*/
|
|
233
|
+
function typedNotBounded(sites: readonly Site[]): readonly string[] {
|
|
234
|
+
const perFile = new Map<string, number>();
|
|
235
|
+
for (const { kind, file } of sites) {
|
|
236
|
+
if (kind === UNBOUNDED_PRIMITIVE.kind) {
|
|
237
|
+
perFile.set(file, (perFile.get(file) ?? 0) + 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (perFile.size === 0) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const total = [...perFile.values()].reduce((sum, count) => sum + count, 0);
|
|
244
|
+
const ranked = [...perFile].sort(([left, a], [right, b]) => b - a || left.localeCompare(right));
|
|
245
|
+
const lines = [
|
|
246
|
+
'',
|
|
247
|
+
' ── Typed, not bounded ─────────────────────────────────────',
|
|
248
|
+
` ⚠ ${total} parameter(s) in ${perFile.size} file(s) → ${UNBOUNDED_PRIMITIVE.rule}: ${UNBOUNDED_PRIMITIVE.question}`,
|
|
249
|
+
...ranked.slice(0, MAX_SITES_SHOWN).map(([file, count]) => ` ${file} ${count}`),
|
|
250
|
+
];
|
|
251
|
+
if (ranked.length > MAX_SITES_SHOWN) {
|
|
252
|
+
lines.push(` … and ${ranked.length - MAX_SITES_SHOWN} more file(s)`);
|
|
253
|
+
}
|
|
254
|
+
lines.push(' A property of the type, not an observed behaviour. Every site is in the --json output.');
|
|
255
|
+
return lines;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A TypeScript diagnostic can enumerate every value an option admits — thousands of
|
|
260
|
+
* characters to say that one of them is wrong. Dumped as-is, it drowns the rest of the
|
|
261
|
+
* report, and a report nobody can read is no longer of any use for deciding.
|
|
262
|
+
*/
|
|
263
|
+
function brief(reason: string): string {
|
|
264
|
+
const enumeration = reason.indexOf(' must be: ');
|
|
265
|
+
const cut = enumeration === -1 ? reason : `${reason.slice(0, enumeration)} has an unrecognised value`;
|
|
266
|
+
return cut.length <= 120 ? cut : `${cut.slice(0, 119)}…`;
|
|
267
|
+
}
|