@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,1139 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { reachesAsyncCall, returnsPromise } from './flow';
|
|
3
|
+
|
|
4
|
+
export type ShapeKind =
|
|
5
|
+
| 'unbounded-fanout'
|
|
6
|
+
| 'bounded-fanout'
|
|
7
|
+
| 'in-flight-drain'
|
|
8
|
+
| 'async-per-item'
|
|
9
|
+
| 'awaited-in-loop'
|
|
10
|
+
| 'paginated-return'
|
|
11
|
+
| 'unbounded-primitive';
|
|
12
|
+
|
|
13
|
+
export type Site = {
|
|
14
|
+
readonly kind: ShapeKind;
|
|
15
|
+
readonly file: string;
|
|
16
|
+
readonly line: number;
|
|
17
|
+
/** The code fragment involved, truncated so the report stays readable. */
|
|
18
|
+
readonly excerpt: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const EXCERPT_LENGTH = 72;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Spots the risky shapes by asking the types rather than guessing.
|
|
25
|
+
*
|
|
26
|
+
* The old pass recognised a read from the method name — `find*`, `get*`, `load*`. Measured on
|
|
27
|
+
* the `apps/api` package of a real-world monorepo: 24 sites seen by type, 20 by name, and
|
|
28
|
+
* **only 10 in common**. The name misses `update`, `addItem`, `republish`, `notifyRefreshChunk`
|
|
29
|
+
* — one of them a write N+1 — and flags `getCompactDisplayName()`, which only reads memory.
|
|
30
|
+
*
|
|
31
|
+
* The return type decides without prejudice: a per-item operation that returns `Promise<…>`
|
|
32
|
+
* leaves the process, whatever its name and whatever its meaning.
|
|
33
|
+
*/
|
|
34
|
+
export function findShapes(parsed: ts.SourceFile, checker: ts.TypeChecker, file: string): readonly Site[] {
|
|
35
|
+
const scope = collectScope(parsed, checker);
|
|
36
|
+
const sites: Site[] = [];
|
|
37
|
+
/** The `Promise.all` that fans out each `.map()`: both questions carry the same address. */
|
|
38
|
+
const anchoredBy = new Map<ts.Node, ts.Node>();
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
|
|
41
|
+
/** `discriminator` separates two answers carried by the same line, such as two parameters. */
|
|
42
|
+
const record = (kind: ShapeKind, node: ts.Node, discriminator = ''): void => {
|
|
43
|
+
const { line } = parsed.getLineAndCharacterOfPosition(node.getStart(parsed));
|
|
44
|
+
const key = `${kind}:${line}:${discriminator}`;
|
|
45
|
+
if (seen.has(key)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
seen.add(key);
|
|
49
|
+
sites.push({ kind, file, line: line + 1, excerpt: excerpt(node.getText(parsed)) });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const visit = (node: ts.Node): void => {
|
|
53
|
+
if (ts.isCallExpression(node) && isPromiseCombinator(node)) {
|
|
54
|
+
const [argument] = node.arguments;
|
|
55
|
+
if (argument !== undefined) {
|
|
56
|
+
// The drain is asked first: it answers a different question from the width.
|
|
57
|
+
// A collection of promises already in flight holds N of them too, and no
|
|
58
|
+
// reading of its width can tell it apart from a fan-out.
|
|
59
|
+
record(drainKind(argument, scope, checker), node);
|
|
60
|
+
markFannedOut(argument, node, anchoredBy);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (ts.isCallExpression(node) && isMapCall(node) && callsAsyncPerItem(node, checker) && !isBoundedWidth(node, scope)) {
|
|
64
|
+
record('async-per-item', anchoredBy.get(node) ?? node);
|
|
65
|
+
}
|
|
66
|
+
if (isSerialAwait(node, scope, checker)) {
|
|
67
|
+
record('awaited-in-loop', node);
|
|
68
|
+
}
|
|
69
|
+
if (ts.isObjectLiteralExpression(node) && hasBoth(node, 'items', 'total')) {
|
|
70
|
+
record('paginated-return', node);
|
|
71
|
+
}
|
|
72
|
+
if (isExported(node)) {
|
|
73
|
+
for (const parameter of node.parameters) {
|
|
74
|
+
if (isBarePrimitive(parameter, checker) && reachesAsyncCall(parameter, node.body, checker)) {
|
|
75
|
+
record('unbounded-primitive', parameter, parameter.name.getText(parsed));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
ts.forEachChild(node, visit);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
visit(parsed);
|
|
83
|
+
return sites;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An exported function is a frontier: its arguments can come from anywhere, including code the
|
|
88
|
+
* compiler never saw, such as a parsed body or a row read from a database. Inside the module
|
|
89
|
+
* the caller is typed, so the guarantee holds and there is nothing left to ask.
|
|
90
|
+
*
|
|
91
|
+
* Necessary, not sufficient: `reachesAsyncCall` then keeps the parameters that go on to leave
|
|
92
|
+
* the process.
|
|
93
|
+
*/
|
|
94
|
+
function isExported(node: ts.Node): node is ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction {
|
|
95
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
96
|
+
return isExportedNode(node);
|
|
97
|
+
}
|
|
98
|
+
if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const declaration = node.parent;
|
|
102
|
+
if (!ts.isVariableDeclaration(declaration)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const statement = declaration.parent.parent;
|
|
106
|
+
return ts.isVariableStatement(statement) && isExportedNode(statement);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isExportedNode(node: ts.Node): boolean {
|
|
110
|
+
return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some(({ kind }) => kind === ts.SyntaxKind.ExportKeyword);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A `string` or a `number`, written bare, hence an infinite domain the type does not narrow.
|
|
115
|
+
*
|
|
116
|
+
* A literal union is left out: the compiler already closed it, and there is no question to
|
|
117
|
+
* ask. Unions and optionals widen the question without changing it, and are left to a later
|
|
118
|
+
* pass: here a question that turns out to be wrong costs a commit, not a second of attention.
|
|
119
|
+
*/
|
|
120
|
+
function isBarePrimitive(parameter: ts.ParameterDeclaration, checker: ts.TypeChecker): boolean {
|
|
121
|
+
if (parameter.type === undefined) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const type = checker.getTypeAtLocation(parameter);
|
|
125
|
+
return (type.flags & (ts.TypeFlags.String | ts.TypeFlags.Number)) !== 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** How far the walk back to a collection's source is allowed to go. */
|
|
129
|
+
const MAX_WALK = 8;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `for (const x of xs) { await go(x) }` — one call per item, waited for one after the other.
|
|
133
|
+
*
|
|
134
|
+
* Everything above this reads concurrency: how many calls leave at once, and under what
|
|
135
|
+
* ceiling. This reads the opposite shape, and it was the blind spot. A serial `await` has a
|
|
136
|
+
* concurrency of one, so it never triggered a single question, while its total cost grows with
|
|
137
|
+
* the collection just as surely as a fan-out's — in time rather than in sockets. Measured on
|
|
138
|
+
* codesema and codesema-tools: 78 loops of this shape against 17 concurrency sites, so the
|
|
139
|
+
* pass was silent on the more common of the two.
|
|
140
|
+
*
|
|
141
|
+
* `task-isolation.ts:1036` in codesema-tools is the one that made the case: a `for…of` awaiting
|
|
142
|
+
* a container removal at 30s of timeout apiece, two lines under a `Promise.all` the pass was
|
|
143
|
+
* asking about wrongly.
|
|
144
|
+
*
|
|
145
|
+
* Three filters keep it from becoming noise, since a serial loop is often exactly what someone
|
|
146
|
+
* wanted:
|
|
147
|
+
*
|
|
148
|
+
* - the collection must be unbounded, which is the same reading as everywhere else. `for
|
|
149
|
+
* (const t of TABLES)` over a written list asks nothing;
|
|
150
|
+
* - the call must leave the process — the return type decides, as it does for `.map()`;
|
|
151
|
+
* - `Promise.all` and friends are left out: they carry their own question, at their own address.
|
|
152
|
+
*/
|
|
153
|
+
function isSerialAwait(node: ts.Node, scope: Scope, checker: ts.TypeChecker): boolean {
|
|
154
|
+
if (!ts.isForOfStatement(node) && !ts.isForInStatement(node)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (isBoundedWidth(node.expression, scope)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return awaitsPerTurn(node.statement, checker);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Is a call awaited on every turn of this body?
|
|
165
|
+
*
|
|
166
|
+
* Nested loops are walked into: awaiting once per turn of an inner loop is the same cost, only
|
|
167
|
+
* worse. Functions are not: a callback declared here runs on its own schedule, which is the
|
|
168
|
+
* same boundary `callsAsyncPerItem` draws.
|
|
169
|
+
*/
|
|
170
|
+
function awaitsPerTurn(body: ts.Node, checker: ts.TypeChecker): boolean {
|
|
171
|
+
let found = false;
|
|
172
|
+
const visit = (node: ts.Node): void => {
|
|
173
|
+
if (found || ts.isFunctionLike(node)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const launched = ts.isAwaitExpression(node) ? launchedBy(node.expression, checker) : undefined;
|
|
177
|
+
if (launched !== undefined && !isPromiseCombinator(launched) && returnsPromise(launched, checker)) {
|
|
178
|
+
found = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
ts.forEachChild(node, visit);
|
|
182
|
+
};
|
|
183
|
+
visit(body);
|
|
184
|
+
return found;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The call an awaited expression actually launches, past its handlers.
|
|
189
|
+
*
|
|
190
|
+
* `await exec(runtime, […]).catch(() => null)` waits on `exec`, and the `.catch` only says what
|
|
191
|
+
* to do if it fails. Reading the outermost call would have let through `task-isolation.ts:1036`
|
|
192
|
+
* — a container removal per item, at 30s of timeout apiece, and the very site this rule was
|
|
193
|
+
* written for. `await p.catch(…)` on a name gives nothing back: there, nothing is launched.
|
|
194
|
+
*/
|
|
195
|
+
function launchedBy(expression: ts.Expression, checker: ts.TypeChecker): ts.CallExpression | undefined {
|
|
196
|
+
if (!ts.isCallExpression(expression)) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
if (!isDecoration(expression, checker)) {
|
|
200
|
+
return expression;
|
|
201
|
+
}
|
|
202
|
+
return ts.isPropertyAccessExpression(expression.expression)
|
|
203
|
+
? launchedBy(expression.expression.expression, checker)
|
|
204
|
+
: undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* `Promise.all` and its siblings already have a question, and an address of their own.
|
|
209
|
+
*
|
|
210
|
+
* The four are read alike, because they open the same calls. `race` and `any` differ only in
|
|
211
|
+
* what is waited for: awaiting the first answer starts every one of them, so the outgoing
|
|
212
|
+
* concurrency is that of `all` and the ceiling question is the same. Reading `all` alone left
|
|
213
|
+
* `Promise.race(ids.map(load))` with its growth question and without its concurrency one.
|
|
214
|
+
*/
|
|
215
|
+
function isPromiseCombinator(call: ts.CallExpression): boolean {
|
|
216
|
+
return isPromiseAll(call) || isPromiseRace(call) || isPromiseMethod(call, 'any');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Which of the three fan-out verdicts this argument deserves.
|
|
221
|
+
*
|
|
222
|
+
* The drain is asked first, because the width only means something once the prior question is
|
|
223
|
+
* settled: were these promises born here, or merely gathered?
|
|
224
|
+
*/
|
|
225
|
+
function drainKind(argument: ts.Expression, scope: Scope, checker: ts.TypeChecker): ShapeKind {
|
|
226
|
+
if (awaitsInFlight(argument, scope, checker)) {
|
|
227
|
+
return 'in-flight-drain';
|
|
228
|
+
}
|
|
229
|
+
return isBoundedWidth(argument, scope) ? 'bounded-fanout' : 'unbounded-fanout';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Does this argument gather promises that were ALREADY in flight?
|
|
234
|
+
*
|
|
235
|
+
* `Promise.all(users.map((u) => sync(u)))` is a fan-out: every promise is born on that line,
|
|
236
|
+
* one per item, and asking for a ceiling is the right question. `Promise.all([...inflight.values()])`
|
|
237
|
+
* is a **drain**: the work was started elsewhere, waiting on it opens nothing, and a ceiling
|
|
238
|
+
* here would bound nothing at all.
|
|
239
|
+
*
|
|
240
|
+
* Width cannot tell the two apart, and neither can the type of the argument: both are
|
|
241
|
+
* `Promise<T>[]`. What tells them apart is where the collection comes from, so the read walks
|
|
242
|
+
* back to its source:
|
|
243
|
+
*
|
|
244
|
+
* - a width-preserving call is traversed: `[...proxies.values()].map((p) => p.catch(…))`
|
|
245
|
+
* decorates promises it did not create, and the receiver is what says so;
|
|
246
|
+
* - a spread-only array literal is a drain when every one of its spreads is one;
|
|
247
|
+
* - a name is followed to its initialiser, so `const settled = […].map(…)` is read where it is
|
|
248
|
+
* written. A name initialised from a **container** stops the walk and hands over to the type:
|
|
249
|
+
* a `Set<Promise<void>>` cannot create what it holds.
|
|
250
|
+
*
|
|
251
|
+
* Whatever the walk cannot follow stays a fan-out. Same failure of caution as everywhere in
|
|
252
|
+
* this file: a drain called a fan-out costs a question, a fan-out called a drain costs a
|
|
253
|
+
* shipped defect.
|
|
254
|
+
*
|
|
255
|
+
* Real sites, all three flagged wrongly before this: `task-isolation.ts:1039`,
|
|
256
|
+
* `task-runner.ts:3323` and `task-server.ts:3651` in codesema-tools. Not one of them opens a
|
|
257
|
+
* single outgoing call.
|
|
258
|
+
*/
|
|
259
|
+
function awaitsInFlight(node: ts.Expression, scope: Scope, checker: ts.TypeChecker, depth = 0): boolean {
|
|
260
|
+
if (depth > MAX_WALK) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
264
|
+
return awaitsInFlight(node.expression, scope, checker, depth + 1);
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
ts.isCallExpression(node) &&
|
|
268
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
269
|
+
WIDTH_PRESERVING.has(node.expression.name.text)
|
|
270
|
+
) {
|
|
271
|
+
return awaitsInFlight(node.expression.expression, scope, checker, depth + 1);
|
|
272
|
+
}
|
|
273
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
274
|
+
return (
|
|
275
|
+
node.elements.length > 0 &&
|
|
276
|
+
node.elements.every(
|
|
277
|
+
(element) => ts.isSpreadElement(element) && awaitsInFlight(element.expression, scope, checker, depth + 1),
|
|
278
|
+
)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (ts.isIdentifier(node)) {
|
|
282
|
+
// A container this file fills itself, in a loop it does not cap, is the fan-out. The
|
|
283
|
+
// hand-written pool holds promises like any drain, and calling it one would file the
|
|
284
|
+
// unbounded concurrency under "proven by construction".
|
|
285
|
+
if (scope.grown.has(node.text)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const initialiser = initialiserOf(node, checker);
|
|
289
|
+
if (initialiser === undefined) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
return ts.isNewExpression(initialiser)
|
|
293
|
+
? holdsPromises(node, checker)
|
|
294
|
+
: awaitsInFlight(initialiser, scope, checker, depth + 1);
|
|
295
|
+
}
|
|
296
|
+
// `map.values()`, `set.values()`: the iterator of a container, filled by another line
|
|
297
|
+
// than this one.
|
|
298
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'values') {
|
|
299
|
+
return holdsPromises(node, checker);
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The expression a name is initialised with, when this pass can see it.
|
|
306
|
+
*
|
|
307
|
+
* `const` only: a `let` reassigned further down makes its initialiser prove nothing. The
|
|
308
|
+
* checker resolves the symbol, so an imported constant is followed as readily as a local one —
|
|
309
|
+
* the program is built, and `scan` had been paying for it without spending it.
|
|
310
|
+
*/
|
|
311
|
+
function initialiserOf(node: ts.Identifier, checker: ts.TypeChecker): ts.Expression | undefined {
|
|
312
|
+
return declarationOf(node, checker)?.initializer;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The `const` declaration a name resolves to, wherever the program keeps it. */
|
|
316
|
+
function declarationOf(node: ts.Identifier, checker: ts.TypeChecker): ts.VariableDeclaration | undefined {
|
|
317
|
+
const symbol = checker.getSymbolAtLocation(node);
|
|
318
|
+
if (symbol === undefined) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
const resolved = (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol);
|
|
322
|
+
const [declaration] = resolved.declarations ?? [];
|
|
323
|
+
if (declaration === undefined || !ts.isVariableDeclaration(declaration)) {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
const list = declaration.parent;
|
|
327
|
+
return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 ? declaration : undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Are the items of this collection promises, that is, work another line already started? */
|
|
331
|
+
function holdsPromises(node: ts.Expression, checker: ts.TypeChecker): boolean {
|
|
332
|
+
const element = elementTypeOf(checker.getTypeAtLocation(node), checker);
|
|
333
|
+
return element !== undefined && checker.typeToString(element).startsWith('Promise<');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The type of one item of an array, a tuple, or any generic container. */
|
|
337
|
+
function elementTypeOf(type: ts.Type, checker: ts.TypeChecker): ts.Type | undefined {
|
|
338
|
+
const indexed = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
|
|
339
|
+
if (indexed !== undefined) {
|
|
340
|
+
return indexed;
|
|
341
|
+
}
|
|
342
|
+
const isReference =
|
|
343
|
+
(type.flags & ts.TypeFlags.Object) !== 0 && ((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) !== 0;
|
|
344
|
+
if (!isReference) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
const args = checker.getTypeArguments(type as ts.TypeReference);
|
|
348
|
+
return args.at(-1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The names whose width this file writes itself — the ones a `Promise.all(…)` can walk
|
|
353
|
+
* without the ceiling question ever arising.
|
|
354
|
+
*
|
|
355
|
+
* A name enters through `isBoundedWidth`: `const TABLES = [a, b]`, `const batch = xs.slice(…)`,
|
|
356
|
+
* `const pool = new Array(limit).fill(null).map(…)`. It leaves again if the file grows it.
|
|
357
|
+
* It gets in all the same when it is **throttled** — a set on which the code waits for a
|
|
358
|
+
* free slot.
|
|
359
|
+
*
|
|
360
|
+
* The order of the three passes is the order of caution: add, remove, then catch up. A
|
|
361
|
+
* throttle beats growth because it is precisely what bounds it.
|
|
362
|
+
*
|
|
363
|
+
* The set is built in source order, so a chain follows through:
|
|
364
|
+
* `const batch = xs.slice(i, i + n)` then `const tasks = batch.map(…)` classifies both. A
|
|
365
|
+
* declaration read after its use is missed — a failure of caution, so it gets flagged.
|
|
366
|
+
*
|
|
367
|
+
* An imported constant enters too, through `collectImportedWidths`: `scan` builds a real
|
|
368
|
+
* program, so a name declared next door is no more opaque than a local one. What stays outside
|
|
369
|
+
* is what the program cannot show — a parameter, whose width belongs to callers this scope may
|
|
370
|
+
* not even contain.
|
|
371
|
+
*/
|
|
372
|
+
function collectScope(parsed: ts.SourceFile, checker: ts.TypeChecker): Scope {
|
|
373
|
+
const measured = collectMeasured(parsed);
|
|
374
|
+
const grown = collectGrownContainers(parsed, measured);
|
|
375
|
+
const imported = collectImportedWidths(parsed, checker);
|
|
376
|
+
return { fixed: collectFixedCollections(parsed, measured, grown, imported), measured, grown };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The imported names whose width is written where they are declared.
|
|
381
|
+
*
|
|
382
|
+
* `import { C4_FILE_KEYS } from '../../db/schema.js'` then `C4_FILE_KEYS.map(…)` is a fan-out
|
|
383
|
+
* over a list written by hand in another file. Two real sites in codesema hung on this, and the
|
|
384
|
+
* comment that used to sit here blamed a missing `createProgram` that `scan` has been building
|
|
385
|
+
* all along.
|
|
386
|
+
*
|
|
387
|
+
* Only the shape that needs no context travels: an array literal, and the width-preserving
|
|
388
|
+
* calls over it. Anything whose width depends on names of the other file is left alone, because
|
|
389
|
+
* judging it would take that file's scope, not this one's.
|
|
390
|
+
*/
|
|
391
|
+
function collectImportedWidths(parsed: ts.SourceFile, checker: ts.TypeChecker): ReadonlySet<string> {
|
|
392
|
+
const names = new Set<string>();
|
|
393
|
+
for (const statement of parsed.statements) {
|
|
394
|
+
const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : undefined;
|
|
395
|
+
if (bindings === undefined || !ts.isNamedImports(bindings)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
for (const element of bindings.elements) {
|
|
399
|
+
if (isWrittenWidth(element.name, checker)) {
|
|
400
|
+
names.add(element.name.text);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return names;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Is this imported name a collection whose width is written, and stays written?
|
|
409
|
+
*
|
|
410
|
+
* The literal alone proves nothing: `export const rows: string[] = []` is as literal as it
|
|
411
|
+
* gets, and a `rows.push(…)` in its own file makes it an accumulator. That is the very
|
|
412
|
+
* inversion `growsInPlace` exists to prevent locally, and importing must not be a way around
|
|
413
|
+
* it. So the declaring file is read too, for any insertion under that name.
|
|
414
|
+
*
|
|
415
|
+
* The name looked for is the DECLARED one, never the local alias: `import { KEYS as k }`
|
|
416
|
+
* renames here and changes nothing over there.
|
|
417
|
+
*/
|
|
418
|
+
function isWrittenWidth(local: ts.Identifier, checker: ts.TypeChecker): boolean {
|
|
419
|
+
const declaration = declarationOf(local, checker);
|
|
420
|
+
if (declaration === undefined || declaration.initializer === undefined || !ts.isIdentifier(declaration.name)) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
if (!isLiteralWidth(declaration.initializer)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
return !isFilledAnywhere(declaration.getSourceFile(), declaration.name.text);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Does this file insert into that name anywhere at all? No loop reading here: any insertion counts. */
|
|
430
|
+
function isFilledAnywhere(parsed: ts.SourceFile, name: string): boolean {
|
|
431
|
+
let filled = false;
|
|
432
|
+
const visit = (node: ts.Node): void => {
|
|
433
|
+
if (filled) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (fillsInPlace(node) === name) {
|
|
437
|
+
filled = true;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
ts.forEachChild(node, visit);
|
|
441
|
+
};
|
|
442
|
+
visit(parsed);
|
|
443
|
+
return filled;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A width written on the spot, owing nothing to the scope that surrounds it. */
|
|
447
|
+
function isLiteralWidth(node: ts.Expression): boolean {
|
|
448
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
449
|
+
return isLiteralWidth(node.expression);
|
|
450
|
+
}
|
|
451
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
452
|
+
return node.elements.every((element) => !ts.isSpreadElement(element) || isLiteralWidth(element.expression));
|
|
453
|
+
}
|
|
454
|
+
return (
|
|
455
|
+
ts.isCallExpression(node) &&
|
|
456
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
457
|
+
WIDTH_PRESERVING.has(node.expression.name.text) &&
|
|
458
|
+
isLiteralWidth(node.expression.expression)
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** What the current file knows about the names it declares, and nothing more. */
|
|
463
|
+
type Scope = {
|
|
464
|
+
/** The names whose width this file writes itself. */
|
|
465
|
+
readonly fixed: ReadonlySet<string>;
|
|
466
|
+
/** The names whose value this file computes from a collection size. */
|
|
467
|
+
readonly measured: ReadonlySet<string>;
|
|
468
|
+
/** The containers this file fills itself, from inside an unbounded loop. */
|
|
469
|
+
readonly grown: ReadonlySet<string>;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
function collectFixedCollections(
|
|
473
|
+
parsed: ts.SourceFile,
|
|
474
|
+
measured: ReadonlySet<string>,
|
|
475
|
+
grown: ReadonlySet<string>,
|
|
476
|
+
imported: ReadonlySet<string>,
|
|
477
|
+
): ReadonlySet<string> {
|
|
478
|
+
// Seeded before the removal pass, not after: a `.push()` in THIS file on an imported name
|
|
479
|
+
// takes it back out, exactly as it would a local one.
|
|
480
|
+
const names = new Set<string>(imported);
|
|
481
|
+
const declaredIn = new Map<string, Set<ts.Node>>();
|
|
482
|
+
const mutations: (readonly [string, ts.Node])[] = [];
|
|
483
|
+
const scope: Scope = { fixed: names, measured, grown };
|
|
484
|
+
const visit = (node: ts.Node): void => {
|
|
485
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
|
|
486
|
+
const declaration = node.parent;
|
|
487
|
+
const isConst = ts.isVariableDeclarationList(declaration) && (declaration.flags & ts.NodeFlags.Const) !== 0;
|
|
488
|
+
if (isConst && isBoundedWidth(node.initializer, scope)) {
|
|
489
|
+
names.add(node.name.text);
|
|
490
|
+
const containers = declaredIn.get(node.name.text) ?? new Set<ts.Node>();
|
|
491
|
+
containers.add(containerOf(node));
|
|
492
|
+
declaredIn.set(node.name.text, containers);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const grownName = growsInPlace(node);
|
|
496
|
+
if (grownName !== undefined) {
|
|
497
|
+
mutations.push([grownName, node]);
|
|
498
|
+
}
|
|
499
|
+
ts.forEachChild(node, visit);
|
|
500
|
+
};
|
|
501
|
+
visit(parsed);
|
|
502
|
+
for (const [name, mutation] of mutations) {
|
|
503
|
+
if (growsUnboundedly(mutation, declaredIn.get(name), scope)) {
|
|
504
|
+
names.delete(name);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const name of collectThrottled(parsed)) {
|
|
508
|
+
names.add(name);
|
|
509
|
+
}
|
|
510
|
+
return names;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The containers this file fills itself, from inside a loop it does not cap.
|
|
515
|
+
*
|
|
516
|
+
* `const running = new Set<Promise<void>>()` filled by `running.add(go(x))` in a `for…of` holds
|
|
517
|
+
* promises, and a `Promise.all(running)` really is the fan-out: the concurrency is unbounded,
|
|
518
|
+
* whichever line opened it. Reading the container's type alone would file the hand-written pool
|
|
519
|
+
* under "proven by construction" — the exact inversion `growsInPlace` was written to avoid,
|
|
520
|
+
* one level up.
|
|
521
|
+
*
|
|
522
|
+
* The distinction is not decorative: a drain worth the name is filled from somewhere this file
|
|
523
|
+
* cannot see (a handler, a caller, another module), and there the wait truly opens nothing.
|
|
524
|
+
*/
|
|
525
|
+
function collectGrownContainers(parsed: ts.SourceFile, measured: ReadonlySet<string>): ReadonlySet<string> {
|
|
526
|
+
const declaredIn = new Map<string, Set<ts.Node>>();
|
|
527
|
+
const mutations: (readonly [string, ts.Node])[] = [];
|
|
528
|
+
const scope: Scope = { fixed: new Set(), measured, grown: new Set() };
|
|
529
|
+
const visit = (node: ts.Node): void => {
|
|
530
|
+
if (
|
|
531
|
+
ts.isVariableDeclaration(node) &&
|
|
532
|
+
ts.isIdentifier(node.name) &&
|
|
533
|
+
node.initializer !== undefined &&
|
|
534
|
+
ts.isNewExpression(node.initializer)
|
|
535
|
+
) {
|
|
536
|
+
const containers = declaredIn.get(node.name.text) ?? new Set<ts.Node>();
|
|
537
|
+
containers.add(containerOf(node));
|
|
538
|
+
declaredIn.set(node.name.text, containers);
|
|
539
|
+
}
|
|
540
|
+
const filled = fillsInPlace(node);
|
|
541
|
+
if (filled !== undefined) {
|
|
542
|
+
mutations.push([filled, node]);
|
|
543
|
+
}
|
|
544
|
+
ts.forEachChild(node, visit);
|
|
545
|
+
};
|
|
546
|
+
visit(parsed);
|
|
547
|
+
|
|
548
|
+
const grown = new Set<string>();
|
|
549
|
+
for (const [name, mutation] of mutations) {
|
|
550
|
+
const containers = declaredIn.get(name);
|
|
551
|
+
if (containers !== undefined && fillsFromLoop(mutation, containers, scope)) {
|
|
552
|
+
grown.add(name);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return grown;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Iterating by call rather than by keyword: as many turns as there are items, all the same. */
|
|
559
|
+
const ITERATIONS = new Set(['forEach', 'map', 'flatMap']);
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Is this container filled in bursts, by a loop this file writes?
|
|
563
|
+
*
|
|
564
|
+
* The question is narrower than `growsUnboundedly`, and deliberately so. That one answers "how
|
|
565
|
+
* wide does this get", where a callback is unbounded because nothing says how often it runs.
|
|
566
|
+
* Here the question is "who opens the concurrency", and the answer is not the same: a container
|
|
567
|
+
* filled one promise at a time by a handler (`cycleLabelPoses.add(settled)` inside
|
|
568
|
+
* `trackCycleLabel`, `task-server.ts:1707`) never has two of them launched by the same line.
|
|
569
|
+
* Waiting on it opens nothing, whereas `for (const x of xs) running.add(go(x))` opens the lot.
|
|
570
|
+
*
|
|
571
|
+
* So only a loop counts, and `forEach`/`map` count as loops: the burst is what makes the
|
|
572
|
+
* concurrency, not the number of items in the end.
|
|
573
|
+
*/
|
|
574
|
+
function fillsFromLoop(mutation: ts.Node, containers: ReadonlySet<ts.Node>, scope: Scope): boolean {
|
|
575
|
+
let current: ts.Node | undefined = mutation.parent;
|
|
576
|
+
while (current !== undefined) {
|
|
577
|
+
if (ts.isForOfStatement(current) || ts.isForInStatement(current)) {
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
if (ts.isForStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current)) {
|
|
581
|
+
const condition = ts.isForStatement(current) ? current.condition : current.expression;
|
|
582
|
+
if (!isCappedComparison(condition, scope)) {
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (
|
|
587
|
+
ts.isCallExpression(current) &&
|
|
588
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
589
|
+
ITERATIONS.has(current.expression.name.text)
|
|
590
|
+
) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
if (containers.has(current)) {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
current = current.parent;
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The name of a container this node fills: `xs.add(p)`, `m.set(k, p)`, or an array push. */
|
|
602
|
+
function fillsInPlace(node: ts.Node): string | undefined {
|
|
603
|
+
if (!ts.isCallExpression(node)) {
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
return receiverOf(node, 'add') ?? receiverOf(node, 'set') ?? growsInPlace(node);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The function that carries this node, or the file when it sits at top level. */
|
|
610
|
+
function containerOf(node: ts.Node): ts.Node {
|
|
611
|
+
let current: ts.Node | undefined = node;
|
|
612
|
+
while (current !== undefined && !ts.isFunctionLike(current) && !ts.isSourceFile(current)) {
|
|
613
|
+
current = current.parent;
|
|
614
|
+
}
|
|
615
|
+
return current ?? node;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Does this insertion grow the array **with the input**, or by a number written here?
|
|
620
|
+
*
|
|
621
|
+
* A `push` is not enough to decide, and counting it as growth without looking at what runs
|
|
622
|
+
* it cost 10 sites across the five repos — `if (x) tasks.push(…)` three times in a row
|
|
623
|
+
* (`package-manager.ts:1130` in pi), or `for (let i = 0; i < this.config.minConnections; i++)`
|
|
624
|
+
* (`connection-pool.ts:136` in ruflo). Two shapes bounded in writing, flagged wrongly.
|
|
625
|
+
*
|
|
626
|
+
* What grows with the input, and nothing else:
|
|
627
|
+
*
|
|
628
|
+
* - a `for…of` / `for…in`: a collection is being iterated;
|
|
629
|
+
* - a loop whose condition is not a comparison against a ceiling. `while (true)` is one such
|
|
630
|
+
* loop: the bound `true` is not a ceiling, it is the absence of one;
|
|
631
|
+
* - a callback, that is, a function that does not carry the declaration: there is no telling
|
|
632
|
+
* how many times it runs.
|
|
633
|
+
*
|
|
634
|
+
* Everything else — straight line, loop with a capped condition — adds a number written in
|
|
635
|
+
* the file, so the width stays written.
|
|
636
|
+
*/
|
|
637
|
+
function growsUnboundedly(mutation: ts.Node, declaredIn: ReadonlySet<ts.Node> | undefined, scope: Scope): boolean {
|
|
638
|
+
if (declaredIn === undefined) {
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
let current: ts.Node | undefined = mutation.parent;
|
|
642
|
+
while (current !== undefined) {
|
|
643
|
+
if (ts.isForOfStatement(current) || ts.isForInStatement(current)) {
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
if (ts.isForStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current)) {
|
|
647
|
+
const condition = ts.isForStatement(current) ? current.condition : current.expression;
|
|
648
|
+
if (!isCappedComparison(condition, scope)) {
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (ts.isFunctionLike(current) || ts.isSourceFile(current)) {
|
|
653
|
+
return !declaredIn.has(current);
|
|
654
|
+
}
|
|
655
|
+
current = current.parent;
|
|
656
|
+
}
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* `i < this.config.minConnections` bounds the loop turn; `i < events.length` does not, and
|
|
662
|
+
* `true` even less. The comparison is required, because `isCeiling` holds as a ceiling
|
|
663
|
+
* everything it cannot read — useful on a length, wrong on a condition.
|
|
664
|
+
*/
|
|
665
|
+
function isCappedComparison(condition: ts.Expression | undefined, scope: Scope): boolean {
|
|
666
|
+
return (
|
|
667
|
+
condition !== undefined &&
|
|
668
|
+
ts.isBinaryExpression(condition) &&
|
|
669
|
+
COMPARISONS.has(condition.operatorToken.kind) &&
|
|
670
|
+
isCeiling(condition, scope)
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const COMPARISONS = new Set<ts.SyntaxKind>([
|
|
675
|
+
ts.SyntaxKind.LessThanToken,
|
|
676
|
+
ts.SyntaxKind.LessThanEqualsToken,
|
|
677
|
+
ts.SyntaxKind.GreaterThanToken,
|
|
678
|
+
ts.SyntaxKind.GreaterThanEqualsToken,
|
|
679
|
+
]);
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Is the width of this expression written here, rather than read back off an input
|
|
683
|
+
* collection? That is the question `resource()` asks, and an expression that answers it on
|
|
684
|
+
* its own must not be flagged: that would penalise the fix.
|
|
685
|
+
*
|
|
686
|
+
* The recursion descends along the calls that cannot grow an array — `map`, `filter`,
|
|
687
|
+
* `slice`, `fill`. `new Array(limit).fill(null).map(…)` is therefore bounded by `limit`,
|
|
688
|
+
* exactly like the `Array.from({ length: limit }, …)` it rewrites: that is the worker pool,
|
|
689
|
+
* measured on pi and prime-agent (`subagent/index.ts:228`).
|
|
690
|
+
*
|
|
691
|
+
* A spread hands the literal the width of what it spreads: `[...this.sessions.values()]` is
|
|
692
|
+
* a whole collection under the syntax of a written array. 20 of the 22 literals in receiver
|
|
693
|
+
* position found across the five repos were of that shape, and without this reservation the
|
|
694
|
+
* widening would have silenced them all — measured, 51 sites of `resource()`.
|
|
695
|
+
*
|
|
696
|
+
* The spread of a width itself written stays written: `[a(), ...(op ? [op] : [])]` makes one
|
|
697
|
+
* or two items, not a collection.
|
|
698
|
+
*/
|
|
699
|
+
function isBoundedWidth(node: ts.Expression, scope: Scope): boolean {
|
|
700
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
701
|
+
return isBoundedWidth(node.expression, scope);
|
|
702
|
+
}
|
|
703
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
704
|
+
return node.elements.every((element) => !ts.isSpreadElement(element) || isBoundedWidth(element.expression, scope));
|
|
705
|
+
}
|
|
706
|
+
// `cond ? [x] : []` is worth zero or one item: both branches are written, so the width
|
|
707
|
+
// is too. That is the optional argument of `agent-session.ts:8000` in prime-agent, and
|
|
708
|
+
// demanding it unspread counted it as a whole collection.
|
|
709
|
+
if (ts.isConditionalExpression(node)) {
|
|
710
|
+
return isBoundedWidth(node.whenTrue, scope) && isBoundedWidth(node.whenFalse, scope);
|
|
711
|
+
}
|
|
712
|
+
if (ts.isIdentifier(node)) {
|
|
713
|
+
return scope.fixed.has(node.text);
|
|
714
|
+
}
|
|
715
|
+
if (isDeclaredArity(node, scope) || isBoundedSlice(node, scope)) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
if (
|
|
719
|
+
ts.isCallExpression(node) &&
|
|
720
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
721
|
+
WIDTH_PRESERVING.has(node.expression.name.text)
|
|
722
|
+
) {
|
|
723
|
+
return isBoundedWidth(node.expression.expression, scope);
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The array methods that never return more items than they receive.
|
|
730
|
+
*
|
|
731
|
+
* `flat` and `flatMap` are absent on purpose: they flatten, so they can multiply. `concat`
|
|
732
|
+
* too.
|
|
733
|
+
*/
|
|
734
|
+
const WIDTH_PRESERVING = new Set(['map', 'filter', 'slice', 'fill']);
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The names throttled by a wait for a free slot: `await Promise.race(running)` when the batch
|
|
738
|
+
* is full, and `running.delete(…)` as soon as a promise settles.
|
|
739
|
+
*
|
|
740
|
+
* That is the concurrency pool written without a library, and the final `Promise.all(running)`
|
|
741
|
+
* drains only what is still in flight — at most the threshold, never the whole loop. It is
|
|
742
|
+
* the very answer `resource()` demands: flagging it would penalise the fix.
|
|
743
|
+
*
|
|
744
|
+
* The three signals are required together. Awaiting `race` without ever removing frees no
|
|
745
|
+
* slot; removing without awaiting slows nothing; and awaiting without looking at the size
|
|
746
|
+
* waits exactly once, which caps nothing either. The threshold is the piece that bounds:
|
|
747
|
+
* without it the rule would rest on an idiom, not on a reading. Real counter-example —
|
|
748
|
+
* `rpc-mode.ts:535` in prime-agent adds and removes without ever awaiting, and stays flagged.
|
|
749
|
+
*/
|
|
750
|
+
function collectThrottled(parsed: ts.SourceFile): ReadonlySet<string> {
|
|
751
|
+
const raced = new Set<string>();
|
|
752
|
+
const drained = new Set<string>();
|
|
753
|
+
const visit = (node: ts.Node): void => {
|
|
754
|
+
if (ts.isCallExpression(node)) {
|
|
755
|
+
const [only] = node.arguments;
|
|
756
|
+
if (isPromiseRace(node) && only !== undefined && ts.isIdentifier(only) && guardsOnWidth(node, only.text)) {
|
|
757
|
+
raced.add(only.text);
|
|
758
|
+
}
|
|
759
|
+
const removed = receiverOf(node, 'delete');
|
|
760
|
+
if (removed !== undefined) {
|
|
761
|
+
drained.add(removed);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
ts.forEachChild(node, visit);
|
|
765
|
+
};
|
|
766
|
+
visit(parsed);
|
|
767
|
+
return new Set([...raced].filter((name) => drained.has(name)));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* The name of an array this file grows, or `undefined`.
|
|
772
|
+
*
|
|
773
|
+
* `const feedItems: AppItem[] = []` is a literal, but a `.push()` further down turns it into
|
|
774
|
+
* an **accumulator**: its width is that of the loop filling it, not that of the literal.
|
|
775
|
+
* Classing it bounded inverted the verdict — the N+1 of `resolve-app-items.ts:67` came out
|
|
776
|
+
* filed under "proven by construction". Four sites in that monorepo hung on this.
|
|
777
|
+
*
|
|
778
|
+
* The removal applies to the name across the whole file, as the addition does: same failure
|
|
779
|
+
* of caution, taken on the side that flags rather than the side that stays silent.
|
|
780
|
+
*/
|
|
781
|
+
function growsInPlace(node: ts.Node): string | undefined {
|
|
782
|
+
if (!ts.isCallExpression(node)) {
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
const pushed = receiverOf(node, 'push') ?? receiverOf(node, 'unshift');
|
|
786
|
+
if (pushed !== undefined) {
|
|
787
|
+
return pushed;
|
|
788
|
+
}
|
|
789
|
+
// `splice(start, count, ...items)` only inserts from the third argument on:
|
|
790
|
+
// `xs.splice(0, 1)` removes, and an array that shrinks stays bounded by its declaration.
|
|
791
|
+
return node.arguments.length >= 3 ? receiverOf(node, 'splice') : undefined;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The name of the receiver of `X.method(…)`, when X is an identifier. */
|
|
795
|
+
function receiverOf(node: ts.CallExpression, method: string): string | undefined {
|
|
796
|
+
if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== method) {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
const receiver = node.expression.expression;
|
|
800
|
+
return ts.isIdentifier(receiver) ? receiver.text : undefined;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* `xs.slice(start, end)` whose width is written, never read back off the collection.
|
|
805
|
+
*
|
|
806
|
+
* Two spellings cover batching as it is actually practised:
|
|
807
|
+
* `slice(i, i + size)`, where the end is read from the start, and `slice(0, ceiling)`.
|
|
808
|
+
* `slice(0, xs.length)` is not one, and `slice(1)` is not either: with no end, all of it stays.
|
|
809
|
+
*/
|
|
810
|
+
function isBoundedSlice(node: ts.Expression, scope: Scope): boolean {
|
|
811
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'slice') {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
const [start, end] = node.arguments;
|
|
815
|
+
if (start === undefined || end === undefined) {
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
if (ts.isBinaryExpression(end) && end.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
819
|
+
const from = start.getText();
|
|
820
|
+
if (end.left.getText() === from) {
|
|
821
|
+
return isCeiling(end.right, scope);
|
|
822
|
+
}
|
|
823
|
+
if (end.right.getText() === from) {
|
|
824
|
+
return isCeiling(end.left, scope);
|
|
825
|
+
}
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
return ts.isNumericLiteral(start) && isCeiling(end, scope);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Is this `Promise.race(X)` under a condition that reads the width of X?
|
|
833
|
+
*
|
|
834
|
+
* That is the wait for a slot: `if (running.size >= ceiling) await Promise.race(running)`.
|
|
835
|
+
* The guard always sits immediately above the `race` in both measured sites
|
|
836
|
+
* (`test-utils.ts:250` in ruflo, `runtime-local-skill-import-panel.tsx:781` in multica).
|
|
837
|
+
*/
|
|
838
|
+
function guardsOnWidth(node: ts.Node, name: string): boolean {
|
|
839
|
+
let current: ts.Node | undefined = node.parent;
|
|
840
|
+
while (current !== undefined && !ts.isFunctionLike(current) && !ts.isSourceFile(current)) {
|
|
841
|
+
const condition = conditionOf(current);
|
|
842
|
+
if (condition !== undefined && readsWidthOf(condition, name)) {
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
current = current.parent;
|
|
846
|
+
}
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function conditionOf(node: ts.Node): ts.Expression | undefined {
|
|
851
|
+
if (ts.isIfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
|
|
852
|
+
return node.expression;
|
|
853
|
+
}
|
|
854
|
+
return ts.isForStatement(node) ? node.condition : undefined;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function readsWidthOf(node: ts.Node, name: string): boolean {
|
|
858
|
+
let found = false;
|
|
859
|
+
const visit = (child: ts.Node): void => {
|
|
860
|
+
if (found) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (
|
|
864
|
+
ts.isPropertyAccessExpression(child) &&
|
|
865
|
+
ts.isIdentifier(child.expression) &&
|
|
866
|
+
child.expression.text === name &&
|
|
867
|
+
(child.name.text === 'size' || child.name.text === 'length')
|
|
868
|
+
) {
|
|
869
|
+
found = true;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
ts.forEachChild(child, visit);
|
|
873
|
+
};
|
|
874
|
+
visit(node);
|
|
875
|
+
return found;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function isPromiseAll(node: ts.CallExpression): boolean {
|
|
879
|
+
return isPromiseMethod(node, 'all') || isPromiseMethod(node, 'allSettled');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function isPromiseRace(node: ts.CallExpression): boolean {
|
|
883
|
+
return isPromiseMethod(node, 'race');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function isPromiseMethod(node: ts.CallExpression, method: string): boolean {
|
|
887
|
+
const callee = node.expression;
|
|
888
|
+
return (
|
|
889
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
890
|
+
ts.isIdentifier(callee.expression) &&
|
|
891
|
+
callee.expression.text === 'Promise' &&
|
|
892
|
+
callee.name.text === method
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function isMapCall(node: ts.CallExpression): boolean {
|
|
897
|
+
return ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'map';
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Anchors to the `Promise.all` the `.map()` calls it fans out itself.
|
|
902
|
+
*
|
|
903
|
+
* `Promise.all(xs.map(f))` is **one** place, on which two rules each have their own
|
|
904
|
+
* question: `resource()` asks for the concurrency ceiling, `complexity()` asks whether the
|
|
905
|
+
* number of calls has to grow. Both stay open — but at the same address, that of the
|
|
906
|
+
* `Promise.all`. Without this anchoring they landed one line apart from each other, and the
|
|
907
|
+
* report announced two places where there is only one.
|
|
908
|
+
*
|
|
909
|
+
* The descent **stops at functions**. In `Promise.all(gs.map(async (g) => …))`, the outer
|
|
910
|
+
* `.map` is anchored, but a `.map` written in the callback body fans out something else:
|
|
911
|
+
* it keeps its own address.
|
|
912
|
+
*/
|
|
913
|
+
function markFannedOut(argument: ts.Expression, anchor: ts.Node, anchoredBy: Map<ts.Node, ts.Node>): void {
|
|
914
|
+
const walk = (node: ts.Node): void => {
|
|
915
|
+
if (ts.isFunctionLike(node)) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (ts.isCallExpression(node) && isMapCall(node)) {
|
|
919
|
+
anchoredBy.set(node, anchor);
|
|
920
|
+
}
|
|
921
|
+
ts.forEachChild(node, walk);
|
|
922
|
+
};
|
|
923
|
+
walk(argument);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function hasBoth(node: ts.ObjectLiteralExpression, left: string, right: string): boolean {
|
|
927
|
+
const names = new Set(
|
|
928
|
+
node.properties.flatMap((property) =>
|
|
929
|
+
property.name !== undefined && ts.isIdentifier(property.name) ? [property.name.text] : [],
|
|
930
|
+
),
|
|
931
|
+
);
|
|
932
|
+
return names.has(left) && names.has(right);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function excerpt(text: string): string {
|
|
936
|
+
const single = text.replaceAll(/\s+/gu, ' ');
|
|
937
|
+
return single.length <= EXCERPT_LENGTH ? single : `${single.slice(0, EXCERPT_LENGTH)}…`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* An array whose length is written at the site: `Array.from({ length: N }, …)`,
|
|
942
|
+
* `new Array(N)` and `Array(N)`. None of them spreads a collection — exactly N slots come
|
|
943
|
+
* out, N written here. That is the worker-pool pattern, the very answer `resource()`
|
|
944
|
+
* demands; flagging it would amount to penalising the fix.
|
|
945
|
+
*
|
|
946
|
+
* N still has to be a ceiling. `{ length: items.length }` and `new Array(items.length)`
|
|
947
|
+
* only read the collection size back under another name, and stay unbounded.
|
|
948
|
+
*
|
|
949
|
+
* `Array.from(aCollection)` is not one either: without the `{ length }` literal, the length
|
|
950
|
+
* comes from what is iterated. Measured on ruflo, six sites were of that shape.
|
|
951
|
+
*/
|
|
952
|
+
function isDeclaredArity(node: ts.Expression, scope: Scope): boolean {
|
|
953
|
+
if (ts.isCallExpression(node) && node.expression.getText() === 'Array.from') {
|
|
954
|
+
const [source] = node.arguments;
|
|
955
|
+
if (source === undefined || !ts.isObjectLiteralExpression(source)) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
const length = source.properties.find(
|
|
959
|
+
(property): property is ts.PropertyAssignment =>
|
|
960
|
+
ts.isPropertyAssignment(property) && property.name.getText() === 'length',
|
|
961
|
+
);
|
|
962
|
+
return length !== undefined && isCeiling(length.initializer, scope);
|
|
963
|
+
}
|
|
964
|
+
if (!ts.isNewExpression(node) && !ts.isCallExpression(node)) {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
if (!ts.isIdentifier(node.expression) || node.expression.text !== 'Array') {
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
// A single argument, and it is the length. `Array(a, b)` is a list of items, and
|
|
971
|
+
// `Array(...xs)` spreads one: neither of them writes a ceiling here.
|
|
972
|
+
const [size, ...rest] = node.arguments ?? [];
|
|
973
|
+
return size !== undefined && rest.length === 0 && !ts.isSpreadElement(size) && isCeiling(size, scope);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* A ceiling is an expression that no collection size makes grow.
|
|
978
|
+
*
|
|
979
|
+
* The reading is structural, because the top node alone lies: `Math.floor(keys.length * f)`
|
|
980
|
+
* is not a neutral call, it is `keys.length` in disguise. Real site — `sw.ts:367` in that
|
|
981
|
+
* monorepo, where the purged share of the cache grows with the cache. Without this descent,
|
|
982
|
+
* widening the sliced batch to the receiver silenced it.
|
|
983
|
+
*
|
|
984
|
+
* - `Math.min(a, b)`: a single capped term is enough, the minimum exceeds neither of them;
|
|
985
|
+
* - `Math.max(a, b)`: all of them are needed, the maximum exceeds each. That is what keeps
|
|
986
|
+
* `Math.max(1, Math.min(concurrency, items.length))` — the ceiling of pi's pool;
|
|
987
|
+
* - a rounding, a parenthesis, an operation: descend, and both sides are required;
|
|
988
|
+
* - `X.length`, `X.size`, and a name this file computes from either of the two: never.
|
|
989
|
+
*
|
|
990
|
+
* Everything else — literal, call, other property — is held to be a ceiling. The failure of
|
|
991
|
+
* caution flips here, and that is deliberate: `giveMeTheLimit()` is unreadable without
|
|
992
|
+
* running it, and refusing to conclude would flag every pool, that is, every fix.
|
|
993
|
+
*/
|
|
994
|
+
function isCeiling(expression: ts.Expression, scope: Scope): boolean {
|
|
995
|
+
if (ts.isParenthesizedExpression(expression)) {
|
|
996
|
+
return isCeiling(expression.expression, scope);
|
|
997
|
+
}
|
|
998
|
+
if (ts.isCallExpression(expression)) {
|
|
999
|
+
const callee = expression.expression.getText();
|
|
1000
|
+
if (callee === 'Math.min') {
|
|
1001
|
+
return expression.arguments.some((argument) => isCeiling(argument, scope));
|
|
1002
|
+
}
|
|
1003
|
+
if (callee === 'Math.max' || ROUNDING.has(callee)) {
|
|
1004
|
+
return expression.arguments.every((argument) => isCeiling(argument, scope));
|
|
1005
|
+
}
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
if (ts.isBinaryExpression(expression)) {
|
|
1009
|
+
// Subtracting can only reduce: `target - alreadyDone` never exceeds `target`, even
|
|
1010
|
+
// when what is subtracted is a size. `container-worker-pool.ts:283` in ruflo writes
|
|
1011
|
+
// exactly that, and requiring both sides flagged two sites wrongly. The reverse does
|
|
1012
|
+
// not hold: `pooledAgents.size - target` grows with the pool, and the left-hand side
|
|
1013
|
+
// says so — `agent-pool.ts:222`, which stays flagged.
|
|
1014
|
+
if (expression.operatorToken.kind === ts.SyntaxKind.MinusToken) {
|
|
1015
|
+
return isCeiling(expression.left, scope);
|
|
1016
|
+
}
|
|
1017
|
+
return isCeiling(expression.left, scope) && isCeiling(expression.right, scope);
|
|
1018
|
+
}
|
|
1019
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
1020
|
+
return expression.name.text !== 'length' && expression.name.text !== 'size';
|
|
1021
|
+
}
|
|
1022
|
+
if (ts.isIdentifier(expression)) {
|
|
1023
|
+
return !scope.measured.has(expression.text);
|
|
1024
|
+
}
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** The roundings, which do not change the magnitude of what they receive. */
|
|
1029
|
+
const ROUNDING = new Set(['Math.floor', 'Math.ceil', 'Math.round', 'Math.trunc', 'Math.abs']);
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* The names whose value this file computes from a collection size.
|
|
1033
|
+
*
|
|
1034
|
+
* `const toPurge = Math.floor(keys.length * fraction)` carries the name of a ceiling and is
|
|
1035
|
+
* not one: `keys.slice(0, toPurge)` grows with the cache. The name alone says nothing, only
|
|
1036
|
+
* its initialisation decides.
|
|
1037
|
+
*
|
|
1038
|
+
* The fixed point catches chains — `const n = xs.length` then `const m = n * 2`. The set can
|
|
1039
|
+
* only grow over a finite file: the loop terminates.
|
|
1040
|
+
*/
|
|
1041
|
+
function collectMeasured(parsed: ts.SourceFile): ReadonlySet<string> {
|
|
1042
|
+
const initializers: (readonly [string, ts.Expression])[] = [];
|
|
1043
|
+
const visit = (node: ts.Node): void => {
|
|
1044
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
|
|
1045
|
+
initializers.push([node.name.text, node.initializer]);
|
|
1046
|
+
}
|
|
1047
|
+
ts.forEachChild(node, visit);
|
|
1048
|
+
};
|
|
1049
|
+
visit(parsed);
|
|
1050
|
+
|
|
1051
|
+
const measured = new Set<string>();
|
|
1052
|
+
const scope: Scope = { fixed: new Set(), measured, grown: new Set() };
|
|
1053
|
+
let growing = true;
|
|
1054
|
+
while (growing) {
|
|
1055
|
+
growing = false;
|
|
1056
|
+
for (const [name, initializer] of initializers) {
|
|
1057
|
+
if (!measured.has(name) && !isCeiling(initializer, scope)) {
|
|
1058
|
+
measured.add(name);
|
|
1059
|
+
growing = true;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return measured;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Does the `.map()` callback contain a call whose return type is a promise, fired once per
|
|
1068
|
+
* item?
|
|
1069
|
+
*
|
|
1070
|
+
* Neither a name, nor an `await`, nor a surrounding `Promise.all` is assumed: in TypeScript,
|
|
1071
|
+
* what leaves the process returns a promise, and `async` propagates on its own up to every
|
|
1072
|
+
* caller. Measured on that monorepo, no surface-synchronous `.map()` hid I/O further down —
|
|
1073
|
+
* one level of type is enough.
|
|
1074
|
+
*
|
|
1075
|
+
* Two bounds, and they close the same error on either side of the dot.
|
|
1076
|
+
*
|
|
1077
|
+
* Downstream: the descent stops at the edge of the first function nested in the callback.
|
|
1078
|
+
* A call written in an `onClick` is not fired by the `.map()`: it is fired later, if the
|
|
1079
|
+
* user clicks. Without this bound, every list render whose rows carry a handler becomes a
|
|
1080
|
+
* fan-out — 17 of the 37 sites found on multica, 18 of the 46 on hermes-agent, all of it
|
|
1081
|
+
* JSX without a single `await`.
|
|
1082
|
+
*
|
|
1083
|
+
* Upstream: the receiver is not visited. In `(await listDir(root)).map((e) => e.path)`, the
|
|
1084
|
+
* promise is the receiver's — one call, once, then a synchronous `.map()`. Counting it would
|
|
1085
|
+
* amount to penalising the batched read, that is, the very answer that `complexity()`
|
|
1086
|
+
* demands. 18 of the 28 remaining sites in pi hung on this.
|
|
1087
|
+
*/
|
|
1088
|
+
function callsAsyncPerItem(call: ts.CallExpression, checker: ts.TypeChecker): boolean {
|
|
1089
|
+
let found = false;
|
|
1090
|
+
const visit = (child: ts.Node, deferred: boolean): void => {
|
|
1091
|
+
if (found) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (ts.isCallExpression(child) && child !== call && returnsPromise(child, checker) && !isDecoration(child, checker)) {
|
|
1095
|
+
found = true;
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (ts.isFunctionLike(child)) {
|
|
1099
|
+
if (deferred) {
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
ts.forEachChild(child, (grandchild) => visit(grandchild, true));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
ts.forEachChild(child, (grandchild) => visit(grandchild, deferred));
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
// The arguments alone: the receiver is set aside, and we are already inside the callback.
|
|
1109
|
+
for (const argument of call.arguments) {
|
|
1110
|
+
if (ts.isFunctionLike(argument)) {
|
|
1111
|
+
ts.forEachChild(argument, (child) => visit(child, true));
|
|
1112
|
+
} else {
|
|
1113
|
+
visit(argument, true);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return found;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** The handlers that attach to a promise instead of starting one. */
|
|
1120
|
+
const DECORATIONS = new Set(['then', 'catch', 'finally']);
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Does this call decorate a promise that is already running?
|
|
1124
|
+
*
|
|
1125
|
+
* `p.catch(() => null)` returns `Promise<…>` like any launch, so the return type alone counts
|
|
1126
|
+
* it as one outgoing call per item. It starts nothing: the promise was made elsewhere and only
|
|
1127
|
+
* gets a handler here. That is `task-isolation.ts:1031` in codesema-tools, a shutdown drain
|
|
1128
|
+
* counted as a fan-out.
|
|
1129
|
+
*
|
|
1130
|
+
* The receiver decides, not the method name: an `Actor.then()` of one's own is not a promise.
|
|
1131
|
+
* And the walk does not stop here — the receiver and the handler are visited as usual, so
|
|
1132
|
+
* `probe.run().finally(…)` is still one launch per item, through its receiver.
|
|
1133
|
+
*/
|
|
1134
|
+
function isDecoration(call: ts.CallExpression, checker: ts.TypeChecker): boolean {
|
|
1135
|
+
if (!ts.isPropertyAccessExpression(call.expression) || !DECORATIONS.has(call.expression.name.text)) {
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
return checker.typeToString(checker.getTypeAtLocation(call.expression.expression)).startsWith('Promise<');
|
|
1139
|
+
}
|