@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,138 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
export type DomainField = {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** The values, already rendered as TypeScript source. */
|
|
6
|
+
readonly values: readonly string[];
|
|
7
|
+
/** The declared type, as it is written. Used in the message when a value is needed. */
|
|
8
|
+
readonly declared: string;
|
|
9
|
+
/** False when part of the type is infinite: a `string`, a `number`, an object. */
|
|
10
|
+
readonly finite: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The boundary values the type allows and that nobody writes by hand. Empty on a finite
|
|
13
|
+
* field, where the compiler already wrote every case.
|
|
14
|
+
*/
|
|
15
|
+
readonly edges: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The boundary values of a primitive.
|
|
20
|
+
*
|
|
21
|
+
* They are mechanical: no business knowledge says that an empty string or a `NaN` deserves a
|
|
22
|
+
* look, only the type does. That is what separates them from the value a human must still
|
|
23
|
+
* provide, which stays a `missing()`.
|
|
24
|
+
*
|
|
25
|
+
* The lists are deliberately short. `allOf()` multiplies the fields, so each value added here
|
|
26
|
+
* widens the whole product. Four per type is what earns its place.
|
|
27
|
+
*/
|
|
28
|
+
const STRING_EDGES: readonly string[] = ["''", "' '", "'\\u0301'", "'x'.repeat(10_000)"];
|
|
29
|
+
const NUMBER_EDGES: readonly string[] = ['0', '-1', 'Number.NaN', 'Number.MAX_SAFE_INTEGER'];
|
|
30
|
+
|
|
31
|
+
export type DomainRead =
|
|
32
|
+
| { readonly kind: 'read'; readonly typeName: string; readonly fields: readonly DomainField[] }
|
|
33
|
+
| { readonly kind: 'refused'; readonly reason: string };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the domain of a type, field by field.
|
|
37
|
+
*
|
|
38
|
+
* A union type is finite and can be written out: `'viewer' | 'org_admin'` makes two cases,
|
|
39
|
+
* `X | null` makes two as well. That is where the automation lives — not at run time, where
|
|
40
|
+
* the type is gone, but when writing the draft, where the compiler is there.
|
|
41
|
+
*
|
|
42
|
+
* What is not finite is **written anyway**, as a call that throws. A `string` has no
|
|
43
|
+
* interesting value the type knows about: "an area" and "no area" is a business
|
|
44
|
+
* distinction. We leave it to a human, by naming it.
|
|
45
|
+
*/
|
|
46
|
+
export function readDomain(file: string, typeName: string): DomainRead {
|
|
47
|
+
const program = ts.createProgram([file], {
|
|
48
|
+
target: ts.ScriptTarget.Latest,
|
|
49
|
+
strict: true,
|
|
50
|
+
noEmit: true,
|
|
51
|
+
skipLibCheck: true,
|
|
52
|
+
});
|
|
53
|
+
const source = program.getSourceFile(file);
|
|
54
|
+
if (source === undefined) {
|
|
55
|
+
return { kind: 'refused', reason: `${file} could not be read.` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const checker = program.getTypeChecker();
|
|
59
|
+
const declaration = findType(source, typeName);
|
|
60
|
+
if (declaration === undefined) {
|
|
61
|
+
return { kind: 'refused', reason: `no type "${typeName}" in ${file}.` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const type = checker.getTypeAtLocation(declaration);
|
|
65
|
+
const properties = checker.getPropertiesOfType(type);
|
|
66
|
+
if (properties.length === 0) {
|
|
67
|
+
return { kind: 'refused', reason: `"${typeName}" has no fields: there is no domain to produce.` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fields = properties.map((property) => field(property, declaration, checker));
|
|
71
|
+
return { kind: 'read', typeName, fields };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function field(property: ts.Symbol, at: ts.Node, checker: ts.TypeChecker): DomainField {
|
|
75
|
+
const type = checker.getTypeOfSymbolAtLocation(property, at);
|
|
76
|
+
const declared = checker.typeToString(type);
|
|
77
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
78
|
+
|
|
79
|
+
const values: string[] = [];
|
|
80
|
+
const edges = new Set<string>();
|
|
81
|
+
let finite = true;
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
const rendered = literalOf(part);
|
|
84
|
+
if (rendered === undefined) {
|
|
85
|
+
finite = false;
|
|
86
|
+
for (const edge of edgesOf(part)) {
|
|
87
|
+
edges.add(edge);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
values.push(rendered);
|
|
92
|
+
}
|
|
93
|
+
return { name: property.name, values, declared, finite, edges: [...edges] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The boundary values of an infinite part, or nothing when the type is not a known primitive. */
|
|
97
|
+
function edgesOf(type: ts.Type): readonly string[] {
|
|
98
|
+
if ((type.flags & ts.TypeFlags.String) !== 0) {
|
|
99
|
+
return STRING_EDGES;
|
|
100
|
+
}
|
|
101
|
+
if ((type.flags & ts.TypeFlags.Number) !== 0) {
|
|
102
|
+
return NUMBER_EDGES;
|
|
103
|
+
}
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The value written as source, or `undefined` when this member is not a literal. */
|
|
108
|
+
function literalOf(type: ts.Type): string | undefined {
|
|
109
|
+
if (type.isStringLiteral()) {
|
|
110
|
+
return JSON.stringify(type.value);
|
|
111
|
+
}
|
|
112
|
+
if (type.isNumberLiteral()) {
|
|
113
|
+
return String(type.value);
|
|
114
|
+
}
|
|
115
|
+
if ((type.flags & ts.TypeFlags.Null) !== 0) {
|
|
116
|
+
return 'null';
|
|
117
|
+
}
|
|
118
|
+
if ((type.flags & ts.TypeFlags.Undefined) !== 0) {
|
|
119
|
+
return 'undefined';
|
|
120
|
+
}
|
|
121
|
+
if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0) {
|
|
122
|
+
// `intrinsicName` is the only public read that tells `true` from `false`.
|
|
123
|
+
return (type as ts.Type & { intrinsicName?: string }).intrinsicName === 'true' ? 'true' : 'false';
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findType(source: ts.SourceFile, name: string): ts.InterfaceDeclaration | ts.TypeAliasDeclaration | undefined {
|
|
129
|
+
let found: ts.InterfaceDeclaration | ts.TypeAliasDeclaration | undefined;
|
|
130
|
+
const visit = (node: ts.Node): void => {
|
|
131
|
+
if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && node.name.text === name) {
|
|
132
|
+
found ??= node;
|
|
133
|
+
}
|
|
134
|
+
ts.forEachChild(node, visit);
|
|
135
|
+
};
|
|
136
|
+
visit(source);
|
|
137
|
+
return found;
|
|
138
|
+
}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { traceCollection, type CollectionOrigin } from './trace';
|
|
3
|
+
|
|
4
|
+
export type Parameter = {
|
|
5
|
+
/** Empty when the parameter is destructured: it has no name to write. */
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly type: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the draft calls to reach the site.
|
|
12
|
+
*
|
|
13
|
+
* A class with injected dependencies is just one of the shapes TypeScript takes. An exported
|
|
14
|
+
* function receives its dependencies as parameters: there is nothing to instantiate, and the
|
|
15
|
+
* subject calls it directly. Telling the two apart here spares the rendering from assuming.
|
|
16
|
+
*/
|
|
17
|
+
export type Subject =
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'class';
|
|
20
|
+
readonly className: string;
|
|
21
|
+
readonly methodName: string;
|
|
22
|
+
readonly constructorParameters: readonly Parameter[];
|
|
23
|
+
readonly entryParameters: readonly Parameter[];
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
readonly kind: 'function';
|
|
27
|
+
readonly functionName: string;
|
|
28
|
+
readonly entryParameters: readonly Parameter[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What is counted per item, and above all **who carries it**.
|
|
33
|
+
*
|
|
34
|
+
* The carrier decides how to observe, and each way costs a different amount of honesty:
|
|
35
|
+
* an injected dependency is swapped for a double, an own method is **wrapped** without
|
|
36
|
+
* being replaced, an item of the collection is built by the draft itself.
|
|
37
|
+
*/
|
|
38
|
+
export type Probe =
|
|
39
|
+
| { readonly kind: 'injected'; readonly property: string; readonly method: string }
|
|
40
|
+
| { readonly kind: 'own'; readonly method: string }
|
|
41
|
+
| { readonly kind: 'item'; readonly method: string }
|
|
42
|
+
| { readonly kind: 'parameter'; readonly parameter: string; readonly method: string };
|
|
43
|
+
|
|
44
|
+
export type DraftSite = {
|
|
45
|
+
readonly file: string;
|
|
46
|
+
readonly line: number;
|
|
47
|
+
readonly subject: Subject;
|
|
48
|
+
readonly probe: Probe;
|
|
49
|
+
/** The expression being iterated, e.g. `candidatesWithOrganization`. */
|
|
50
|
+
readonly collection: string;
|
|
51
|
+
/** Where that collection comes from, when tracing back lands. */
|
|
52
|
+
readonly origin: CollectionOrigin | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* What the site calls, and therefore the rule to write. A `.map()` wrapped in a
|
|
55
|
+
* `Promise.all` fires everything at once: the question is the ceiling, not the total —
|
|
56
|
+
* so `resource()` and its `Gauge`. A bare `.map()` calls in series: that is `complexity()`.
|
|
57
|
+
*/
|
|
58
|
+
readonly shape: 'unbounded-fanout' | 'async-per-item';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A refusal is not a failure: it is the expected behavior when several reads share the same
|
|
63
|
+
* `.map()`. Guessing which one to count would produce a subject that measures the wrong
|
|
64
|
+
* thing — a reassuring green light that lies, exactly what this fights.
|
|
65
|
+
*/
|
|
66
|
+
export type Extraction =
|
|
67
|
+
{ readonly kind: 'drafted'; readonly site: DraftSite } | { readonly kind: 'refused'; readonly reason: string };
|
|
68
|
+
|
|
69
|
+
const READ_VERBS = /^(find|get|load|fetch|read|list|search|count|exists)/u;
|
|
70
|
+
|
|
71
|
+
export function extractSite(file: string, source: string, line: number): Extraction {
|
|
72
|
+
const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
73
|
+
const call = findMapCallAt(parsed, line);
|
|
74
|
+
if (call === undefined) {
|
|
75
|
+
return { kind: 'refused', reason: `no ".map(…)" call at line ${line} of ${file}.` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const found = enclosingCallable(call);
|
|
79
|
+
if (found === undefined) {
|
|
80
|
+
return { kind: 'refused', reason: 'the site is not inside a function or a method: nothing to call from the outside.' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const resolved =
|
|
84
|
+
found.class === undefined ? freeSubject(found.callable, parsed) : classSubject(found.callable, found.class, parsed);
|
|
85
|
+
if (resolved.kind === 'refused') {
|
|
86
|
+
return resolved;
|
|
87
|
+
}
|
|
88
|
+
const { subject, entry } = resolved;
|
|
89
|
+
|
|
90
|
+
const owned = new Set(subject.kind === 'class' ? subject.constructorParameters.map(({ name }) => name) : []);
|
|
91
|
+
const probe = chooseProbe(call, owned, found.callable);
|
|
92
|
+
if (probe.kind === 'refused') {
|
|
93
|
+
return probe;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const container = found.class ?? parsed;
|
|
97
|
+
return {
|
|
98
|
+
kind: 'drafted',
|
|
99
|
+
site: {
|
|
100
|
+
file,
|
|
101
|
+
line,
|
|
102
|
+
subject,
|
|
103
|
+
probe: probe.probe,
|
|
104
|
+
collection: collectionOf(call, parsed),
|
|
105
|
+
origin: ts.isPropertyAccessExpression(call.expression)
|
|
106
|
+
? traceCollection(call.expression.expression, { container, entry, owned, parsed })
|
|
107
|
+
: undefined,
|
|
108
|
+
shape: awaitedTogether(call) ? 'unbounded-fanout' : 'async-per-item',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Resolved =
|
|
114
|
+
| { readonly kind: 'found'; readonly subject: Subject; readonly entry: ts.SignatureDeclarationBase }
|
|
115
|
+
| { readonly kind: 'refused'; readonly reason: string };
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The subject when the site lives inside a class.
|
|
119
|
+
*
|
|
120
|
+
* An empty constructor is no obstacle: `new C()` is enough, and requiring injection has so
|
|
121
|
+
* far ruled out half of the real classes for a reason that did not exist.
|
|
122
|
+
*/
|
|
123
|
+
function classSubject(callable: ts.SignatureDeclarationBase, declaration: ts.ClassDeclaration, parsed: ts.SourceFile): Resolved {
|
|
124
|
+
if (declaration.name === undefined) {
|
|
125
|
+
return { kind: 'refused', reason: 'the class is anonymous: nothing to instantiate from the draft.' };
|
|
126
|
+
}
|
|
127
|
+
if (!ts.isMethodDeclaration(callable)) {
|
|
128
|
+
return { kind: 'refused', reason: 'the site is not inside a class method: nothing to instantiate.' };
|
|
129
|
+
}
|
|
130
|
+
const entry = publicEntryFor(callable, declaration, parsed);
|
|
131
|
+
if (entry.kind === 'refused') {
|
|
132
|
+
return entry;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
kind: 'found',
|
|
136
|
+
subject: {
|
|
137
|
+
kind: 'class',
|
|
138
|
+
className: declaration.name.text,
|
|
139
|
+
methodName: entry.name,
|
|
140
|
+
constructorParameters: readConstructorParameters(declaration, parsed),
|
|
141
|
+
entryParameters: readParameters(entry.method.parameters, parsed),
|
|
142
|
+
},
|
|
143
|
+
entry: entry.method,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The subject when the site lives inside a free function.
|
|
149
|
+
*
|
|
150
|
+
* Only an exported function lets itself be called from a neighboring rules file. A function
|
|
151
|
+
* local to the module is out of scope: naming it would mean writing an import that does not
|
|
152
|
+
* compile.
|
|
153
|
+
*/
|
|
154
|
+
function freeSubject(callable: ts.SignatureDeclarationBase, parsed: ts.SourceFile): Resolved {
|
|
155
|
+
const named = exportedName(callable);
|
|
156
|
+
if (named === undefined) {
|
|
157
|
+
return { kind: 'refused', reason: 'the site is not inside an exported function: nothing to call from the outside.' };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
kind: 'found',
|
|
161
|
+
subject: { kind: 'function', functionName: named, entryParameters: readParameters(callable.parameters, parsed) },
|
|
162
|
+
entry: callable,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `export function f()` and `export const f = () => …`: both forms can be imported. */
|
|
167
|
+
function exportedName(callable: ts.SignatureDeclarationBase): string | undefined {
|
|
168
|
+
if (ts.isFunctionDeclaration(callable)) {
|
|
169
|
+
const exported = (ts.getCombinedModifierFlags(callable) & ts.ModifierFlags.Export) !== 0;
|
|
170
|
+
return exported && callable.name !== undefined ? callable.name.text : undefined;
|
|
171
|
+
}
|
|
172
|
+
const parent = callable.parent;
|
|
173
|
+
if (
|
|
174
|
+
(ts.isArrowFunction(callable) || ts.isFunctionExpression(callable)) &&
|
|
175
|
+
ts.isVariableDeclaration(parent) &&
|
|
176
|
+
ts.isIdentifier(parent.name)
|
|
177
|
+
) {
|
|
178
|
+
return (ts.getCombinedModifierFlags(parent) & ts.ModifierFlags.Export) !== 0 ? parent.name.text : undefined;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type Chosen = { readonly kind: 'found'; readonly probe: Probe } | { readonly kind: 'refused'; readonly reason: string };
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What to count per item.
|
|
187
|
+
*
|
|
188
|
+
* Two readings, in this order. First the injected dependencies read in the body: that is the
|
|
189
|
+
* case where a double already exists, so the most measurable — and two of them stay an
|
|
190
|
+
* ambiguity we refuse. Then, failing that, **the call the callback returns**: in a
|
|
191
|
+
* `Promise.all(xs.map(x => f(x)))`, that is exactly what goes out in parallel. This is not a
|
|
192
|
+
* naming heuristic, it is the structure of the site.
|
|
193
|
+
*/
|
|
194
|
+
function chooseProbe(call: ts.CallExpression, owned: ReadonlySet<string>, callable: ts.SignatureDeclarationBase): Chosen {
|
|
195
|
+
const reads = collectReads(call, owned);
|
|
196
|
+
if (reads.length > 1) {
|
|
197
|
+
const listed = reads.map(({ property, method }) => `this.${property}.${method}()`).join(', ');
|
|
198
|
+
return {
|
|
199
|
+
kind: 'refused',
|
|
200
|
+
reason: `several reads in this .map() (${listed}). Tell me which one to count, I will not guess.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const [read] = reads;
|
|
204
|
+
if (read !== undefined) {
|
|
205
|
+
return { kind: 'found', probe: { kind: 'injected', ...read } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const perItem = perItemCall(call);
|
|
209
|
+
if (perItem === undefined) {
|
|
210
|
+
return {
|
|
211
|
+
kind: 'refused',
|
|
212
|
+
reason: 'the .map() callback does not point to a single call per item: nothing identifiable to count.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return classify(perItem, call, owned, callable);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function classify(
|
|
219
|
+
returned: ts.CallExpression,
|
|
220
|
+
call: ts.CallExpression,
|
|
221
|
+
owned: ReadonlySet<string>,
|
|
222
|
+
callable: ts.SignatureDeclarationBase,
|
|
223
|
+
): Chosen {
|
|
224
|
+
const callee = returned.expression;
|
|
225
|
+
if (!ts.isPropertyAccessExpression(callee)) {
|
|
226
|
+
return {
|
|
227
|
+
kind: 'refused',
|
|
228
|
+
reason: 'the call returned per item is a module function: it cannot be observed without a module double.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const method = callee.name.text;
|
|
232
|
+
const target = callee.expression;
|
|
233
|
+
|
|
234
|
+
if (target.kind === ts.SyntaxKind.ThisKeyword) {
|
|
235
|
+
return { kind: 'found', probe: { kind: 'own', method } };
|
|
236
|
+
}
|
|
237
|
+
if (ts.isPropertyAccessExpression(target) && target.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
238
|
+
if (owned.has(target.name.text)) {
|
|
239
|
+
return { kind: 'found', probe: { kind: 'injected', property: target.name.text, method } };
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
kind: 'refused',
|
|
243
|
+
reason: `the call returned per item goes through "this.${target.name.text}", which is not injected: nothing to observe without rewriting internal state.`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (!ts.isIdentifier(target)) {
|
|
247
|
+
return {
|
|
248
|
+
kind: 'refused',
|
|
249
|
+
reason: 'the call returned per item starts from an expression, not from a name: the probe carrier cannot be identified.',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (itemNames(call).has(target.text)) {
|
|
253
|
+
return { kind: 'found', probe: { kind: 'item', method } };
|
|
254
|
+
}
|
|
255
|
+
if (parameterNames(callable).has(target.text)) {
|
|
256
|
+
return { kind: 'found', probe: { kind: 'parameter', parameter: target.text, method } };
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
kind: 'refused',
|
|
260
|
+
reason: `"${target.text}" is neither a parameter nor the item being iterated: the draft cannot replace it with a probe.`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The call the `.map()` fires per item.
|
|
266
|
+
*
|
|
267
|
+
* Two readings, from the sharpest to the broadest. **What the callback returns** first: in
|
|
268
|
+
* `xs.map((x) => f(x))`, that is exactly what the `Promise.all` spreads out. Then, failing
|
|
269
|
+
* that, **the sole awaited call of the body**: a callback that guards on entry then `await`s
|
|
270
|
+
* once — the most widespread shape in real code — also has only one piece of work per item,
|
|
271
|
+
* and it is that `await` that holds the slot for the whole fan-out.
|
|
272
|
+
*
|
|
273
|
+
* Beyond a single one, we stop. Picking one call out of the batch would measure a cog while
|
|
274
|
+
* believing it measures the fan-out.
|
|
275
|
+
*/
|
|
276
|
+
function perItemCall(call: ts.CallExpression): ts.CallExpression | undefined {
|
|
277
|
+
const callback = callbackOf(call);
|
|
278
|
+
if (callback === undefined) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const { body } = callback;
|
|
282
|
+
if (!ts.isBlock(body)) {
|
|
283
|
+
return asCall(body);
|
|
284
|
+
}
|
|
285
|
+
const returns = body.statements.filter(ts.isReturnStatement);
|
|
286
|
+
const [returned] = returns;
|
|
287
|
+
if (returns.length === 1 && returned?.expression !== undefined) {
|
|
288
|
+
const found = asCall(returned.expression);
|
|
289
|
+
if (found !== undefined) {
|
|
290
|
+
return found;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return soleAwaitedCall(body);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The sole `await`ed call of the body, when there is only one.
|
|
298
|
+
*
|
|
299
|
+
* The `await`s of a nested callback do not count: they belong to a spread other than the
|
|
300
|
+
* site's, and conflating them would place the probe one notch too low.
|
|
301
|
+
*/
|
|
302
|
+
function soleAwaitedCall(body: ts.Block): ts.CallExpression | undefined {
|
|
303
|
+
const awaited: ts.CallExpression[] = [];
|
|
304
|
+
const visit = (node: ts.Node): void => {
|
|
305
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (ts.isAwaitExpression(node)) {
|
|
309
|
+
const found = asCall(node.expression);
|
|
310
|
+
if (found !== undefined) {
|
|
311
|
+
awaited.push(found);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
ts.forEachChild(node, visit);
|
|
315
|
+
};
|
|
316
|
+
visit(body);
|
|
317
|
+
const [only] = awaited;
|
|
318
|
+
return awaited.length === 1 ? only : undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function callbackOf(call: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | undefined {
|
|
322
|
+
const [callback] = call.arguments;
|
|
323
|
+
return callback !== undefined && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ? callback : undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function asCall(expression: ts.Expression): ts.CallExpression | undefined {
|
|
327
|
+
let current: ts.Expression = expression;
|
|
328
|
+
while (
|
|
329
|
+
ts.isAwaitExpression(current) ||
|
|
330
|
+
ts.isParenthesizedExpression(current) ||
|
|
331
|
+
ts.isAsExpression(current) ||
|
|
332
|
+
ts.isNonNullExpression(current)
|
|
333
|
+
) {
|
|
334
|
+
current = current.expression;
|
|
335
|
+
}
|
|
336
|
+
return ts.isCallExpression(current) ? current : undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The names the callback binds to the item, destructuring included. */
|
|
340
|
+
function itemNames(call: ts.CallExpression): ReadonlySet<string> {
|
|
341
|
+
const names = new Set<string>();
|
|
342
|
+
const callback = callbackOf(call);
|
|
343
|
+
if (callback === undefined) {
|
|
344
|
+
return names;
|
|
345
|
+
}
|
|
346
|
+
const [first] = callback.parameters;
|
|
347
|
+
if (first !== undefined) {
|
|
348
|
+
bindingNames(first.name, names);
|
|
349
|
+
}
|
|
350
|
+
return names;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parameterNames(callable: ts.SignatureDeclarationBase): ReadonlySet<string> {
|
|
354
|
+
const names = new Set<string>();
|
|
355
|
+
for (const parameter of callable.parameters) {
|
|
356
|
+
bindingNames(parameter.name, names);
|
|
357
|
+
}
|
|
358
|
+
return names;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function bindingNames(name: ts.BindingName, into: Set<string>): void {
|
|
362
|
+
if (ts.isIdentifier(name)) {
|
|
363
|
+
into.add(name.text);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const element of name.elements) {
|
|
367
|
+
if (ts.isBindingElement(element)) {
|
|
368
|
+
bindingNames(element.name, into);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
type Entry =
|
|
374
|
+
| { readonly kind: 'found'; readonly name: string; readonly method: ts.MethodDeclaration }
|
|
375
|
+
| { readonly kind: 'refused'; readonly reason: string };
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The entry point to call from the draft.
|
|
379
|
+
*
|
|
380
|
+
* A private method cannot be called from the outside, and measuring an internal cog rather
|
|
381
|
+
* than the use case would skew the subject. So we walk up to the public method that calls
|
|
382
|
+
* it — and if there are zero or several of them, we refuse instead of choosing.
|
|
383
|
+
*/
|
|
384
|
+
function publicEntryFor(method: ts.MethodDeclaration, declaration: ts.ClassDeclaration, parsed: ts.SourceFile): Entry {
|
|
385
|
+
const name = methodNameOf(method, parsed);
|
|
386
|
+
if (!isPrivate(method)) {
|
|
387
|
+
return { kind: 'found', name, method };
|
|
388
|
+
}
|
|
389
|
+
const callers = declaration.members
|
|
390
|
+
.filter(ts.isMethodDeclaration)
|
|
391
|
+
.filter((candidate) => !isPrivate(candidate) && callsMethod(candidate, name));
|
|
392
|
+
|
|
393
|
+
const [only] = callers;
|
|
394
|
+
if (callers.length === 1 && only !== undefined) {
|
|
395
|
+
return { kind: 'found', name: methodNameOf(only, parsed), method: only };
|
|
396
|
+
}
|
|
397
|
+
if (callers.length === 0) {
|
|
398
|
+
return {
|
|
399
|
+
kind: 'refused',
|
|
400
|
+
reason: `"${name}" is private and no public method calls it: nothing to measure from the outside.`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
kind: 'refused',
|
|
405
|
+
reason: `"${name}" is private and ${callers.length} public methods call it (${callers
|
|
406
|
+
.map((candidate) => methodNameOf(candidate, parsed))
|
|
407
|
+
.join(', ')}). Tell me the entry point, I will not guess.`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isPrivate(method: ts.MethodDeclaration): boolean {
|
|
412
|
+
return (ts.getCombinedModifierFlags(method) & ts.ModifierFlags.Private) !== 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function callsMethod(method: ts.MethodDeclaration, name: string): boolean {
|
|
416
|
+
let found = false;
|
|
417
|
+
const visit = (node: ts.Node): void => {
|
|
418
|
+
if (
|
|
419
|
+
ts.isCallExpression(node) &&
|
|
420
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
421
|
+
node.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
422
|
+
node.expression.name.text === name
|
|
423
|
+
) {
|
|
424
|
+
found = true;
|
|
425
|
+
}
|
|
426
|
+
ts.forEachChild(node, visit);
|
|
427
|
+
};
|
|
428
|
+
if (method.body !== undefined) {
|
|
429
|
+
visit(method.body);
|
|
430
|
+
}
|
|
431
|
+
return found;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Is the `.map()` the argument of a `Promise.all` / `Promise.allSettled`?
|
|
436
|
+
*
|
|
437
|
+
* We walk up through parentheses and `as`, nothing else: `Promise.all(await f(xs.map(…)))`
|
|
438
|
+
* does not spread the `.map()` itself, and counting it as such would produce a ceiling
|
|
439
|
+
* measured on the wrong thing.
|
|
440
|
+
*/
|
|
441
|
+
function awaitedTogether(call: ts.CallExpression): boolean {
|
|
442
|
+
let current: ts.Node = call;
|
|
443
|
+
let parent: ts.Node | undefined = call.parent;
|
|
444
|
+
while (parent !== undefined && (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
|
|
445
|
+
current = parent;
|
|
446
|
+
parent = parent.parent;
|
|
447
|
+
}
|
|
448
|
+
if (parent !== undefined && ts.isSpreadElement(parent)) {
|
|
449
|
+
current = parent;
|
|
450
|
+
parent = parent.parent;
|
|
451
|
+
}
|
|
452
|
+
if (parent !== undefined && ts.isArrayLiteralExpression(parent)) {
|
|
453
|
+
current = parent;
|
|
454
|
+
parent = parent.parent;
|
|
455
|
+
}
|
|
456
|
+
if (parent === undefined || !ts.isCallExpression(parent) || !parent.arguments.includes(current as ts.Expression)) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
const callee = parent.expression;
|
|
460
|
+
return (
|
|
461
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
462
|
+
ts.isIdentifier(callee.expression) &&
|
|
463
|
+
callee.expression.text === 'Promise' &&
|
|
464
|
+
(callee.name.text === 'all' || callee.name.text === 'allSettled')
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function findMapCallAt(parsed: ts.SourceFile, line: number): ts.CallExpression | undefined {
|
|
469
|
+
let found: ts.CallExpression | undefined;
|
|
470
|
+
const visit = (node: ts.Node): void => {
|
|
471
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'map') {
|
|
472
|
+
const { line: at } = parsed.getLineAndCharacterOfPosition(node.getStart(parsed));
|
|
473
|
+
// A call folded over several lines: we accept the Promise.all line just above.
|
|
474
|
+
if (at + 1 === line || at === line) {
|
|
475
|
+
found ??= node;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
ts.forEachChild(node, visit);
|
|
479
|
+
};
|
|
480
|
+
visit(parsed);
|
|
481
|
+
return found;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The function or method that contains the site, and the class that carries it.
|
|
486
|
+
*
|
|
487
|
+
* The anonymous callbacks crossed along the way are not entry points: we keep walking up to
|
|
488
|
+
* what has a name, the only thing an import can name.
|
|
489
|
+
*/
|
|
490
|
+
function enclosingCallable(
|
|
491
|
+
node: ts.Node,
|
|
492
|
+
): { readonly callable: ts.SignatureDeclarationBase; readonly class: ts.ClassDeclaration | undefined } | undefined {
|
|
493
|
+
let current: ts.Node | undefined = node.parent;
|
|
494
|
+
while (current !== undefined) {
|
|
495
|
+
if (ts.isMethodDeclaration(current) || ts.isFunctionDeclaration(current)) {
|
|
496
|
+
return { callable: current, class: ts.isClassDeclaration(current.parent) ? current.parent : undefined };
|
|
497
|
+
}
|
|
498
|
+
if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && exportedName(current) !== undefined) {
|
|
499
|
+
return { callable: current, class: undefined };
|
|
500
|
+
}
|
|
501
|
+
current = current.parent;
|
|
502
|
+
}
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function readConstructorParameters(declaration: ts.ClassDeclaration, parsed: ts.SourceFile): readonly Parameter[] {
|
|
507
|
+
const found = declaration.members.find(ts.isConstructorDeclaration);
|
|
508
|
+
return readParameters(found?.parameters ?? [], parsed);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Position counts as much as the name: a destructured parameter keeps its slot, without a name. */
|
|
512
|
+
function readParameters(parameters: readonly ts.ParameterDeclaration[], parsed: ts.SourceFile): readonly Parameter[] {
|
|
513
|
+
return parameters.map((parameter) => ({
|
|
514
|
+
name: ts.isIdentifier(parameter.name) ? parameter.name.text : '',
|
|
515
|
+
type: parameter.type?.getText(parsed) ?? 'unknown',
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
type Read = {
|
|
520
|
+
readonly property: string;
|
|
521
|
+
readonly method: string;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
/** The `this.<dependency>.<readVerb>(…)` calls in the body of the `.map()`. */
|
|
525
|
+
function collectReads(call: ts.CallExpression, owned: ReadonlySet<string>): readonly Read[] {
|
|
526
|
+
const reads = new Map<string, Read>();
|
|
527
|
+
const visit = (node: ts.Node): void => {
|
|
528
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
529
|
+
const target = node.expression.expression;
|
|
530
|
+
if (ts.isPropertyAccessExpression(target) && target.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
531
|
+
const property = target.name.text;
|
|
532
|
+
const method = node.expression.name.text;
|
|
533
|
+
if (owned.has(property) && READ_VERBS.test(method)) {
|
|
534
|
+
reads.set(`${property}.${method}`, { property, method });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
ts.forEachChild(node, visit);
|
|
539
|
+
};
|
|
540
|
+
call.arguments.forEach((argument) => visit(argument));
|
|
541
|
+
return [...reads.values()];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function collectionOf(call: ts.CallExpression, parsed: ts.SourceFile): string {
|
|
545
|
+
return ts.isPropertyAccessExpression(call.expression) ? call.expression.expression.getText(parsed) : 'collection';
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function methodNameOf(method: ts.MethodDeclaration, parsed: ts.SourceFile): string {
|
|
549
|
+
return method.name.getText(parsed);
|
|
550
|
+
}
|