carrick 0.3.104 → 0.3.105
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/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/capture/anchors.d.ts +6 -0
- package/sidecar/dist/src/capture/anchors.js +78 -13
- package/sidecar/dist/src/capture/api.d.ts +22 -3
- package/sidecar/dist/src/capture/check-classify.d.ts +1 -0
- package/sidecar/dist/src/capture/check-classify.js +8 -12
- package/sidecar/dist/src/capture/check-fields.js +4 -6
- package/sidecar/dist/src/capture/check-probe.js +16 -6
- package/sidecar/dist/src/capture/check-scrub.d.ts +3 -0
- package/sidecar/dist/src/capture/check-scrub.js +8 -4
- package/sidecar/dist/src/capture/check.js +64 -50
- package/sidecar/dist/src/capture/deep-walk.d.ts +20 -0
- package/sidecar/dist/src/capture/deep-walk.js +51 -11
- package/sidecar/dist/src/capture/guarded-fs.d.ts +5 -1
- package/sidecar/dist/src/capture/guarded-fs.js +23 -1
- package/sidecar/dist/src/capture/index.js +6 -2
- package/sidecar/dist/src/capture/member-name.d.ts +20 -0
- package/sidecar/dist/src/capture/member-name.js +24 -0
- package/sidecar/dist/src/capture/self-check.js +41 -9
- package/sidecar/dist/src/failure-path.d.ts +36 -0
- package/sidecar/dist/src/failure-path.js +212 -0
- package/sidecar/dist/src/retype.js +15 -2
- package/sidecar/dist/src/type-inferrer.d.ts +2 -0
- package/sidecar/dist/src/type-inferrer.js +17 -0
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* wherever it is reported.
|
|
15
15
|
*/
|
|
16
16
|
import ts from 'typescript';
|
|
17
|
+
import { memberPath } from './member-name.js';
|
|
17
18
|
/** Cap on findings reported per alias. The FIRST one is what the check phase
|
|
18
19
|
* pre-gates on, so verdicts never depend on this number; the rest are there to
|
|
19
20
|
* tell a reader which fields are `any` (carrick#376), and a type with more
|
|
@@ -73,7 +74,7 @@ export function findUnresolvedPlaceholders(root, program, checker, location) {
|
|
|
73
74
|
}
|
|
74
75
|
/** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
|
|
75
76
|
* internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
|
|
76
|
-
function isErrorPlaceholder(t) {
|
|
77
|
+
export function isErrorPlaceholder(t) {
|
|
77
78
|
return ((t.flags & ts.TypeFlags.Any) !== 0 &&
|
|
78
79
|
t.intrinsicName === 'error');
|
|
79
80
|
}
|
|
@@ -230,7 +231,7 @@ function walkTopTypes(root, program, checker, location, flagOf) {
|
|
|
230
231
|
continue;
|
|
231
232
|
}
|
|
232
233
|
const propType = checker.getTypeOfSymbolAtLocation(prop, location);
|
|
233
|
-
walk(propType, path
|
|
234
|
+
walk(propType, memberPath(path, prop, checker), depth + 1);
|
|
234
235
|
if (exhausted)
|
|
235
236
|
return;
|
|
236
237
|
}
|
|
@@ -276,8 +277,8 @@ function disqualifyingFlag(t) {
|
|
|
276
277
|
}
|
|
277
278
|
return t.flags & ts.TypeFlags.Unknown ? 'unknown' : undefined;
|
|
278
279
|
}
|
|
279
|
-
/** How many
|
|
280
|
-
const
|
|
280
|
+
/** How many specifiers or names a detail lists before it counts the rest. */
|
|
281
|
+
const MAX_NAMED_IN_DETAIL = 3;
|
|
281
282
|
/**
|
|
282
283
|
* Turn a deep finding into the published provenance entry (carrick#376).
|
|
283
284
|
*
|
|
@@ -321,12 +322,51 @@ function unresolvedDetail(specifiers) {
|
|
|
321
322
|
const lead = "the type at this position did not resolve on the scanned checkout, so the compiler printed a placeholder 'any' rather than a declared type";
|
|
322
323
|
if (specifiers.length === 0)
|
|
323
324
|
return lead;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
return `${lead}; unresolved imports reachable from the anchor: ${quotedList(specifiers)}`;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The entry for one position at which the emitted tree holds the
|
|
329
|
+
* unresolved-reference placeholder (carrick#1446): `path` is `''` for the
|
|
330
|
+
* alias's own type. `names` are the identifiers the self-check could not find
|
|
331
|
+
* in this alias's statement and the files it reaches.
|
|
332
|
+
*
|
|
333
|
+
* The cause is `unresolved_import`, the word a reader already has for "a
|
|
334
|
+
* reference here did not resolve"; the detail says where it failed to.
|
|
335
|
+
*/
|
|
336
|
+
export function unresolvedInTreeProvenance(path, names) {
|
|
337
|
+
const where = path === '' ? 'this type' : 'the type at this position';
|
|
338
|
+
const lead = `${where} does not resolve in the declarations the capture emitted, so the ` +
|
|
339
|
+
"compiler reads it as 'any' although the printed text shows the name it could not follow";
|
|
340
|
+
return {
|
|
341
|
+
path,
|
|
342
|
+
kind: 'any',
|
|
343
|
+
reason: 'unresolved_import',
|
|
344
|
+
detail: names.length === 0
|
|
345
|
+
? lead
|
|
346
|
+
: `${lead}; names those declarations cannot find: ${quotedList(names)}`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The root entry for a literal anchor demoted because its text names a module
|
|
351
|
+
* whose declaration emit was skipped (carrick#1446, carrick#1165): that text is
|
|
352
|
+
* what the index serves for it, and nothing in the emitted tree declares what
|
|
353
|
+
* it imports.
|
|
354
|
+
*/
|
|
355
|
+
export function unemittedModuleProvenance() {
|
|
356
|
+
return {
|
|
357
|
+
path: '',
|
|
358
|
+
kind: 'any',
|
|
359
|
+
reason: 'unresolved_import',
|
|
360
|
+
detail: 'this type names a module whose declarations the capture could not emit, so it ' +
|
|
361
|
+
"does not resolve in the declarations the capture emitted and the compiler reads it as 'any'",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/** `'a', 'b', 'c' and 2 more`. */
|
|
365
|
+
function quotedList(items) {
|
|
366
|
+
const named = items
|
|
367
|
+
.slice(0, MAX_NAMED_IN_DETAIL)
|
|
368
|
+
.map((item) => `'${item}'`)
|
|
327
369
|
.join(', ');
|
|
328
|
-
const more =
|
|
329
|
-
|
|
330
|
-
: '';
|
|
331
|
-
return `${lead}; unresolved imports reachable from the anchor: ${named}${more}`;
|
|
370
|
+
const more = items.length > MAX_NAMED_IN_DETAIL ? ` and ${items.length - MAX_NAMED_IN_DETAIL} more` : '';
|
|
371
|
+
return `${named}${more}`;
|
|
332
372
|
}
|
|
@@ -42,7 +42,11 @@ export declare class WriteGuard {
|
|
|
42
42
|
private readonly files;
|
|
43
43
|
private readonly protect;
|
|
44
44
|
private constructor();
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* A guard over `roots`. Throws when a root equals or contains a protected
|
|
47
|
+
* tree, or when a directory root lies inside one anywhere but beneath a
|
|
48
|
+
* `.carrick` directory (carrick#1768).
|
|
49
|
+
*/
|
|
46
50
|
static of(roots: WriteRoots): WriteGuard;
|
|
47
51
|
/**
|
|
48
52
|
* A new, empty directory under `parent` (the OS temp dir by default) and a
|
|
@@ -40,7 +40,11 @@ export class WriteGuard {
|
|
|
40
40
|
this.files = files;
|
|
41
41
|
this.protect = protect;
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* A guard over `roots`. Throws when a root equals or contains a protected
|
|
45
|
+
* tree, or when a directory root lies inside one anywhere but beneath a
|
|
46
|
+
* `.carrick` directory (carrick#1768).
|
|
47
|
+
*/
|
|
44
48
|
static of(roots) {
|
|
45
49
|
const protect = (roots.protect ?? []).map(landing);
|
|
46
50
|
const dirs = (roots.dirs ?? []).map(landing);
|
|
@@ -51,6 +55,16 @@ export class WriteGuard {
|
|
|
51
55
|
throw new WriteRefused(`refused write root ${root}: it is or contains the scanned tree ${covered}`);
|
|
52
56
|
}
|
|
53
57
|
}
|
|
58
|
+
// Inside a scanned tree, a directory root is Carrick's only beneath a
|
|
59
|
+
// `.carrick` directory. Anywhere else it is the repo's own (a sibling
|
|
60
|
+
// service, say), and a stub dir is emptied before it is written. A file
|
|
61
|
+
// root is exempt: the surface entry has to sit inside rootDir.
|
|
62
|
+
for (const root of dirs) {
|
|
63
|
+
const inside = protect.find((tree) => within(tree, root) && !carrickOwned(tree, root));
|
|
64
|
+
if (inside !== undefined) {
|
|
65
|
+
throw new WriteRefused(`refused write root ${root}: it lies inside the scanned tree ${inside}, outside a .carrick directory`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
54
68
|
return new WriteGuard(dirs, files, protect);
|
|
55
69
|
}
|
|
56
70
|
/**
|
|
@@ -133,6 +147,14 @@ function within(root, p) {
|
|
|
133
147
|
const rel = path.relative(root, p);
|
|
134
148
|
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
135
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether `root`, inside `tree`, lies beneath a `.carrick` directory between
|
|
152
|
+
* the two. `.carrick` itself is not enough: it holds the workspace's proposal,
|
|
153
|
+
* jobs and scan logs. Both are resolved paths.
|
|
154
|
+
*/
|
|
155
|
+
function carrickOwned(tree, root) {
|
|
156
|
+
return path.relative(tree, root).split(path.sep).slice(0, -1).includes('.carrick');
|
|
157
|
+
}
|
|
136
158
|
function isDirectory(p) {
|
|
137
159
|
try {
|
|
138
160
|
return fs.statSync(p).isDirectory();
|
|
@@ -145,10 +145,13 @@ export function captureStub(opts) {
|
|
|
145
145
|
// Everything this capture writes, it writes through `guard` (carrick#1748):
|
|
146
146
|
// the stub dir, the staging dir, and the surface entry. A stub dir is
|
|
147
147
|
// emptied before it is written, so one that is or holds the repo is refused
|
|
148
|
-
// before anything is touched.
|
|
148
|
+
// before anything is touched. The scan root is protected too: a stub dir
|
|
149
|
+
// in a sibling service of the same repo is refused unless it sits beneath
|
|
150
|
+
// a `.carrick` directory (carrick#1768).
|
|
149
151
|
let guard;
|
|
150
152
|
try {
|
|
151
|
-
|
|
153
|
+
const protect = opts.scanRoot === undefined ? [repoRoot] : [repoRoot, path.resolve(opts.scanRoot)];
|
|
154
|
+
guard = WriteGuard.of({ dirs: [stubDir], protect });
|
|
152
155
|
}
|
|
153
156
|
catch (err) {
|
|
154
157
|
return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
|
|
@@ -563,6 +566,7 @@ function demoteDanglingAliases(args) {
|
|
|
563
566
|
serialization: 'structural_fallback',
|
|
564
567
|
failureReason: `declaration emit was skipped for module '${dangling}'; ` +
|
|
565
568
|
'alias demoted to keep the partially emitted tree usable',
|
|
569
|
+
namesUnemittedModule: true,
|
|
566
570
|
};
|
|
567
571
|
});
|
|
568
572
|
if (surfaceKey !== undefined && demoted.size > 0) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A member's place in a path, written the same way on every scan
|
|
3
|
+
* (carrick#1766).
|
|
4
|
+
*
|
|
5
|
+
* A member keyed by a unique symbol (`[Symbol.iterator]`, or a user's
|
|
6
|
+
* `const KEY: unique symbol`) has no name of its own. The checker escapes it as
|
|
7
|
+
* `__@<description>@<symbol id>`, and the id counts the symbols the process
|
|
8
|
+
* made before this one, so it differs between two scans of one tree and a
|
|
9
|
+
* stored sentence naming it changed on every scan. The checker prints such a
|
|
10
|
+
* member from its key instead, `[Symbol.iterator]`, which is how the source
|
|
11
|
+
* writes it.
|
|
12
|
+
*
|
|
13
|
+
* Every other member keeps `getName()`. A string key that starts with `__` is
|
|
14
|
+
* escaped with one more underscore, so it never reads as symbol-keyed here, and
|
|
15
|
+
* `symbolToString` would quote a key such as `content-type`, changing text that
|
|
16
|
+
* is already stable.
|
|
17
|
+
*/
|
|
18
|
+
import ts from 'typescript';
|
|
19
|
+
/** `parent.name`, or `parent[KEY]` for a member keyed by a unique symbol. */
|
|
20
|
+
export declare function memberPath(parent: string, member: ts.Symbol, checker: ts.TypeChecker): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A member's place in a path, written the same way on every scan
|
|
3
|
+
* (carrick#1766).
|
|
4
|
+
*
|
|
5
|
+
* A member keyed by a unique symbol (`[Symbol.iterator]`, or a user's
|
|
6
|
+
* `const KEY: unique symbol`) has no name of its own. The checker escapes it as
|
|
7
|
+
* `__@<description>@<symbol id>`, and the id counts the symbols the process
|
|
8
|
+
* made before this one, so it differs between two scans of one tree and a
|
|
9
|
+
* stored sentence naming it changed on every scan. The checker prints such a
|
|
10
|
+
* member from its key instead, `[Symbol.iterator]`, which is how the source
|
|
11
|
+
* writes it.
|
|
12
|
+
*
|
|
13
|
+
* Every other member keeps `getName()`. A string key that starts with `__` is
|
|
14
|
+
* escaped with one more underscore, so it never reads as symbol-keyed here, and
|
|
15
|
+
* `symbolToString` would quote a key such as `content-type`, changing text that
|
|
16
|
+
* is already stable.
|
|
17
|
+
*/
|
|
18
|
+
/** `parent.name`, or `parent[KEY]` for a member keyed by a unique symbol. */
|
|
19
|
+
export function memberPath(parent, member, checker) {
|
|
20
|
+
if (!member.escapedName.startsWith('__@')) {
|
|
21
|
+
return parent === '' ? member.getName() : `${parent}.${member.getName()}`;
|
|
22
|
+
}
|
|
23
|
+
return `${parent}${checker.symbolToString(member)}`;
|
|
24
|
+
}
|
|
@@ -34,7 +34,7 @@ import * as fs from 'node:fs';
|
|
|
34
34
|
import * as path from 'node:path';
|
|
35
35
|
import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
|
|
36
36
|
import { repairDanglingImports } from './repair-dangling.js';
|
|
37
|
-
import { findDisqualifyingTopTypes, provenanceOf, } from './deep-walk.js';
|
|
37
|
+
import { findDisqualifyingTopTypes, findUnresolvedPlaceholders, isErrorPlaceholder, provenanceOf, unemittedModuleProvenance, unresolvedInTreeProvenance, } from './deep-walk.js';
|
|
38
38
|
export function selfCheckStub(args) {
|
|
39
39
|
const typesDir = path.join(args.stubDir, 'types');
|
|
40
40
|
const treeFiles = [];
|
|
@@ -104,6 +104,7 @@ function runSelfCheck(args, treeFiles, repaired) {
|
|
|
104
104
|
const emptyFailures = () => ({
|
|
105
105
|
externalPinned: new Set(),
|
|
106
106
|
internal: new Set(),
|
|
107
|
+
unfoundNames: new Set(),
|
|
107
108
|
});
|
|
108
109
|
const bucketIn = (map, key) => {
|
|
109
110
|
let entry = map.get(key);
|
|
@@ -113,11 +114,24 @@ function runSelfCheck(args, treeFiles, repaired) {
|
|
|
113
114
|
}
|
|
114
115
|
return entry;
|
|
115
116
|
};
|
|
117
|
+
// A surface diagnostic outside every alias statement (a file-level import,
|
|
118
|
+
// a reference directive) is attributable to no alias and keeps the
|
|
119
|
+
// service-wide file bucket: soundness over precision, the same fallback
|
|
120
|
+
// check-poison.ts makes.
|
|
121
|
+
const bucketFor = (abs, start) => {
|
|
122
|
+
const owner = abs === surfaceAbs ? aliasAtSurfacePosition(start) : undefined;
|
|
123
|
+
return owner ? bucketIn(surfaceFailuresByAlias, owner) : bucketIn(failuresByFile, abs);
|
|
124
|
+
};
|
|
116
125
|
for (const d of diagnostics) {
|
|
117
126
|
if (!d.file)
|
|
118
127
|
continue;
|
|
119
128
|
const abs = path.resolve(d.file.fileName);
|
|
120
129
|
const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ');
|
|
130
|
+
// A name the compiler cannot find is the placeholder's cause wherever a
|
|
131
|
+
// type uses it; the record names it beside those positions (carrick#1446).
|
|
132
|
+
const unfound = /Cannot find (?:name|namespace) '([^']+)'/.exec(msg);
|
|
133
|
+
if (unfound)
|
|
134
|
+
bucketFor(abs, d.start).unfoundNames.add(unfound[1]);
|
|
121
135
|
// A name the repair did not reach: the import that bound it is gone, so
|
|
122
136
|
// the module is no longer reported missing and only this diagnostic is
|
|
123
137
|
// left to say the tree is incomplete. Blamed on the specifier that bound
|
|
@@ -133,14 +147,7 @@ function runSelfCheck(args, treeFiles, repaired) {
|
|
|
133
147
|
if (!m)
|
|
134
148
|
continue;
|
|
135
149
|
const spec = m[1];
|
|
136
|
-
|
|
137
|
-
// a reference directive) is attributable to no alias and keeps the
|
|
138
|
-
// service-wide file bucket: soundness over precision, the same fallback
|
|
139
|
-
// check-poison.ts makes.
|
|
140
|
-
const owner = abs === surfaceAbs ? aliasAtSurfacePosition(d.start) : undefined;
|
|
141
|
-
const bucket = owner
|
|
142
|
-
? bucketIn(surfaceFailuresByAlias, owner)
|
|
143
|
-
: bucketIn(failuresByFile, abs);
|
|
150
|
+
const bucket = bucketFor(abs, d.start);
|
|
144
151
|
if (!isRelative(spec) && args.pinned[packageNameOf(spec)]) {
|
|
145
152
|
bucket.externalPinned.add(spec);
|
|
146
153
|
}
|
|
@@ -254,12 +261,23 @@ function demotedRecord(anchor) {
|
|
|
254
261
|
self_check_detail: anchor.failureReason,
|
|
255
262
|
capture_failure_reason: anchor.failureReason,
|
|
256
263
|
top_type_at_self_check: true,
|
|
264
|
+
// A literal's text is what the index serves for it, and a module it
|
|
265
|
+
// names never reached the tree (carrick#1446). A symbol anchor's served
|
|
266
|
+
// declaration comes from elsewhere, so its demotion says nothing about it.
|
|
267
|
+
...(anchor.request.kind === 'literal' && anchor.namesUnemittedModule
|
|
268
|
+
? { unresolved_in_tree: [unemittedModuleProvenance()] }
|
|
269
|
+
: {}),
|
|
257
270
|
};
|
|
258
271
|
}
|
|
259
272
|
function checkedRecord(anchor, ctx) {
|
|
260
273
|
const alias = anchor.request.alias;
|
|
261
274
|
let topType = true;
|
|
262
275
|
let deepFindings = [];
|
|
276
|
+
// Where the alias's type holds the unresolved-reference placeholder in this
|
|
277
|
+
// tree (carrick#1446): `''` for its own type. The deep walk leaves the
|
|
278
|
+
// placeholder out because the check heals a pinned external; what the index
|
|
279
|
+
// publishes is this tree, so the record says where it does not resolve.
|
|
280
|
+
let unresolvedPaths = [];
|
|
263
281
|
const seeds = [];
|
|
264
282
|
if (ctx.surfaceSource) {
|
|
265
283
|
for (const stmt of ctx.surfaceSource.statements) {
|
|
@@ -270,6 +288,10 @@ function checkedRecord(anchor, ctx) {
|
|
|
270
288
|
(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) !== 0;
|
|
271
289
|
if (!topType) {
|
|
272
290
|
deepFindings = findDisqualifyingTopTypes(type, ctx.program, ctx.checker, stmt.name);
|
|
291
|
+
unresolvedPaths = findUnresolvedPlaceholders(type, ctx.program, ctx.checker, stmt.name);
|
|
292
|
+
}
|
|
293
|
+
else if (isErrorPlaceholder(type)) {
|
|
294
|
+
unresolvedPaths = [''];
|
|
273
295
|
}
|
|
274
296
|
// Seed the closure with the alias's own import-type targets.
|
|
275
297
|
const visit = (node) => {
|
|
@@ -300,6 +322,7 @@ function checkedRecord(anchor, ctx) {
|
|
|
300
322
|
let blamedExternal;
|
|
301
323
|
let internalFailure;
|
|
302
324
|
const danglingSpecifiers = new Set();
|
|
325
|
+
const unfoundNames = new Set();
|
|
303
326
|
// This alias's own surface statement, then the closure's files. The surface
|
|
304
327
|
// file bucket now holds only the diagnostics no alias statement covers.
|
|
305
328
|
const closureFailures = [
|
|
@@ -315,6 +338,8 @@ function checkedRecord(anchor, ctx) {
|
|
|
315
338
|
internalFailure = [...failures.internal][0];
|
|
316
339
|
for (const specifier of failures.internal)
|
|
317
340
|
danglingSpecifiers.add(specifier);
|
|
341
|
+
for (const name of failures.unfoundNames)
|
|
342
|
+
unfoundNames.add(name);
|
|
318
343
|
}
|
|
319
344
|
// Classification consults the closure failures REGARDLESS of the root
|
|
320
345
|
// type: a dangling internal specifier means part of this alias's closure
|
|
@@ -416,6 +441,13 @@ function checkedRecord(anchor, ctx) {
|
|
|
416
441
|
? { dangling_specifiers: [...danglingSpecifiers].sort() }
|
|
417
442
|
: {}),
|
|
418
443
|
...(anchor.undeclaredNames ? { undeclared_names: anchor.undeclaredNames } : {}),
|
|
444
|
+
...(unresolvedPaths.length > 0
|
|
445
|
+
? {
|
|
446
|
+
unresolved_in_tree: [...unresolvedPaths]
|
|
447
|
+
.sort()
|
|
448
|
+
.map((p) => unresolvedInTreeProvenance(p, [...unfoundNames].sort())),
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
419
451
|
};
|
|
420
452
|
}
|
|
421
453
|
/** Resolve a relative specifier from `fromAbs` to a tree file, if present. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the source reaches a node only when an HTTP response FAILED
|
|
3
|
+
* (carrick#1796).
|
|
4
|
+
*
|
|
5
|
+
* A consumer reads the success body on one path and the error text or the
|
|
6
|
+
* error body on another. Only the first is the call's response contract, so
|
|
7
|
+
* the def-use walk behind a `call_result` row must not take a read from the
|
|
8
|
+
* second. Which path a read is on is told by the source's own tests of the
|
|
9
|
+
* response, read two ways:
|
|
10
|
+
*
|
|
11
|
+
* - the read sits in a branch of a test: `if (!res.ok) { ... }`, the `else`
|
|
12
|
+
* of `if (res.ok)`, either arm of a conditional expression;
|
|
13
|
+
* - the read follows an `if` whose other branch cannot complete, so the rest
|
|
14
|
+
* of the block runs only on the side that did not leave:
|
|
15
|
+
* `if (res.ok) { return ... } const text = await res.text()`.
|
|
16
|
+
*
|
|
17
|
+
* A test is read as the set of statuses it lets through. `ok` is true for
|
|
18
|
+
* 200-299 and false for every other status; a comparison of the status with a
|
|
19
|
+
* number lets through what it admits; `!`, `&&` and `||` combine the sets, and
|
|
20
|
+
* any other condition lets everything through. A node is on the failure path
|
|
21
|
+
* only when NO status in 200-299 can reach it.
|
|
22
|
+
*
|
|
23
|
+
* That is deliberately stricter than the retype check's reading of a status
|
|
24
|
+
* test (`okWhenTrue` in retype.ts), which reads the false side of
|
|
25
|
+
* `res.status === 200` as the failure path so it can find a success read to
|
|
26
|
+
* judge. Here a decided failure REMOVES a read, so the reading has to be sound
|
|
27
|
+
* the other way round: after `if (res.status === 204) return null`, 200 still
|
|
28
|
+
* gets through, and the json read that follows is the payload.
|
|
29
|
+
*/
|
|
30
|
+
import { Node } from 'ts-morph';
|
|
31
|
+
/**
|
|
32
|
+
* True when the source reaches `node` only after the response named by
|
|
33
|
+
* `isResponse` failed. The walk climbs from `node` to `boundary` (the function
|
|
34
|
+
* the call sits in) and never looks at a test outside it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function reachedOnlyOnFailure(node: Node, boundary: Node, isResponse: (node: Node) => boolean): boolean;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the source reaches a node only when an HTTP response FAILED
|
|
3
|
+
* (carrick#1796).
|
|
4
|
+
*
|
|
5
|
+
* A consumer reads the success body on one path and the error text or the
|
|
6
|
+
* error body on another. Only the first is the call's response contract, so
|
|
7
|
+
* the def-use walk behind a `call_result` row must not take a read from the
|
|
8
|
+
* second. Which path a read is on is told by the source's own tests of the
|
|
9
|
+
* response, read two ways:
|
|
10
|
+
*
|
|
11
|
+
* - the read sits in a branch of a test: `if (!res.ok) { ... }`, the `else`
|
|
12
|
+
* of `if (res.ok)`, either arm of a conditional expression;
|
|
13
|
+
* - the read follows an `if` whose other branch cannot complete, so the rest
|
|
14
|
+
* of the block runs only on the side that did not leave:
|
|
15
|
+
* `if (res.ok) { return ... } const text = await res.text()`.
|
|
16
|
+
*
|
|
17
|
+
* A test is read as the set of statuses it lets through. `ok` is true for
|
|
18
|
+
* 200-299 and false for every other status; a comparison of the status with a
|
|
19
|
+
* number lets through what it admits; `!`, `&&` and `||` combine the sets, and
|
|
20
|
+
* any other condition lets everything through. A node is on the failure path
|
|
21
|
+
* only when NO status in 200-299 can reach it.
|
|
22
|
+
*
|
|
23
|
+
* That is deliberately stricter than the retype check's reading of a status
|
|
24
|
+
* test (`okWhenTrue` in retype.ts), which reads the false side of
|
|
25
|
+
* `res.status === 200` as the failure path so it can find a success read to
|
|
26
|
+
* judge. Here a decided failure REMOVES a read, so the reading has to be sound
|
|
27
|
+
* the other way round: after `if (res.status === 204) return null`, 200 still
|
|
28
|
+
* gets through, and the json read that follows is the payload.
|
|
29
|
+
*/
|
|
30
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
31
|
+
const FIRST_STATUS = 100;
|
|
32
|
+
const LAST_STATUS = 599;
|
|
33
|
+
const EVERY_STATUS = () => true;
|
|
34
|
+
/** The statuses `ok` is true for. */
|
|
35
|
+
const SUCCEEDED = (status) => status >= 200 && status <= 299;
|
|
36
|
+
const UNDECIDED = { whenTrue: EVERY_STATUS, whenFalse: EVERY_STATUS };
|
|
37
|
+
/**
|
|
38
|
+
* True when the source reaches `node` only after the response named by
|
|
39
|
+
* `isResponse` failed. The walk climbs from `node` to `boundary` (the function
|
|
40
|
+
* the call sits in) and never looks at a test outside it.
|
|
41
|
+
*/
|
|
42
|
+
export function reachedOnlyOnFailure(node, boundary, isResponse) {
|
|
43
|
+
let admitted = EVERY_STATUS;
|
|
44
|
+
let narrowed = false;
|
|
45
|
+
const narrow = (by) => {
|
|
46
|
+
if (by === EVERY_STATUS)
|
|
47
|
+
return;
|
|
48
|
+
const before = admitted;
|
|
49
|
+
admitted = (status) => before(status) && by(status);
|
|
50
|
+
narrowed = true;
|
|
51
|
+
};
|
|
52
|
+
for (let child = node, parent = node.getParent(); parent && parent !== boundary; child = parent, parent = parent.getParent()) {
|
|
53
|
+
if (Node.isIfStatement(parent)) {
|
|
54
|
+
if (child === parent.getThenStatement()) {
|
|
55
|
+
narrow(readTest(parent.getExpression(), isResponse).whenTrue);
|
|
56
|
+
}
|
|
57
|
+
else if (child === parent.getElseStatement()) {
|
|
58
|
+
narrow(readTest(parent.getExpression(), isResponse).whenFalse);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (Node.isConditionalExpression(parent)) {
|
|
62
|
+
if (child === parent.getWhenTrue()) {
|
|
63
|
+
narrow(readTest(parent.getCondition(), isResponse).whenTrue);
|
|
64
|
+
}
|
|
65
|
+
else if (child === parent.getWhenFalse()) {
|
|
66
|
+
narrow(readTest(parent.getCondition(), isResponse).whenFalse);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (Node.isBlock(parent) ||
|
|
70
|
+
Node.isSourceFile(parent) ||
|
|
71
|
+
Node.isCaseClause(parent) ||
|
|
72
|
+
Node.isDefaultClause(parent)) {
|
|
73
|
+
for (const statement of parent.getStatements()) {
|
|
74
|
+
if (statement === child)
|
|
75
|
+
break;
|
|
76
|
+
if (!Node.isIfStatement(statement))
|
|
77
|
+
continue;
|
|
78
|
+
const otherwise = statement.getElseStatement();
|
|
79
|
+
const thenLeaves = cannotComplete(statement.getThenStatement());
|
|
80
|
+
const elseLeaves = otherwise !== undefined && cannotComplete(otherwise);
|
|
81
|
+
if (thenLeaves && !elseLeaves) {
|
|
82
|
+
narrow(readTest(statement.getExpression(), isResponse).whenFalse);
|
|
83
|
+
}
|
|
84
|
+
else if (elseLeaves && !thenLeaves) {
|
|
85
|
+
narrow(readTest(statement.getExpression(), isResponse).whenTrue);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!narrowed)
|
|
91
|
+
return false;
|
|
92
|
+
let reachable = false;
|
|
93
|
+
for (let status = FIRST_STATUS; status <= LAST_STATUS; status++) {
|
|
94
|
+
if (!admitted(status))
|
|
95
|
+
continue;
|
|
96
|
+
if (SUCCEEDED(status))
|
|
97
|
+
return false;
|
|
98
|
+
reachable = true;
|
|
99
|
+
}
|
|
100
|
+
return reachable;
|
|
101
|
+
}
|
|
102
|
+
/** The statuses `condition` lets through when it is true and when it is false. */
|
|
103
|
+
function readTest(condition, isResponse) {
|
|
104
|
+
let test = condition;
|
|
105
|
+
while (Node.isParenthesizedExpression(test))
|
|
106
|
+
test = test.getExpression();
|
|
107
|
+
if (Node.isPrefixUnaryExpression(test) &&
|
|
108
|
+
test.getOperatorToken() === SyntaxKind.ExclamationToken) {
|
|
109
|
+
const inner = readTest(test.getOperand(), isResponse);
|
|
110
|
+
return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
111
|
+
}
|
|
112
|
+
if (isMemberOfResponse(test, 'ok', isResponse)) {
|
|
113
|
+
return { whenTrue: SUCCEEDED, whenFalse: (status) => !SUCCEEDED(status) };
|
|
114
|
+
}
|
|
115
|
+
if (Node.isBinaryExpression(test)) {
|
|
116
|
+
const operator = test.getOperatorToken().getKind();
|
|
117
|
+
if (operator === SyntaxKind.AmpersandAmpersandToken ||
|
|
118
|
+
operator === SyntaxKind.BarBarToken) {
|
|
119
|
+
const left = readTest(test.getLeft(), isResponse);
|
|
120
|
+
const right = readTest(test.getRight(), isResponse);
|
|
121
|
+
if (left === UNDECIDED && right === UNDECIDED)
|
|
122
|
+
return UNDECIDED;
|
|
123
|
+
return operator === SyntaxKind.AmpersandAmpersandToken
|
|
124
|
+
? {
|
|
125
|
+
whenTrue: (status) => left.whenTrue(status) && right.whenTrue(status),
|
|
126
|
+
whenFalse: (status) => left.whenFalse(status) || right.whenFalse(status),
|
|
127
|
+
}
|
|
128
|
+
: {
|
|
129
|
+
whenTrue: (status) => left.whenTrue(status) || right.whenTrue(status),
|
|
130
|
+
whenFalse: (status) => left.whenFalse(status) && right.whenFalse(status),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const compared = statusComparison(test.getLeft(), operator, test.getRight(), isResponse);
|
|
134
|
+
if (compared) {
|
|
135
|
+
return { whenTrue: compared, whenFalse: (status) => !compared(status) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return UNDECIDED;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* `res.status <op> N` or `N <op> res.status`, as the statuses it is true for;
|
|
142
|
+
* `undefined` when the comparison is not one of the response's status with a
|
|
143
|
+
* number.
|
|
144
|
+
*/
|
|
145
|
+
function statusComparison(left, operator, right, isResponse) {
|
|
146
|
+
let op = operator;
|
|
147
|
+
let value;
|
|
148
|
+
if (isMemberOfResponse(left, 'status', isResponse) && Node.isNumericLiteral(right)) {
|
|
149
|
+
value = right.getLiteralValue();
|
|
150
|
+
}
|
|
151
|
+
else if (isMemberOfResponse(right, 'status', isResponse) && Node.isNumericLiteral(left)) {
|
|
152
|
+
value = left.getLiteralValue();
|
|
153
|
+
op = MIRRORED[op] ?? op;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
switch (op) {
|
|
159
|
+
case SyntaxKind.EqualsEqualsEqualsToken:
|
|
160
|
+
case SyntaxKind.EqualsEqualsToken:
|
|
161
|
+
return (status) => status === value;
|
|
162
|
+
case SyntaxKind.ExclamationEqualsEqualsToken:
|
|
163
|
+
case SyntaxKind.ExclamationEqualsToken:
|
|
164
|
+
return (status) => status !== value;
|
|
165
|
+
case SyntaxKind.LessThanToken:
|
|
166
|
+
return (status) => status < value;
|
|
167
|
+
case SyntaxKind.LessThanEqualsToken:
|
|
168
|
+
return (status) => status <= value;
|
|
169
|
+
case SyntaxKind.GreaterThanToken:
|
|
170
|
+
return (status) => status > value;
|
|
171
|
+
case SyntaxKind.GreaterThanEqualsToken:
|
|
172
|
+
return (status) => status >= value;
|
|
173
|
+
default:
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** `400 <= res.status` reads as `res.status >= 400`. */
|
|
178
|
+
const MIRRORED = {
|
|
179
|
+
[SyntaxKind.LessThanToken]: SyntaxKind.GreaterThanToken,
|
|
180
|
+
[SyntaxKind.LessThanEqualsToken]: SyntaxKind.GreaterThanEqualsToken,
|
|
181
|
+
[SyntaxKind.GreaterThanToken]: SyntaxKind.LessThanToken,
|
|
182
|
+
[SyntaxKind.GreaterThanEqualsToken]: SyntaxKind.LessThanEqualsToken,
|
|
183
|
+
};
|
|
184
|
+
function isMemberOfResponse(node, name, isResponse) {
|
|
185
|
+
return (Node.isPropertyAccessExpression(node) &&
|
|
186
|
+
node.getName() === name &&
|
|
187
|
+
isResponse(node.getExpression()));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A statement that never runs on into the statement after it: it returns,
|
|
191
|
+
* throws, breaks or continues, or every path through it does. A loop, a
|
|
192
|
+
* `switch` or a `try` is read as one that can complete, which only ever
|
|
193
|
+
* leaves a read on the path it was on.
|
|
194
|
+
*/
|
|
195
|
+
function cannotComplete(statement) {
|
|
196
|
+
if (Node.isReturnStatement(statement) ||
|
|
197
|
+
Node.isThrowStatement(statement) ||
|
|
198
|
+
Node.isBreakStatement(statement) ||
|
|
199
|
+
Node.isContinueStatement(statement)) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (Node.isBlock(statement)) {
|
|
203
|
+
return statement.getStatements().some((inner) => cannotComplete(inner));
|
|
204
|
+
}
|
|
205
|
+
if (Node.isIfStatement(statement)) {
|
|
206
|
+
const otherwise = statement.getElseStatement();
|
|
207
|
+
return (otherwise !== undefined &&
|
|
208
|
+
cannotComplete(statement.getThenStatement()) &&
|
|
209
|
+
cannotComplete(otherwise));
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
@@ -735,11 +735,23 @@ function branchSide(condition, whenTrue, isResponse) {
|
|
|
735
735
|
return ok;
|
|
736
736
|
return ok === whenTrue ? 'success' : 'failure';
|
|
737
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* The success statuses whose response carries no content (RFC 9110: 204 No
|
|
740
|
+
* Content, 205 Reset Content).
|
|
741
|
+
*/
|
|
742
|
+
const NO_CONTENT_STATUSES = new Set([204, 205]);
|
|
738
743
|
/**
|
|
739
744
|
* Whether `condition` being true means the response succeeded: `res.ok`,
|
|
740
745
|
* `res.status === 200`, `res.status !== 200`, `res.status >= 400` and their
|
|
741
746
|
* negations. Any other test of the response is `'unclear'`; a condition that does not
|
|
742
747
|
* test the response is `undefined`.
|
|
748
|
+
*
|
|
749
|
+
* An equality or inequality with a no-content status is `undefined` too
|
|
750
|
+
* (carrick#1813). The retype only runs against a producer that publishes a
|
|
751
|
+
* response body, and that body never arrives with a 204 or 205, so
|
|
752
|
+
* `if (res.status === 204) return null` only takes away a status the body
|
|
753
|
+
* cannot come with. The read after it sits where a read no test decides
|
|
754
|
+
* sits, not on the error path.
|
|
743
755
|
*/
|
|
744
756
|
function okWhenTrue(condition, isResponse) {
|
|
745
757
|
let e = condition;
|
|
@@ -759,13 +771,14 @@ function okWhenTrue(condition, isResponse) {
|
|
|
759
771
|
: undefined;
|
|
760
772
|
if (value !== undefined) {
|
|
761
773
|
const success = value >= 200 && value < 300;
|
|
774
|
+
const noContent = NO_CONTENT_STATUSES.has(value);
|
|
762
775
|
switch (op) {
|
|
763
776
|
case SyntaxKind.EqualsEqualsEqualsToken:
|
|
764
777
|
case SyntaxKind.EqualsEqualsToken:
|
|
765
|
-
return success;
|
|
778
|
+
return noContent ? undefined : success;
|
|
766
779
|
case SyntaxKind.ExclamationEqualsEqualsToken:
|
|
767
780
|
case SyntaxKind.ExclamationEqualsToken:
|
|
768
|
-
return !success;
|
|
781
|
+
return noContent ? undefined : !success;
|
|
769
782
|
case SyntaxKind.GreaterThanEqualsToken:
|
|
770
783
|
if (value >= 300)
|
|
771
784
|
return false;
|
|
@@ -434,6 +434,8 @@ export declare class TypeInferrer {
|
|
|
434
434
|
private bodyReadOnReceiver;
|
|
435
435
|
private collectDefUseNodes;
|
|
436
436
|
private expressionUsesNames;
|
|
437
|
+
/** `expr` is a use of a tracked name, or contains one. */
|
|
438
|
+
private usesTrackedNames;
|
|
437
439
|
private isIdentifierUsage;
|
|
438
440
|
private isInFunctionScope;
|
|
439
441
|
/**
|