carrick 0.3.92 → 0.3.94
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/README.md +2 -1
- package/dist/auth/oauth.d.ts +8 -0
- package/dist/auth/oauth.js +9 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/read.d.ts +5 -0
- package/dist/auth/read.js +11 -1
- package/dist/auth/read.js.map +1 -1
- package/dist/auth/run.d.ts +10 -0
- package/dist/auth/run.js +16 -0
- package/dist/auth/run.js.map +1 -1
- package/dist/init/connect.d.ts +19 -7
- package/dist/init/connect.js +29 -16
- package/dist/init/connect.js.map +1 -1
- package/dist/init/projects.d.ts +16 -3
- package/dist/init/projects.js +27 -18
- package/dist/init/projects.js.map +1 -1
- package/dist/init/remove.js +28 -5
- package/dist/init/remove.js.map +1 -1
- package/dist/init/repo-copies.d.ts +56 -0
- package/dist/init/repo-copies.js +241 -0
- package/dist/init/repo-copies.js.map +1 -0
- package/dist/init/repos.d.ts +27 -0
- package/dist/init/repos.js +67 -0
- package/dist/init/repos.js.map +1 -1
- package/dist/init/run.d.ts +109 -48
- package/dist/init/run.js +331 -154
- package/dist/init/run.js.map +1 -1
- package/dist/init/task-skills.d.ts +6 -6
- package/dist/init/task-skills.js +9 -9
- package/dist/init/task-skills.js.map +1 -1
- package/package.json +6 -6
- package/sidecar/dist/src/capture/check-probe.d.ts +6 -1
- package/sidecar/dist/src/capture/check-probe.js +7 -2
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +1 -0
- package/sidecar/dist/src/index.js +8 -2
- package/sidecar/dist/src/retype.d.ts +46 -4
- package/sidecar/dist/src/retype.js +108 -19
- package/sidecar/dist/src/type-inferrer.d.ts +33 -0
- package/sidecar/dist/src/type-inferrer.js +211 -1
- package/sidecar/dist/src/types.d.ts +20 -1
- package/sidecar/dist/src/unwidened.d.ts +101 -0
- package/sidecar/dist/src/unwidened.js +272 -0
- package/sidecar/dist/src/validators.d.ts +10 -0
- package/sidecar/dist/src/validators.js +1 -0
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* project other requests read is the project the scan loaded.
|
|
26
26
|
*/
|
|
27
27
|
import { Node, SyntaxKind, ts } from 'ts-morph';
|
|
28
|
+
import { fileDiagnostics } from './unwidened.js';
|
|
28
29
|
/** Names appended to a file that is not ours carry this prefix. */
|
|
29
30
|
const PREFIX = '__carrick_';
|
|
30
31
|
const WIRE = `${PREFIX}Wire`;
|
|
@@ -32,16 +33,19 @@ export class Retyper {
|
|
|
32
33
|
project;
|
|
33
34
|
inferrer;
|
|
34
35
|
jsonWire;
|
|
36
|
+
topTypes;
|
|
35
37
|
/**
|
|
36
38
|
* `jsonWire` is the check phase's own JSON wire transform
|
|
37
|
-
* (`jsonWireDeclarations` in the capture bundle)
|
|
38
|
-
*
|
|
39
|
-
* crossing the bundle
|
|
39
|
+
* (`jsonWireDeclarations` in the capture bundle) and `topTypes` its
|
|
40
|
+
* any/unknown walk, handed in by the entry point so both judges read the
|
|
41
|
+
* wire and a top type the same way without this file crossing the bundle
|
|
42
|
+
* seam.
|
|
40
43
|
*/
|
|
41
|
-
constructor(project, inferrer, jsonWire) {
|
|
44
|
+
constructor(project, inferrer, jsonWire, topTypes) {
|
|
42
45
|
this.project = project;
|
|
43
46
|
this.inferrer = inferrer;
|
|
44
47
|
this.jsonWire = jsonWire;
|
|
48
|
+
this.topTypes = topTypes;
|
|
45
49
|
}
|
|
46
50
|
/**
|
|
47
51
|
* Judge every item, spending at most `budgetMs`. Each item rebuilds the
|
|
@@ -93,6 +97,8 @@ export class Retyper {
|
|
|
93
97
|
const plan = this.plan(call, producer, item.wire);
|
|
94
98
|
if (typeof plan === 'string')
|
|
95
99
|
return abstain(item, plan);
|
|
100
|
+
// Planned now: a check forgets every node of the file, `call` included.
|
|
101
|
+
const unwidened = this.unwidenedPlan(call, item);
|
|
96
102
|
const original = sourceFile.getFullText();
|
|
97
103
|
let pre = before.get(sourceFile);
|
|
98
104
|
if (!pre) {
|
|
@@ -119,10 +125,21 @@ export class Retyper {
|
|
|
119
125
|
messages = new Map(plain.added.map((d) => [key(d), d.message]));
|
|
120
126
|
}
|
|
121
127
|
}
|
|
128
|
+
// carrick#1516: the published type is what the compiler inferred, with
|
|
129
|
+
// any literal the handler returns widened. When the handler's own return,
|
|
130
|
+
// read before that widening, raises nothing, the producer's type is wider
|
|
131
|
+
// than what it sends: its own verdict class, not a break. Anything short
|
|
132
|
+
// of a clean check (a diagnostic, an abstain) leaves the mismatch.
|
|
133
|
+
let outcome = 'mismatch';
|
|
134
|
+
if (unwidened) {
|
|
135
|
+
const narrowed = this.check(sourceFile, original, unwidened(true), pre);
|
|
136
|
+
if (narrowed.kind === 'checked' && narrowed.added.length === 0)
|
|
137
|
+
outcome = 'wider';
|
|
138
|
+
}
|
|
122
139
|
const lineOf = lineIndex(original);
|
|
123
140
|
return {
|
|
124
141
|
item_id: item.item_id,
|
|
125
|
-
outcome
|
|
142
|
+
outcome,
|
|
126
143
|
diagnostics: decisive.added.map((d) => ({
|
|
127
144
|
line: lineOf(d.start),
|
|
128
145
|
code: d.code,
|
|
@@ -130,6 +147,17 @@ export class Retyper {
|
|
|
130
147
|
})),
|
|
131
148
|
};
|
|
132
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The rewrite that states the producer's UNWIDENED return (carrick#1516),
|
|
152
|
+
* when the item carries one that can be stated at this call.
|
|
153
|
+
*/
|
|
154
|
+
unwidenedPlan(call, item) {
|
|
155
|
+
const text = oneLine(item.producer_unwidened_type ?? '');
|
|
156
|
+
if (!text || text.includes('//') || text.includes('/*'))
|
|
157
|
+
return undefined;
|
|
158
|
+
const plan = this.plan(call, text, item.wire);
|
|
159
|
+
return typeof plan === 'string' ? undefined : plan;
|
|
160
|
+
}
|
|
133
161
|
/**
|
|
134
162
|
* How to state the producer's type at this call, or why it cannot be done.
|
|
135
163
|
* Returns a builder so the wire and the declared form share one decision.
|
|
@@ -150,7 +178,8 @@ export class Retyper {
|
|
|
150
178
|
const text = stated(useWire);
|
|
151
179
|
const delta = text.length - (argEnd - argStart);
|
|
152
180
|
return {
|
|
153
|
-
edits: [{ start: argStart, end: argEnd, text }],
|
|
181
|
+
edits: [{ start: argStart, end: argEnd, text, statedAt: 0 }],
|
|
182
|
+
stated: text,
|
|
154
183
|
wire: useWire && wire,
|
|
155
184
|
typeArgumentCall: { start: callStart, end: callEnd + delta },
|
|
156
185
|
};
|
|
@@ -164,7 +193,8 @@ export class Retyper {
|
|
|
164
193
|
return (useWire) => {
|
|
165
194
|
const text = `<${stated(useWire)}>`;
|
|
166
195
|
return {
|
|
167
|
-
edits: [{ start: at, end: at, text }],
|
|
196
|
+
edits: [{ start: at, end: at, text, statedAt: 1 }],
|
|
197
|
+
stated: stated(useWire),
|
|
168
198
|
wire: useWire && wire,
|
|
169
199
|
typeArgumentCall: { start: callStart, end: callEnd + text.length },
|
|
170
200
|
};
|
|
@@ -190,6 +220,7 @@ export class Retyper {
|
|
|
190
220
|
const text = stated(useWire);
|
|
191
221
|
return {
|
|
192
222
|
edits: edits.flatMap((edit) => edit(text)),
|
|
223
|
+
stated: text,
|
|
193
224
|
wire: useWire && wire,
|
|
194
225
|
};
|
|
195
226
|
};
|
|
@@ -249,12 +280,76 @@ export class Retyper {
|
|
|
249
280
|
added.push(mapped);
|
|
250
281
|
}
|
|
251
282
|
added.sort((a, b) => a.start - b.start || a.code - b.code);
|
|
283
|
+
const unresolved = this.unresolvedMembers(sourceFile, rewrite, added.length > 0);
|
|
284
|
+
if (unresolved)
|
|
285
|
+
return { kind: 'abstain', reason: unresolved };
|
|
252
286
|
return { kind: 'checked', added };
|
|
253
287
|
}
|
|
254
288
|
finally {
|
|
255
289
|
sourceFile.replaceWithText(original);
|
|
256
290
|
}
|
|
257
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Why the stated type, as the consumer's program reads it, cannot be
|
|
294
|
+
* compared, or `undefined` when it can (carrick#1514). Read after the
|
|
295
|
+
* diagnostics diff, because the two top types fail in opposite directions.
|
|
296
|
+
*
|
|
297
|
+
* The producer's text is sent only when it holds no `any`/`unknown`, but
|
|
298
|
+
* the consumer's program is where it is read: under its compiler options,
|
|
299
|
+
* through the wire transform (a lib type whose `toJSON()` returns `any`),
|
|
300
|
+
* with its own declarations.
|
|
301
|
+
*
|
|
302
|
+
* - A member that reads as `unknown` makes reads fail that the producer's
|
|
303
|
+
* type would pass, so any diagnostic may be ours. It is never compared.
|
|
304
|
+
* - A member that reads as `any` can hide a failure but never make one, so
|
|
305
|
+
* the diagnostics the diff found stand; only an empty diff is not
|
|
306
|
+
* compared, since it may be the `any` agreeing.
|
|
307
|
+
*
|
|
308
|
+
* Either way the reason names the member, as the check phase does for a
|
|
309
|
+
* published type.
|
|
310
|
+
*
|
|
311
|
+
* The walk's budget sentinel is dropped. The scanner's text screen
|
|
312
|
+
* (`contains_disqualifying_top_type`, which has no budget) found no
|
|
313
|
+
* `any`/`unknown` in the producer's text before sending it, so a walk that
|
|
314
|
+
* runs out of budget can only miss one the consumer's program made deeper
|
|
315
|
+
* than the budget allows; the compiler's diagnostics stand.
|
|
316
|
+
*/
|
|
317
|
+
unresolvedMembers(sourceFile, rewrite, diagnosed) {
|
|
318
|
+
let shift = 0;
|
|
319
|
+
let at;
|
|
320
|
+
for (const edit of [...rewrite.edits].sort((a, b) => a.start - b.start)) {
|
|
321
|
+
if (at === undefined && edit.statedAt !== undefined) {
|
|
322
|
+
at = edit.start + shift + edit.statedAt;
|
|
323
|
+
}
|
|
324
|
+
shift += edit.text.length - (edit.end - edit.start);
|
|
325
|
+
}
|
|
326
|
+
const node = at === undefined
|
|
327
|
+
? undefined
|
|
328
|
+
: sourceFile.getDescendantAtStartWithWidth(at, rewrite.stated.length);
|
|
329
|
+
// Every rewrite states the type at a recorded offset, so this is only
|
|
330
|
+
// reached through a wrong offset; it abstains rather than skip the walk.
|
|
331
|
+
if (!node)
|
|
332
|
+
return 'the stated producer type could not be found again after the rewrite';
|
|
333
|
+
const type = node.getType().compilerType;
|
|
334
|
+
let found;
|
|
335
|
+
if (type.flags & ts.TypeFlags.Unknown)
|
|
336
|
+
found = [{ kind: 'unknown', path: '' }];
|
|
337
|
+
else if (type.flags & ts.TypeFlags.Any)
|
|
338
|
+
found = [{ kind: 'any', path: '' }];
|
|
339
|
+
else {
|
|
340
|
+
const program = this.project.getProgram().compilerObject;
|
|
341
|
+
found = this.topTypes(type, program, program.getTypeChecker(), node.compilerNode);
|
|
342
|
+
}
|
|
343
|
+
const unknowns = found.filter((finding) => finding.kind === 'unknown');
|
|
344
|
+
const anys = found.filter((finding) => finding.kind === 'any');
|
|
345
|
+
const deciding = unknowns.length > 0 ? unknowns : diagnosed ? [] : anys;
|
|
346
|
+
if (deciding.length === 0)
|
|
347
|
+
return undefined;
|
|
348
|
+
const members = deciding
|
|
349
|
+
.map(({ kind, path }) => (path === '' ? `'${kind}'` : `'${kind}' at '${path}'`))
|
|
350
|
+
.join(', ');
|
|
351
|
+
return `the producer's response reads as ${members} in the consumer's program, so it was not compared`;
|
|
352
|
+
}
|
|
258
353
|
/**
|
|
259
354
|
* The stated type argument must be what the call RETURNS (or a type
|
|
260
355
|
* argument or member of it): the rewrite is only a statement about the
|
|
@@ -738,7 +833,11 @@ function bodyReadEdit(read) {
|
|
|
738
833
|
const type = parent.getTypeNodeOrThrow();
|
|
739
834
|
const start = type.getStart();
|
|
740
835
|
const end = type.getEnd();
|
|
741
|
-
return (stated) => [
|
|
836
|
+
return (stated) => [
|
|
837
|
+
awaited
|
|
838
|
+
? { start, end, text: stated, statedAt: 0 }
|
|
839
|
+
: { start, end, text: `Promise<${stated}>`, statedAt: 'Promise<'.length },
|
|
840
|
+
];
|
|
742
841
|
}
|
|
743
842
|
else if (!Node.isParenthesizedExpression(parent))
|
|
744
843
|
break;
|
|
@@ -750,22 +849,12 @@ function bodyReadEdit(read) {
|
|
|
750
849
|
// The compiler reports past a parenthesis, so a finding on the cast read
|
|
751
850
|
// lands on `res`, an original position, never on this one.
|
|
752
851
|
{ start, end: start, text: '(' },
|
|
753
|
-
{ start: end, end, text: ` as Promise<${stated}>)
|
|
852
|
+
{ start: end, end, text: ` as Promise<${stated}>)`, statedAt: ' as Promise<'.length },
|
|
754
853
|
];
|
|
755
854
|
}
|
|
756
855
|
function declaresTypeParameters(call) {
|
|
757
856
|
return (resolvedDeclaration(call)?.typeParameters?.length ?? 0) > 0;
|
|
758
857
|
}
|
|
759
|
-
function fileDiagnostics(sourceFile) {
|
|
760
|
-
const program = sourceFile.getProject().getProgram().compilerObject;
|
|
761
|
-
const node = sourceFile.compilerNode;
|
|
762
|
-
return [...program.getSyntacticDiagnostics(node), ...program.getSemanticDiagnostics(node)]
|
|
763
|
-
.filter((d) => d.file === node && d.start !== undefined)
|
|
764
|
-
.map((d) => ({ start: d.start, code: d.code, message: flatten(d.messageText) }));
|
|
765
|
-
}
|
|
766
|
-
function flatten(text) {
|
|
767
|
-
return ts.flattenDiagnosticMessageText(text, ' ');
|
|
768
|
-
}
|
|
769
858
|
function key(d) {
|
|
770
859
|
return `${d.start}:${d.code}`;
|
|
771
860
|
}
|
|
@@ -57,6 +57,11 @@ export interface TypeInferrerOptions {
|
|
|
57
57
|
* `node_modules` symlink from an installed dependency (carrick#1264).
|
|
58
58
|
*/
|
|
59
59
|
repoRoot: string;
|
|
60
|
+
/**
|
|
61
|
+
* How long the unwidened reading of one `infer` batch may take
|
|
62
|
+
* (carrick#1516). Defaults to `UNWIDENED_BUDGET_MS`.
|
|
63
|
+
*/
|
|
64
|
+
unwidenedBudgetMs?: number;
|
|
60
65
|
}
|
|
61
66
|
/**
|
|
62
67
|
* TypeInferrer - Extracts types from source code, both explicit and inferred
|
|
@@ -69,6 +74,13 @@ export declare class TypeInferrer {
|
|
|
69
74
|
private readonly project;
|
|
70
75
|
private readonly packageOf;
|
|
71
76
|
private readonly repoRoot;
|
|
77
|
+
/**
|
|
78
|
+
* The node each inferred type was read from, keyed by the location object
|
|
79
|
+
* `getNodeLocation` built for it (the one an `InferredType` carries). The
|
|
80
|
+
* unwidened reading re-reads from here (carrick#1516).
|
|
81
|
+
*/
|
|
82
|
+
private readonly readNodes;
|
|
83
|
+
private readonly unwidenedBudgetMs;
|
|
72
84
|
constructor(options: TypeInferrerOptions);
|
|
73
85
|
/**
|
|
74
86
|
* What the structural printer needs to tell the user's own declarations from
|
|
@@ -85,6 +97,27 @@ export declare class TypeInferrer {
|
|
|
85
97
|
* @returns InferResult with inferred types or errors
|
|
86
98
|
*/
|
|
87
99
|
infer(requests: InferRequestItem[], extractionConfig?: ExtractionConfig): InferResult;
|
|
100
|
+
/**
|
|
101
|
+
* carrick#1516: read each response inference again with the literals on its
|
|
102
|
+
* handler's path marked `as const`, and record the narrower type the handler
|
|
103
|
+
* really returns beside the published one (`unwidened_type_string`). See
|
|
104
|
+
* `unwidened.ts` for what is marked and why the reading is sound.
|
|
105
|
+
*
|
|
106
|
+
* One rewrite for the whole batch: every file on any request's path is
|
|
107
|
+
* rewritten once, type-checked before and after, the requests re-inferred,
|
|
108
|
+
* and every file restored before returning, so the project other requests
|
|
109
|
+
* read is the project the scan loaded.
|
|
110
|
+
*
|
|
111
|
+
* Nothing is read past `deadline`: a batch that runs out keeps the readings
|
|
112
|
+
* it made and publishes none for the rest.
|
|
113
|
+
*/
|
|
114
|
+
private addUnwidenedReadings;
|
|
115
|
+
/**
|
|
116
|
+
* The request as it locates in the rewritten files: a span moved past the
|
|
117
|
+
* insertions before it, an expression text replaced by the rewritten text of
|
|
118
|
+
* the node it named. Lines do not move (an insertion holds no newline).
|
|
119
|
+
*/
|
|
120
|
+
private mappedRequest;
|
|
88
121
|
/**
|
|
89
122
|
* Infer a single type from a request
|
|
90
123
|
*/
|
|
@@ -21,6 +21,7 @@ import * as path from 'node:path';
|
|
|
21
21
|
import { Node, SyntaxKind, ts, } from 'ts-morph';
|
|
22
22
|
import { validateInferRequestItem } from './validators.js';
|
|
23
23
|
import { isExternalOrigin } from './origin.js';
|
|
24
|
+
import { addedDiagnostics, applyInsertions, fileDiagnostics, literalInsertions, mapBack, mapForward, normalise, pathOf, } from './unwidened.js';
|
|
24
25
|
import { expandTypeStructural, } from './type-structural-expander.js';
|
|
25
26
|
/**
|
|
26
27
|
* TS/lib globals and primitives that must never be emitted as a deterministic
|
|
@@ -230,6 +231,18 @@ const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InT
|
|
|
230
231
|
function typeText(type, enclosingNode) {
|
|
231
232
|
return type.getText(enclosingNode, TYPE_TEXT_FLAGS);
|
|
232
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* How long the unwidened reading of one batch may take by default. The
|
|
236
|
+
* reading adds a field and never an answer, so running out costs only the
|
|
237
|
+
* readings not yet made; the rewrite is always undone.
|
|
238
|
+
*/
|
|
239
|
+
const UNWIDENED_BUDGET_MS = 120_000;
|
|
240
|
+
/**
|
|
241
|
+
* No reading starts or continues past this long after the batch began: the
|
|
242
|
+
* scanner's read deadline for one request is 900s, and the inferences the
|
|
243
|
+
* batch already made must reach it.
|
|
244
|
+
*/
|
|
245
|
+
const UNWIDENED_LATEST_MS = 600_000;
|
|
233
246
|
/**
|
|
234
247
|
* TypeInferrer - Extracts types from source code, both explicit and inferred
|
|
235
248
|
*
|
|
@@ -241,10 +254,18 @@ export class TypeInferrer {
|
|
|
241
254
|
project;
|
|
242
255
|
packageOf;
|
|
243
256
|
repoRoot;
|
|
257
|
+
/**
|
|
258
|
+
* The node each inferred type was read from, keyed by the location object
|
|
259
|
+
* `getNodeLocation` built for it (the one an `InferredType` carries). The
|
|
260
|
+
* unwidened reading re-reads from here (carrick#1516).
|
|
261
|
+
*/
|
|
262
|
+
readNodes = new WeakMap();
|
|
263
|
+
unwidenedBudgetMs;
|
|
244
264
|
constructor(options) {
|
|
245
265
|
this.project = options.project;
|
|
246
266
|
this.packageOf = options.packageOf;
|
|
247
267
|
this.repoRoot = options.repoRoot;
|
|
268
|
+
this.unwidenedBudgetMs = options.unwidenedBudgetMs ?? UNWIDENED_BUDGET_MS;
|
|
248
269
|
}
|
|
249
270
|
/**
|
|
250
271
|
* What the structural printer needs to tell the user's own declarations from
|
|
@@ -266,8 +287,11 @@ export class TypeInferrer {
|
|
|
266
287
|
* @returns InferResult with inferred types or errors
|
|
267
288
|
*/
|
|
268
289
|
infer(requests, extractionConfig) {
|
|
290
|
+
const started = performance.now();
|
|
269
291
|
const inferredTypes = [];
|
|
270
292
|
const errors = [];
|
|
293
|
+
/** Response inferences the unwidened reading re-reads (carrick#1516). */
|
|
294
|
+
const responses = [];
|
|
271
295
|
for (const request of requests) {
|
|
272
296
|
// Plain JavaScript has no type annotations to extract, and `checkJs` is
|
|
273
297
|
// off, so inferring against a `.js` file yields nothing useful — it only
|
|
@@ -287,6 +311,9 @@ export class TypeInferrer {
|
|
|
287
311
|
const result = this.inferSingle(request, extractionConfig);
|
|
288
312
|
if (result) {
|
|
289
313
|
inferredTypes.push(result);
|
|
314
|
+
if (request.infer_kind === 'response_body' || request.infer_kind === 'function_return') {
|
|
315
|
+
responses.push({ request, result });
|
|
316
|
+
}
|
|
290
317
|
}
|
|
291
318
|
else {
|
|
292
319
|
errors.push(`Could not infer type at ${request.file_path}:${loc} (${request.infer_kind})`);
|
|
@@ -298,12 +325,193 @@ export class TypeInferrer {
|
|
|
298
325
|
errors.push(`Error inferring type at ${request.file_path}:${loc}: ${error}`);
|
|
299
326
|
}
|
|
300
327
|
}
|
|
328
|
+
try {
|
|
329
|
+
const deadline = Math.min(performance.now() + this.unwidenedBudgetMs, started + UNWIDENED_LATEST_MS);
|
|
330
|
+
this.addUnwidenedReadings(responses, extractionConfig, deadline);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
// The reading only ever adds a field; it never costs an answer.
|
|
334
|
+
this.logError(`Unwidened reading failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
+
}
|
|
301
336
|
return {
|
|
302
337
|
success: errors.length === 0 || inferredTypes.length > 0,
|
|
303
338
|
inferred_types: inferredTypes.length > 0 ? inferredTypes : undefined,
|
|
304
339
|
errors: errors.length > 0 ? errors : undefined,
|
|
305
340
|
};
|
|
306
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* carrick#1516: read each response inference again with the literals on its
|
|
344
|
+
* handler's path marked `as const`, and record the narrower type the handler
|
|
345
|
+
* really returns beside the published one (`unwidened_type_string`). See
|
|
346
|
+
* `unwidened.ts` for what is marked and why the reading is sound.
|
|
347
|
+
*
|
|
348
|
+
* One rewrite for the whole batch: every file on any request's path is
|
|
349
|
+
* rewritten once, type-checked before and after, the requests re-inferred,
|
|
350
|
+
* and every file restored before returning, so the project other requests
|
|
351
|
+
* read is the project the scan loaded.
|
|
352
|
+
*
|
|
353
|
+
* Nothing is read past `deadline`: a batch that runs out keeps the readings
|
|
354
|
+
* it made and publishes none for the rest.
|
|
355
|
+
*/
|
|
356
|
+
addUnwidenedReadings(responses, extractionConfig, deadline) {
|
|
357
|
+
const outOfTime = (stage) => {
|
|
358
|
+
if (performance.now() <= deadline)
|
|
359
|
+
return false;
|
|
360
|
+
this.log(`Unwidened reading stopped ${stage}: it ran out of its budget`);
|
|
361
|
+
return true;
|
|
362
|
+
};
|
|
363
|
+
const plans = [];
|
|
364
|
+
const insertionsByFile = new Map();
|
|
365
|
+
const checkedFiles = new Set();
|
|
366
|
+
// Every position is read before the first rewrite: a rewrite forgets
|
|
367
|
+
// every node of its file.
|
|
368
|
+
for (const { request, result } of responses) {
|
|
369
|
+
// A declared return type is the contract; nothing was widened.
|
|
370
|
+
if (result.is_explicit)
|
|
371
|
+
continue;
|
|
372
|
+
const read = this.readNodes.get(result.source_location);
|
|
373
|
+
if (!read)
|
|
374
|
+
continue;
|
|
375
|
+
const functions = pathOf(read);
|
|
376
|
+
let marked = 0;
|
|
377
|
+
for (const fn of functions) {
|
|
378
|
+
const insertions = literalInsertions(fn);
|
|
379
|
+
marked += insertions.length;
|
|
380
|
+
if (insertions.length === 0)
|
|
381
|
+
continue;
|
|
382
|
+
const file = fn.getSourceFile();
|
|
383
|
+
insertionsByFile.set(file, [...(insertionsByFile.get(file) ?? []), ...insertions]);
|
|
384
|
+
}
|
|
385
|
+
// Nothing on the path to mark: the reading would equal the published type.
|
|
386
|
+
if (marked === 0)
|
|
387
|
+
continue;
|
|
388
|
+
const path = functions.map((fn) => ({
|
|
389
|
+
file: fn.getSourceFile(),
|
|
390
|
+
start: fn.getStart(),
|
|
391
|
+
end: fn.getEnd(),
|
|
392
|
+
}));
|
|
393
|
+
for (const fn of path)
|
|
394
|
+
checkedFiles.add(fn.file);
|
|
395
|
+
let locatedByText;
|
|
396
|
+
if (request.expression_text && request.span_start === undefined) {
|
|
397
|
+
const sourceFile = this.getSourceFile(request.file_path);
|
|
398
|
+
const located = sourceFile
|
|
399
|
+
? this.findNodeByText(sourceFile, request.expression_text, request.expression_line)
|
|
400
|
+
: undefined;
|
|
401
|
+
if (located)
|
|
402
|
+
locatedByText = { start: located.getStart(), end: located.getEnd() };
|
|
403
|
+
}
|
|
404
|
+
plans.push({
|
|
405
|
+
request,
|
|
406
|
+
result,
|
|
407
|
+
read: { file: read.getSourceFile(), start: read.getStart(), end: read.getEnd() },
|
|
408
|
+
path,
|
|
409
|
+
locatedByText,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (plans.length === 0)
|
|
413
|
+
return;
|
|
414
|
+
const insertions = new Map();
|
|
415
|
+
for (const [file, list] of insertionsByFile)
|
|
416
|
+
insertions.set(file, normalise(list));
|
|
417
|
+
const before = new Map();
|
|
418
|
+
for (const file of checkedFiles) {
|
|
419
|
+
if (outOfTime('before the rewrite'))
|
|
420
|
+
return;
|
|
421
|
+
before.set(file, fileDiagnostics(file));
|
|
422
|
+
}
|
|
423
|
+
const originals = new Map();
|
|
424
|
+
for (const file of insertions.keys())
|
|
425
|
+
originals.set(file, file.getFullText());
|
|
426
|
+
try {
|
|
427
|
+
for (const [file, list] of insertions) {
|
|
428
|
+
file.replaceWithText(applyInsertions(originals.get(file), list));
|
|
429
|
+
}
|
|
430
|
+
// A diagnostic the marking added sits in a function on some request's
|
|
431
|
+
// path (or elsewhere in that function's file): every request whose path
|
|
432
|
+
// holds it loses its reading.
|
|
433
|
+
const tainted = new Set();
|
|
434
|
+
for (const file of checkedFiles) {
|
|
435
|
+
if (outOfTime('while checking the rewrite'))
|
|
436
|
+
return;
|
|
437
|
+
const list = insertions.get(file) ?? [];
|
|
438
|
+
for (const at of addedDiagnostics(before.get(file), fileDiagnostics(file), list)) {
|
|
439
|
+
const holders = plans.flatMap((plan) => plan.path).filter((fn) => fn.file === file && fn.start <= at && at < fn.end);
|
|
440
|
+
for (const plan of plans) {
|
|
441
|
+
const onPath = plan.path.some((fn) => holders.length > 0 ? holders.includes(fn) : fn.file === file);
|
|
442
|
+
if (onPath)
|
|
443
|
+
tainted.add(plan);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const plan of plans) {
|
|
448
|
+
if (outOfTime('while re-reading'))
|
|
449
|
+
return;
|
|
450
|
+
if (tainted.has(plan)) {
|
|
451
|
+
this.log(`Unwidened reading for ${plan.request.file_path}:${plan.request.line_number} ` +
|
|
452
|
+
'dropped: marking its literals added a diagnostic on its path');
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const request = this.mappedRequest(plan.request, plan.locatedByText, insertions);
|
|
456
|
+
let again = null;
|
|
457
|
+
try {
|
|
458
|
+
again = this.inferSingle(request, extractionConfig);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
again = null;
|
|
462
|
+
}
|
|
463
|
+
if (!again || again.type_string === plan.result.type_string)
|
|
464
|
+
continue;
|
|
465
|
+
// An invariant, not a case: `mappedRequest` is what keeps the re-read
|
|
466
|
+
// on the node the inference read, so no fixture reaches this. It is
|
|
467
|
+
// what turns a locator the mapping got wrong into no reading instead
|
|
468
|
+
// of another node's type published as this handler's.
|
|
469
|
+
const readAgain = this.readNodes.get(again.source_location);
|
|
470
|
+
const list = insertions.get(plan.read.file) ?? [];
|
|
471
|
+
const sameNode = readAgain !== undefined &&
|
|
472
|
+
readAgain.getSourceFile() === plan.read.file &&
|
|
473
|
+
mapBack(readAgain.getStart(), list) === plan.read.start &&
|
|
474
|
+
mapBack(readAgain.getEnd(), list) === plan.read.end;
|
|
475
|
+
if (!sameNode) {
|
|
476
|
+
this.log(`Unwidened reading for ${plan.request.file_path}:${plan.request.line_number} ` +
|
|
477
|
+
'dropped: the re-read did not read the node the inference read');
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
plan.result.unwidened_type_string = again.type_string;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
for (const [file, text] of originals)
|
|
485
|
+
file.replaceWithText(text);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The request as it locates in the rewritten files: a span moved past the
|
|
490
|
+
* insertions before it, an expression text replaced by the rewritten text of
|
|
491
|
+
* the node it named. Lines do not move (an insertion holds no newline).
|
|
492
|
+
*/
|
|
493
|
+
mappedRequest(request, locatedByText, insertions) {
|
|
494
|
+
const sourceFile = this.getSourceFile(request.file_path);
|
|
495
|
+
const list = sourceFile ? insertions.get(sourceFile) ?? [] : [];
|
|
496
|
+
if (list.length === 0)
|
|
497
|
+
return request;
|
|
498
|
+
if (request.span_start !== undefined && request.span_end !== undefined) {
|
|
499
|
+
return {
|
|
500
|
+
...request,
|
|
501
|
+
span_start: mapForward(request.span_start, list, 'start'),
|
|
502
|
+
span_end: mapForward(request.span_end, list, 'end'),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (locatedByText && sourceFile) {
|
|
506
|
+
return {
|
|
507
|
+
...request,
|
|
508
|
+
expression_text: sourceFile
|
|
509
|
+
.getFullText()
|
|
510
|
+
.slice(mapForward(locatedByText.start, list, 'start'), mapForward(locatedByText.end, list, 'end')),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return request;
|
|
514
|
+
}
|
|
307
515
|
/**
|
|
308
516
|
* Infer a single type from a request
|
|
309
517
|
*/
|
|
@@ -5421,13 +5629,15 @@ export class TypeInferrer {
|
|
|
5421
5629
|
getNodeLocation(node) {
|
|
5422
5630
|
const startLinePos = node.getStartLineNumber();
|
|
5423
5631
|
const endLinePos = node.getEndLineNumber();
|
|
5424
|
-
|
|
5632
|
+
const location = {
|
|
5425
5633
|
file_path: node.getSourceFile().getFilePath(),
|
|
5426
5634
|
start_line: startLinePos,
|
|
5427
5635
|
end_line: endLinePos,
|
|
5428
5636
|
start_column: node.getStart() - node.getStartLinePos(),
|
|
5429
5637
|
end_column: node.getEnd() - node.getStartLinePos(),
|
|
5430
5638
|
};
|
|
5639
|
+
this.readNodes.set(location, node);
|
|
5640
|
+
return location;
|
|
5431
5641
|
}
|
|
5432
5642
|
createInferredType(request, typeString, isExplicit, sourceLocation, payloadTypeString, primaryTypeSymbol, arrayDepth, primaryTypeSymbolSource) {
|
|
5433
5643
|
const alias = request.alias ||
|
|
@@ -300,6 +300,12 @@ export interface RetypeItem {
|
|
|
300
300
|
expression_line?: number;
|
|
301
301
|
/** The producer's response type as TypeScript text, fully inlined. */
|
|
302
302
|
producer_type: string;
|
|
303
|
+
/**
|
|
304
|
+
* The producer's response as its handler returns it, literals read before
|
|
305
|
+
* TypeScript widens them (carrick#1516), when that differs from
|
|
306
|
+
* `producer_type`. Asked only when `producer_type` raised diagnostics.
|
|
307
|
+
*/
|
|
308
|
+
producer_unwidened_type?: string;
|
|
303
309
|
/** Judge the form JSON puts on the wire (an `http` response). */
|
|
304
310
|
wire: boolean;
|
|
305
311
|
}
|
|
@@ -511,11 +517,14 @@ export interface RetypeDiagnostic {
|
|
|
511
517
|
* - `mismatch`: the rewrite added diagnostics; each is a place the consumer
|
|
512
518
|
* uses something the producer's response does not provide.
|
|
513
519
|
* - `agrees`: it added none.
|
|
520
|
+
* - `wider`: the published type added diagnostics and the handler's
|
|
521
|
+
* unwidened return added none (carrick#1516): the producer's type is wider
|
|
522
|
+
* than what it sends. `diagnostics` are the published type's.
|
|
514
523
|
* - `abstain`: the check could not be made; `reason` says why.
|
|
515
524
|
*/
|
|
516
525
|
export interface RetypeOutcome {
|
|
517
526
|
item_id: string;
|
|
518
|
-
outcome: 'mismatch' | 'agrees' | 'abstain';
|
|
527
|
+
outcome: 'mismatch' | 'agrees' | 'wider' | 'abstain';
|
|
519
528
|
diagnostics: RetypeDiagnostic[];
|
|
520
529
|
reason?: string;
|
|
521
530
|
}
|
|
@@ -615,6 +624,16 @@ export interface InferredType {
|
|
|
615
624
|
* Sorted by `path`; absent (not empty) when the type carries no top type.
|
|
616
625
|
*/
|
|
617
626
|
any_provenance?: TypeProvenance[];
|
|
627
|
+
/**
|
|
628
|
+
* carrick#1516, response inferences only: the same inference re-read with
|
|
629
|
+
* every literal on the handler's path kept at its literal type
|
|
630
|
+
* (`unwidened.ts`). `type_string` is what the compiler infers and what the
|
|
631
|
+
* index publishes; this is what the handler actually sends when TypeScript
|
|
632
|
+
* widened a literal in it (`scope: string` published, `scope: 'all' |
|
|
633
|
+
* 'specific'` sent). Absent when the two are the same, or when the reading
|
|
634
|
+
* was dropped as unsound.
|
|
635
|
+
*/
|
|
636
|
+
unwidened_type_string?: string;
|
|
618
637
|
}
|
|
619
638
|
/**
|
|
620
639
|
* Why a type carries `any`/`unknown` at a position, and where.
|