@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,219 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the iterated collection comes from.
|
|
5
|
+
*
|
|
6
|
+
* Two origins, and one thing really tells them apart: the one the draft can **feed**. An
|
|
7
|
+
* injected dependency is driven through its double; a parameter of the entry point is driven
|
|
8
|
+
* more simply still — we are the ones passing it.
|
|
9
|
+
*/
|
|
10
|
+
export type CollectionOrigin =
|
|
11
|
+
| {
|
|
12
|
+
readonly kind: 'dependency';
|
|
13
|
+
/** The injected dependency that produces the collection, e.g. `users`. */
|
|
14
|
+
readonly property: string;
|
|
15
|
+
/** The method called on it, e.g. `list`. */
|
|
16
|
+
readonly method: string;
|
|
17
|
+
/** The path read on its result, e.g. `['items']`. Empty when the collection is returned as is. */
|
|
18
|
+
readonly path: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
/** The collection is an argument of the entry point: the draft passes it itself. */
|
|
22
|
+
readonly kind: 'argument';
|
|
23
|
+
readonly index: number;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type TraceScope = {
|
|
28
|
+
/** What contains the site: the class, or the file when the function is free-standing. */
|
|
29
|
+
readonly container: ts.ClassDeclaration | ts.SourceFile;
|
|
30
|
+
/** What the draft calls. Its parameters are under our direct control. */
|
|
31
|
+
readonly entry: ts.SignatureDeclarationBase;
|
|
32
|
+
readonly owned: ReadonlySet<string>;
|
|
33
|
+
readonly parsed: ts.SourceFile;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The calls that do not change **where the collection comes from**.
|
|
38
|
+
*
|
|
39
|
+
* `concat` and `flat` are excluded, for a reason different from the one that keeps them out of
|
|
40
|
+
* the width computation in `shapes.ts`: here they have **two origins**, not one.
|
|
41
|
+
* `a.concat(b)` would trace back to `a` and say nothing of `b`; the draft would then feed only
|
|
42
|
+
* one of the two sources and measure a fan-out over a half-empty collection — a green light
|
|
43
|
+
* obtained on the wrong thing. Two origins is a refusal, never a choice.
|
|
44
|
+
*
|
|
45
|
+
* `sort` and `reverse` stay: they reorder a single source, they do not add to it.
|
|
46
|
+
*/
|
|
47
|
+
const PASS_THROUGH = new Set(['filter', 'map', 'slice', 'sort', 'reverse']);
|
|
48
|
+
|
|
49
|
+
/** Safety bound: a round trip between two methods must not loop forever. */
|
|
50
|
+
const MAX_HOPS = 24;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Traces the iterated collection back to what produces it.
|
|
54
|
+
*
|
|
55
|
+
* The site says *who* is called per item; it does not say *where the items come from*.
|
|
56
|
+
* Without this trace, the draft does not know what to feed, and the size never reaches the
|
|
57
|
+
* subject — the measurement is then flat and the rule lies.
|
|
58
|
+
*
|
|
59
|
+
* Every hop is a local declaration, a call argument or a property access: AST reading, never a
|
|
60
|
+
* guess. What cannot be traced back returns `undefined`, and the hole stays written in the
|
|
61
|
+
* draft.
|
|
62
|
+
*/
|
|
63
|
+
export function traceCollection(collection: ts.Expression, scope: TraceScope): CollectionOrigin | undefined {
|
|
64
|
+
const path: string[] = [];
|
|
65
|
+
let current: ts.Expression | undefined = collection;
|
|
66
|
+
|
|
67
|
+
for (let hop = 0; hop < MAX_HOPS && current !== undefined; hop += 1) {
|
|
68
|
+
const node: ts.Expression = current;
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
ts.isAwaitExpression(node) ||
|
|
72
|
+
ts.isParenthesizedExpression(node) ||
|
|
73
|
+
ts.isNonNullExpression(node) ||
|
|
74
|
+
ts.isAsExpression(node)
|
|
75
|
+
) {
|
|
76
|
+
current = node.expression;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `this.users.list(…)`: the source, if `users` really is an injected dependency.
|
|
81
|
+
const source = injectedCall(node, scope.owned);
|
|
82
|
+
if (source !== undefined) {
|
|
83
|
+
return { kind: 'dependency', ...source, path: [...path].reverse() };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
ts.isCallExpression(node) &&
|
|
88
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
89
|
+
PASS_THROUGH.has(node.expression.name.text)
|
|
90
|
+
) {
|
|
91
|
+
current = node.expression.expression;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `page.items`: note the property and carry on with `page`.
|
|
96
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
97
|
+
path.push(node.name.text);
|
|
98
|
+
current = node.expression;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ts.isIdentifier(node)) {
|
|
103
|
+
// A parameter of the entry point is the safest origin there is: the draft has
|
|
104
|
+
// nothing to double, it passes the collection itself. We require it returned as is —
|
|
105
|
+
// rebuilding a carrier object would invent its other fields.
|
|
106
|
+
const index = parameterIndex(scope.entry, node.text);
|
|
107
|
+
if (index !== -1 && path.length === 0 && enclosingCallable(node) === scope.entry) {
|
|
108
|
+
return { kind: 'argument', index, name: node.text };
|
|
109
|
+
}
|
|
110
|
+
current = boundTo(node, scope);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `this.<dependency>.<method>(…)`, when the dependency comes from the constructor. */
|
|
120
|
+
function injectedCall(node: ts.Expression, owned: ReadonlySet<string>): { property: string; method: string } | undefined {
|
|
121
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const target = node.expression.expression;
|
|
125
|
+
if (!ts.isPropertyAccessExpression(target) || target.expression.kind !== ts.SyntaxKind.ThisKeyword) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const property = target.name.text;
|
|
129
|
+
return owned.has(property) ? { property, method: node.expression.name.text } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parameterIndex(callable: ts.SignatureDeclarationBase, name: string): number {
|
|
133
|
+
return callable.parameters.findIndex((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What an identifier is bound to: the initialiser of its `const`, or — when it is a parameter —
|
|
138
|
+
* the argument passed at the single call to its method.
|
|
139
|
+
*
|
|
140
|
+
* "Single" is not a detail. Two callers pass two different collections; picking one would amount
|
|
141
|
+
* to measuring one path while believing another is measured.
|
|
142
|
+
*/
|
|
143
|
+
function boundTo(name: ts.Identifier, scope: TraceScope): ts.Expression | undefined {
|
|
144
|
+
const callable = enclosingCallable(name);
|
|
145
|
+
if (callable === undefined) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const local = localDeclaration(callable, name.text);
|
|
150
|
+
if (local !== undefined) {
|
|
151
|
+
return local;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const index = parameterIndex(callable, name.text);
|
|
155
|
+
const owner = ts.isMethodDeclaration(callable) || ts.isFunctionDeclaration(callable) ? callable.name : undefined;
|
|
156
|
+
if (index === -1 || owner === undefined) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const calls = callsTo(scope.container, owner.getText(scope.parsed), scope.parsed);
|
|
160
|
+
return calls.length === 1 ? calls[0]?.arguments[index] : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function localDeclaration(callable: ts.SignatureDeclarationBase, name: string): ts.Expression | undefined {
|
|
164
|
+
let found: ts.Expression | undefined;
|
|
165
|
+
const visit = (node: ts.Node): void => {
|
|
166
|
+
if (
|
|
167
|
+
found === undefined &&
|
|
168
|
+
ts.isVariableDeclaration(node) &&
|
|
169
|
+
ts.isIdentifier(node.name) &&
|
|
170
|
+
node.name.text === name &&
|
|
171
|
+
node.initializer !== undefined
|
|
172
|
+
) {
|
|
173
|
+
found = node.initializer;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
ts.forEachChild(node, visit);
|
|
177
|
+
};
|
|
178
|
+
const { body } = callable as ts.SignatureDeclarationBase & { body?: ts.Node };
|
|
179
|
+
if (body !== undefined) {
|
|
180
|
+
visit(body);
|
|
181
|
+
}
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Every call to `<name>` in the container: `this.<name>(…)` or `<name>(…)`. */
|
|
186
|
+
function callsTo(container: ts.ClassDeclaration | ts.SourceFile, name: string, parsed: ts.SourceFile): ts.CallExpression[] {
|
|
187
|
+
const calls: ts.CallExpression[] = [];
|
|
188
|
+
const visit = (node: ts.Node): void => {
|
|
189
|
+
if (ts.isCallExpression(node)) {
|
|
190
|
+
const callee = node.expression;
|
|
191
|
+
const onThis =
|
|
192
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
193
|
+
callee.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
194
|
+
callee.name.getText(parsed) === name;
|
|
195
|
+
if (onThis || (ts.isIdentifier(callee) && callee.text === name)) {
|
|
196
|
+
calls.push(node);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
ts.forEachChild(node, visit);
|
|
200
|
+
};
|
|
201
|
+
visit(container);
|
|
202
|
+
return calls;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function enclosingCallable(node: ts.Node): ts.SignatureDeclarationBase | undefined {
|
|
206
|
+
let current: ts.Node | undefined = node.parent;
|
|
207
|
+
while (current !== undefined) {
|
|
208
|
+
if (
|
|
209
|
+
ts.isMethodDeclaration(current) ||
|
|
210
|
+
ts.isFunctionDeclaration(current) ||
|
|
211
|
+
ts.isArrowFunction(current) ||
|
|
212
|
+
ts.isFunctionExpression(current)
|
|
213
|
+
) {
|
|
214
|
+
return current;
|
|
215
|
+
}
|
|
216
|
+
current = current.parent;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
package/src/forbidden.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { requireIntention, type Judgement, type Measurement, type Origin, type Rule } from './rule';
|
|
2
|
+
|
|
3
|
+
export type ForbiddenOptions<T> = {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Mandatory: a ban is domain knowledge, no default sentence can state it. */
|
|
6
|
+
readonly intention: string;
|
|
7
|
+
/** The inputs to exercise. Hand-written list, fast-check, faker — take your pick. */
|
|
8
|
+
readonly cases: Iterable<T> | (() => Iterable<T>);
|
|
9
|
+
/** True when the input produces what is forbidden. */
|
|
10
|
+
readonly forbids: (value: T) => Promise<boolean> | boolean;
|
|
11
|
+
/** How to name a counterexample in the report. */
|
|
12
|
+
readonly describe?: (value: T) => string;
|
|
13
|
+
readonly origin?: Origin;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ban rule: no input must ever produce this behaviour.
|
|
18
|
+
*
|
|
19
|
+
* It is the most demanding kind of rule, because it quantifies over the whole input space
|
|
20
|
+
* instead of illustrating a few happy cases.
|
|
21
|
+
*/
|
|
22
|
+
export function forbidden<T>(options: ForbiddenOptions<T>): Rule {
|
|
23
|
+
const intention = requireIntention(options.name, options.intention);
|
|
24
|
+
const describe = options.describe ?? ((value: T) => JSON.stringify(value));
|
|
25
|
+
|
|
26
|
+
/** Filled by `measure()`, read by `judge()`: `run()` guarantees that order. */
|
|
27
|
+
let firstCounterexample: string | undefined;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name: options.name,
|
|
31
|
+
intention,
|
|
32
|
+
origin: options.origin ?? 'declared',
|
|
33
|
+
|
|
34
|
+
async measure(): Promise<readonly Measurement[]> {
|
|
35
|
+
firstCounterexample = undefined;
|
|
36
|
+
const cases = typeof options.cases === 'function' ? options.cases() : options.cases;
|
|
37
|
+
let tried = 0;
|
|
38
|
+
let violations = 0;
|
|
39
|
+
|
|
40
|
+
for (const value of cases) {
|
|
41
|
+
tried += 1;
|
|
42
|
+
if (await options.forbids(value)) {
|
|
43
|
+
violations += 1;
|
|
44
|
+
firstCounterexample ??= describe(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (tried === 0) {
|
|
48
|
+
throw new Error(`speccov: “${options.name}” tried no input. A ban without cases proves nothing.`);
|
|
49
|
+
}
|
|
50
|
+
return [{ size: tried, cost: violations }];
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
judge(measurements): Judgement {
|
|
54
|
+
const [measurement] = measurements;
|
|
55
|
+
if (measurement === undefined) {
|
|
56
|
+
throw new Error(`speccov: no measurement for “${options.name}”.`);
|
|
57
|
+
}
|
|
58
|
+
const { size, cost } = measurement;
|
|
59
|
+
// `measure()` already refuses zero input: the predicate necessarily judged something real.
|
|
60
|
+
const exercised = size > 0;
|
|
61
|
+
if (cost === 0) {
|
|
62
|
+
return {
|
|
63
|
+
held: true,
|
|
64
|
+
exercised,
|
|
65
|
+
magnitude: 0,
|
|
66
|
+
detail: `No counterexample across ${size} input(s) tried`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
held: false,
|
|
71
|
+
exercised,
|
|
72
|
+
magnitude: cost,
|
|
73
|
+
detail: `${cost} counterexample(s) across ${size} input(s), including ${firstCounterexample ?? 'unknown'}`,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/gauge.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency probe: tracks the number of operations in flight and keeps the peak.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart of {@link Counter}: one counts the total, the other measures the peak.
|
|
5
|
+
*/
|
|
6
|
+
export class Gauge {
|
|
7
|
+
#current = 0;
|
|
8
|
+
#peak = 0;
|
|
9
|
+
|
|
10
|
+
enter(): void {
|
|
11
|
+
this.#current += 1;
|
|
12
|
+
this.#peak = Math.max(this.#peak, this.#current);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One `exit()` too many throws rather than dropping below zero: an unbalanced probe
|
|
17
|
+
* underestimates the peak, so it would let through a rule that does not hold.
|
|
18
|
+
*/
|
|
19
|
+
exit(): void {
|
|
20
|
+
if (this.#current === 0) {
|
|
21
|
+
throw new Error('speccov: exit() without a matching enter(); the concurrency probe is unbalanced.');
|
|
22
|
+
}
|
|
23
|
+
this.#current -= 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Wraps a piece of asynchronous work. The `exit()` happens even on failure. */
|
|
27
|
+
async track<T>(work: () => Promise<T>): Promise<T> {
|
|
28
|
+
this.enter();
|
|
29
|
+
try {
|
|
30
|
+
return await work();
|
|
31
|
+
} finally {
|
|
32
|
+
this.exit();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get current(): number {
|
|
37
|
+
return this.#current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get peak(): number {
|
|
41
|
+
return this.#peak;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
reset(): void {
|
|
45
|
+
this.#current = 0;
|
|
46
|
+
this.#peak = 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/growth.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Measurement } from './rule';
|
|
2
|
+
|
|
3
|
+
export type Growth = {
|
|
4
|
+
/** Extra cost per input item, estimated by least squares. */
|
|
5
|
+
readonly perItem: number;
|
|
6
|
+
/** The measured series, ready to display: "1→3, 2→4, 4→6". */
|
|
7
|
+
readonly series: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Float margin. Costs are integers: a genuinely flat slope is 0. */
|
|
11
|
+
export const EPSILON = 1e-9;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Estimates how the cost grows with the input size.
|
|
15
|
+
*
|
|
16
|
+
* Least squares rather than a gap between the extremes: a single noisy measurement must not
|
|
17
|
+
* decide on its own. Any unusable data throws — a tool you trust never concludes "held" for
|
|
18
|
+
* want of a measurement.
|
|
19
|
+
*/
|
|
20
|
+
export function measureGrowth(measurements: readonly Measurement[]): Growth {
|
|
21
|
+
assertUsable(measurements);
|
|
22
|
+
|
|
23
|
+
const meanSize = mean(measurements.map(({ size }) => size));
|
|
24
|
+
const meanCost = mean(measurements.map(({ cost }) => cost));
|
|
25
|
+
|
|
26
|
+
let covariance = 0;
|
|
27
|
+
let variance = 0;
|
|
28
|
+
for (const { size, cost } of measurements) {
|
|
29
|
+
const deltaSize = size - meanSize;
|
|
30
|
+
covariance += deltaSize * (cost - meanCost);
|
|
31
|
+
variance += deltaSize * deltaSize;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (variance <= EPSILON) {
|
|
35
|
+
throw new Error('speccov: every measurement carries the same input size; growth is undecidable.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
perItem: covariance / variance,
|
|
40
|
+
series: measurements.map(({ size, cost }) => `${size}→${cost}`).join(', '),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertUsable(measurements: readonly Measurement[]): void {
|
|
45
|
+
if (measurements.length < 2) {
|
|
46
|
+
throw new Error(`speccov: judging growth needs at least two measurements, got ${measurements.length}.`);
|
|
47
|
+
}
|
|
48
|
+
for (const { size, cost } of measurements) {
|
|
49
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
50
|
+
throw new Error(`speccov: invalid input size (${size}), expected a finite number greater than zero.`);
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isFinite(cost) || cost < 0) {
|
|
53
|
+
throw new Error(`speccov: invalid cost (${cost}), expected a finite number of zero or more.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mean(values: readonly number[]): number {
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (const value of values) {
|
|
61
|
+
total += value;
|
|
62
|
+
}
|
|
63
|
+
return total / values.length;
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
accept,
|
|
3
|
+
applyAnswers,
|
|
4
|
+
DEFAULT_ANSWERS_PATH,
|
|
5
|
+
loadAnswers,
|
|
6
|
+
parseAnswers,
|
|
7
|
+
saveAnswers,
|
|
8
|
+
type Answer,
|
|
9
|
+
type Applied,
|
|
10
|
+
} from './answers';
|
|
11
|
+
export { complexity, type ComplexityOptions } from './complexity';
|
|
12
|
+
export { Counter } from './counter';
|
|
13
|
+
export { allOf, type Domain } from './domain';
|
|
14
|
+
export { forbidden, type ForbiddenOptions } from './forbidden';
|
|
15
|
+
export { Gauge } from './gauge';
|
|
16
|
+
export { metamorphic, type MetamorphicOptions } from './metamorphic';
|
|
17
|
+
export { resource, type ResourceOptions } from './resource';
|
|
18
|
+
export { measureGrowth, type Growth } from './growth';
|
|
19
|
+
export { exitCode, report } from './report';
|
|
20
|
+
export type { Held, Infraction, Judgement, Lacuna, Measurement, Origin, Rule, Verdict } from './rule';
|
|
21
|
+
export { run } from './run';
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { MutateResult } from './mutate/run';
|
|
2
|
+
import { measurementWindow } from './report';
|
|
3
|
+
import type { Verdict } from './rule';
|
|
4
|
+
import { CANDIDATES, flagCoverage, type ScanResult } from './scan/report';
|
|
5
|
+
import type { Site } from './scan/shapes';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Machine-readable output.
|
|
9
|
+
*
|
|
10
|
+
* This is the real product: speccov's thesis is that an agent reads the verdict and acts.
|
|
11
|
+
* Making it parse prose wrapped at 88 columns would ask it to guess — exactly the brittle
|
|
12
|
+
* thing speccov exists to replace.
|
|
13
|
+
*
|
|
14
|
+
* The schema carries the same refusals as the prose. An unresolved scope shows up here too:
|
|
15
|
+
* without it, the absence of an alert would read as a green light, which is the one lie
|
|
16
|
+
* this tool cannot afford.
|
|
17
|
+
*/
|
|
18
|
+
const SCHEMA = 1;
|
|
19
|
+
|
|
20
|
+
export function scanToJson(result: ScanResult, accepted: readonly Site[] = []): string {
|
|
21
|
+
const coverage = flagCoverage(result);
|
|
22
|
+
const [scopesEvaluated = 0] = coverage.map(({ scopesEvaluated: n }) => n);
|
|
23
|
+
|
|
24
|
+
return stringify({
|
|
25
|
+
speccov: SCHEMA,
|
|
26
|
+
command: 'scan',
|
|
27
|
+
filesScanned: result.filesScanned,
|
|
28
|
+
scopes: { evaluated: scopesEvaluated, found: result.guarantees.length },
|
|
29
|
+
provenByConstruction: {
|
|
30
|
+
flags: coverage.filter(({ offIn }) => offIn.length === 0).map(({ flag, proves }) => ({ flag, proves })),
|
|
31
|
+
boundedFanOut: result.sites.filter(({ kind }) => kind === 'bounded-fanout').length,
|
|
32
|
+
awaitedInFlight: result.sites.filter(({ kind }) => kind === 'in-flight-drain').length,
|
|
33
|
+
},
|
|
34
|
+
alreadyAnswered: {
|
|
35
|
+
count: accepted.length,
|
|
36
|
+
sites: accepted.map(({ file, line, excerpt }) => ({ file, line, excerpt })),
|
|
37
|
+
},
|
|
38
|
+
guaranteedByNobody: {
|
|
39
|
+
flags: coverage.filter(({ offIn }) => offIn.length > 0).map(({ flag, proves, offIn }) => ({ flag, proves, offIn })),
|
|
40
|
+
candidates: CANDIDATES.flatMap(({ kind, rule, question }) => {
|
|
41
|
+
const sites = result.sites.filter((site) => site.kind === kind);
|
|
42
|
+
return sites.length === 0
|
|
43
|
+
? []
|
|
44
|
+
: [{ rule, question, sites: sites.map(({ file, line, excerpt }) => ({ file, line, excerpt })) }];
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
notMeasured: {
|
|
48
|
+
behaviour: 'this pass is static; nothing was executed',
|
|
49
|
+
// Two distinct failures stop a scope from being read: the refused program
|
|
50
|
+
// (`program.ts`) and the unresolved tsconfig (`guarantees.ts`). Looking at only one
|
|
51
|
+
// would announce "types resolved" next to a full `unresolvedScopes` — a green light.
|
|
52
|
+
typesResolved:
|
|
53
|
+
result.refused.length === 0 && result.guarantees.every(({ guarantees }) => guarantees.kind !== 'unresolved'),
|
|
54
|
+
unresolvedScopes: result.guarantees.flatMap((scope) =>
|
|
55
|
+
scope.guarantees.kind === 'unresolved' ? [{ path: scope.path, reason: scope.guarantees.reason }] : [],
|
|
56
|
+
),
|
|
57
|
+
unrecognisedOptions: result.guarantees.flatMap((scope) =>
|
|
58
|
+
scope.guarantees.kind === 'read' && scope.guarantees.unrecognised.length > 0
|
|
59
|
+
? [{ path: scope.path, options: [...scope.guarantees.unrecognised] }]
|
|
60
|
+
: [],
|
|
61
|
+
),
|
|
62
|
+
filesNotParsed: Object.fromEntries(result.skipped),
|
|
63
|
+
testsExcluded: result.testsExcluded,
|
|
64
|
+
ignored: { files: result.ignored.files, sites: result.ignored.sites },
|
|
65
|
+
unreadable: [...result.unreadable],
|
|
66
|
+
refusedScopes: result.refused.map(({ path, reason }) => ({ path, reason })),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function verdictsToJson(verdicts: readonly Verdict[], accepted: readonly Verdict[], exitCode: number): string {
|
|
72
|
+
const window = measurementWindow(verdicts);
|
|
73
|
+
|
|
74
|
+
return stringify({
|
|
75
|
+
speccov: SCHEMA,
|
|
76
|
+
command: 'rules',
|
|
77
|
+
exitCode,
|
|
78
|
+
verdicts: verdicts.map((verdict) => ({
|
|
79
|
+
kind: verdict.kind,
|
|
80
|
+
rule: verdict.rule.name,
|
|
81
|
+
intention: verdict.rule.intention,
|
|
82
|
+
origin: verdict.rule.origin,
|
|
83
|
+
detail: verdict.detail,
|
|
84
|
+
magnitude: verdict.magnitude,
|
|
85
|
+
...('question' in verdict ? { question: verdict.question } : {}),
|
|
86
|
+
measurements: verdict.measurements.map(({ size, cost }) => ({ size, cost })),
|
|
87
|
+
})),
|
|
88
|
+
alreadyAccepted: accepted.map((verdict) => verdict.rule.name),
|
|
89
|
+
notMeasured: {
|
|
90
|
+
inputSizesTried: window.sizes,
|
|
91
|
+
largestSizeTried: window.largest,
|
|
92
|
+
rulesWithoutMeasurement: window.unobserved,
|
|
93
|
+
dataShape: 'the rule file builds its own data; no production shape was observed',
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function mutantsToJson(result: MutateResult, exitCode: number): string {
|
|
99
|
+
const survivors = result.outcomes.filter(({ fate }) => fate === 'survived');
|
|
100
|
+
|
|
101
|
+
return stringify({
|
|
102
|
+
speccov: SCHEMA,
|
|
103
|
+
command: 'mutate',
|
|
104
|
+
exitCode,
|
|
105
|
+
tried: result.outcomes.length,
|
|
106
|
+
killed: result.outcomes.length - survivors.length,
|
|
107
|
+
survivors: survivors.map(({ mutant }) => ({
|
|
108
|
+
file: mutant.file,
|
|
109
|
+
line: mutant.line,
|
|
110
|
+
mutator: mutant.mutator,
|
|
111
|
+
original: mutant.original,
|
|
112
|
+
replacement: mutant.replacement,
|
|
113
|
+
})),
|
|
114
|
+
notMeasured: { generatedButNotTried: result.skipped },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Indented: a human sometimes rereads what their agent read, and a CI diff has to stay legible. */
|
|
119
|
+
function stringify(payload: unknown): string {
|
|
120
|
+
return JSON.stringify(payload, undefined, 2);
|
|
121
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { requireIntention, type Judgement, type Measurement, type Origin, type Rule } from './rule';
|
|
2
|
+
|
|
3
|
+
export type MetamorphicOptions<V, O> = {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Mandatory: the expected relation cannot be guessed. */
|
|
6
|
+
readonly intention: string;
|
|
7
|
+
/** The variants to compare. At least two — comparing a thing to nothing proves nothing. */
|
|
8
|
+
readonly variants: readonly V[];
|
|
9
|
+
/** What is observed, and what must stay identical from one variant to the next. */
|
|
10
|
+
readonly observe: (variant: V) => Promise<O> | O;
|
|
11
|
+
readonly describe?: (variant: V) => string;
|
|
12
|
+
readonly origin?: Origin;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Metamorphic rule: one observation stays invariant from one variant to the next.
|
|
17
|
+
*
|
|
18
|
+
* It does not say which value is the right one — often we do not know. It only says the
|
|
19
|
+
* value must not change. This is what catches a `total` that follows the page instead of
|
|
20
|
+
* the full result set.
|
|
21
|
+
*/
|
|
22
|
+
export function metamorphic<V, O>(options: MetamorphicOptions<V, O>): Rule {
|
|
23
|
+
const intention = requireIntention(options.name, options.intention);
|
|
24
|
+
const describe = options.describe ?? ((variant: V) => JSON.stringify(variant));
|
|
25
|
+
|
|
26
|
+
if (options.variants.length < 2) {
|
|
27
|
+
throw new Error(`speccov: “${options.name}” needs at least two variants, got ${options.variants.length}.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Filled by `measure()`, read by `judge()`. */
|
|
31
|
+
let observed: readonly string[] = [];
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
name: options.name,
|
|
35
|
+
intention,
|
|
36
|
+
origin: options.origin ?? 'declared',
|
|
37
|
+
|
|
38
|
+
async measure(): Promise<readonly Measurement[]> {
|
|
39
|
+
const seen: string[] = [];
|
|
40
|
+
for (const variant of options.variants) {
|
|
41
|
+
seen.push(`${describe(variant)} → ${JSON.stringify(await options.observe(variant))}`);
|
|
42
|
+
}
|
|
43
|
+
observed = seen;
|
|
44
|
+
const distinct = new Set(seen.map((entry) => entry.split(' → ').at(-1))).size;
|
|
45
|
+
return [{ size: options.variants.length, cost: distinct }];
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
judge(measurements): Judgement {
|
|
49
|
+
const [measurement] = measurements;
|
|
50
|
+
if (measurement === undefined) {
|
|
51
|
+
throw new Error(`speccov: no measurement for “${options.name}”.`);
|
|
52
|
+
}
|
|
53
|
+
const { size, cost } = measurement;
|
|
54
|
+
// The constructor already refuses fewer than two variants: an invariance has
|
|
55
|
+
// always had something to compare against. The hole is closed upstream, not here.
|
|
56
|
+
const exercised = true;
|
|
57
|
+
if (cost <= 1) {
|
|
58
|
+
return { held: true, exercised, magnitude: 0, detail: `Same observation across ${size} variants` };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
held: false,
|
|
62
|
+
exercised,
|
|
63
|
+
magnitude: cost - 1,
|
|
64
|
+
detail: `${cost} different values across ${size} variants: ${observed.join(' ; ')}`,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|