carrick 0.3.111 → 0.3.113
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/bin/carrick.mjs +3 -0
- package/dist/contract.d.ts +6 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -1
- package/dist/hook/post-edit.js +11 -2
- package/dist/hook/post-edit.js.map +1 -1
- package/dist/hook/post-read.d.ts +2 -0
- package/dist/hook/post-read.js +89 -0
- package/dist/hook/post-read.js.map +1 -0
- package/dist/hook/reuse.d.ts +24 -0
- package/dist/hook/reuse.js +58 -2
- package/dist/hook/reuse.js.map +1 -1
- package/dist/init/settings.js +6 -0
- package/dist/init/settings.js.map +1 -1
- package/dist/render.d.ts +22 -2
- package/dist/render.js +53 -2
- package/dist/render.js.map +1 -1
- package/dist/update.js +3 -0
- package/dist/update.js.map +1 -1
- package/package.json +62 -7
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +10 -0
- package/scripts/install-scanned-deps.sh +539 -0
- package/sidecar/dist/src/capture/api.d.ts +22 -1
- package/sidecar/dist/src/capture/check-probe.d.ts +8 -0
- package/sidecar/dist/src/capture/check-probe.js +9 -1
- package/sidecar/dist/src/capture/check.d.ts +32 -0
- package/sidecar/dist/src/capture/check.js +74 -5
- package/sidecar/dist/src/capture/checkout.d.ts +38 -0
- package/sidecar/dist/src/capture/checkout.js +139 -0
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +22 -7
- package/sidecar/dist/src/capture/machinery.js +1 -30
- package/sidecar/dist/src/capture/project-references.js +2 -1
- package/sidecar/dist/src/capture/self-check.d.ts +20 -1
- package/sidecar/dist/src/capture/self-check.js +30 -11
- package/sidecar/dist/src/origin.js +1 -29
- package/sidecar/dist/src/project-loader.d.ts +6 -0
- package/sidecar/dist/src/project-loader.js +64 -1
- package/sidecar/dist/src/type-inferrer.d.ts +235 -1
- package/sidecar/dist/src/type-inferrer.js +901 -111
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* function's return type.
|
|
19
19
|
*/
|
|
20
20
|
import * as path from 'node:path';
|
|
21
|
-
import { Node, SyntaxKind, ts, } from 'ts-morph';
|
|
21
|
+
import { Node, SyntaxKind, VariableDeclarationKind, ts, } from 'ts-morph';
|
|
22
22
|
import { typeToStringAsDeclared } from './print-type.js';
|
|
23
23
|
import { validateInferRequestItem } from './validators.js';
|
|
24
24
|
import { notePrintedType, PrintedTypes } from './printed-names.js';
|
|
@@ -172,6 +172,15 @@ const RESPONSE_HELPER_MAX_DEPTH = 4;
|
|
|
172
172
|
* the branch an error path, whose shape is not the endpoint's contract.
|
|
173
173
|
*/
|
|
174
174
|
const STATUS_MEMBER_NAMES = ['status', 'statusCode'];
|
|
175
|
+
/** The provenance sentence for each reason a branch was not read. */
|
|
176
|
+
const SERIALISED_BODY_UNREAD_DETAIL = {
|
|
177
|
+
prebuilt: 'A return hands back a status and body built somewhere else, such as a variable or a field of another result, so the value in its body was not read.',
|
|
178
|
+
not_serialised: "A return's body string is not made by JSON.stringify, so the value it holds was not read.",
|
|
179
|
+
builder: 'A return calls a response helper that could not be followed to the one value it serialises, so what it sends was not read.',
|
|
180
|
+
top_type: 'A return serialises a value typed any or unknown, so the shape of its body is not known.',
|
|
181
|
+
};
|
|
182
|
+
/** `any` or `unknown` as a type in printed type text, not as a member name or a string. */
|
|
183
|
+
const TOP_TYPE_TOKEN = /(?<![\w$'"`])(?:any|unknown)(?![\w$'"`?:])/;
|
|
175
184
|
/**
|
|
176
185
|
* The items whose whitespace-collapsed texts differ, first of each kept, in
|
|
177
186
|
* order: how a joined union drops a member it already has.
|
|
@@ -257,6 +266,83 @@ function typeText(type, enclosingNode) {
|
|
|
257
266
|
* `text` is `string`, alone or beside `null` and `undefined` in either order:
|
|
258
267
|
* the only payload a raw-text read publishes (carrick#1842).
|
|
259
268
|
*/
|
|
269
|
+
/**
|
|
270
|
+
* Source text with every plain string literal written in double quotes:
|
|
271
|
+
* `'/a'`, `"/a"` and an interpolation-free `` `/a` `` are one value, so text
|
|
272
|
+
* that differs only in its quotes compares equal (carrick#2056).
|
|
273
|
+
*
|
|
274
|
+
* The text is read left to right, pairing quotes the way the scanner does,
|
|
275
|
+
* so a template's closing backtick never pairs with the next template's
|
|
276
|
+
* opening one. A literal holding a quote, a backslash or an interpolation is
|
|
277
|
+
* copied as it is, and so is an unterminated one with everything after it.
|
|
278
|
+
*/
|
|
279
|
+
export function canonicalStringQuotes(text) {
|
|
280
|
+
const isQuote = (ch) => ch === "'" || ch === '"' || ch === '`';
|
|
281
|
+
// The index just past the literal opening at `start`, or -1.
|
|
282
|
+
const literalEnd = (start) => {
|
|
283
|
+
const quote = text[start];
|
|
284
|
+
let j = start + 1;
|
|
285
|
+
while (j < text.length) {
|
|
286
|
+
const ch = text[j];
|
|
287
|
+
if (ch === '\\') {
|
|
288
|
+
j += 2;
|
|
289
|
+
}
|
|
290
|
+
else if (ch === quote) {
|
|
291
|
+
return j + 1;
|
|
292
|
+
}
|
|
293
|
+
else if (quote === '`' && ch === '$' && text[j + 1] === '{') {
|
|
294
|
+
const close = codeEnd(j + 2);
|
|
295
|
+
if (close < 0)
|
|
296
|
+
return -1;
|
|
297
|
+
j = close + 1;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
j++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return -1;
|
|
304
|
+
};
|
|
305
|
+
// The index of the `}` that closes an interpolation whose code starts at `start`, or -1.
|
|
306
|
+
const codeEnd = (start) => {
|
|
307
|
+
let depth = 0;
|
|
308
|
+
let j = start;
|
|
309
|
+
while (j < text.length) {
|
|
310
|
+
const ch = text[j];
|
|
311
|
+
if (isQuote(ch)) {
|
|
312
|
+
const end = literalEnd(j);
|
|
313
|
+
if (end < 0)
|
|
314
|
+
return -1;
|
|
315
|
+
j = end;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (ch === '{')
|
|
319
|
+
depth++;
|
|
320
|
+
if (ch === '}') {
|
|
321
|
+
if (depth === 0)
|
|
322
|
+
return j;
|
|
323
|
+
depth--;
|
|
324
|
+
}
|
|
325
|
+
j++;
|
|
326
|
+
}
|
|
327
|
+
return -1;
|
|
328
|
+
};
|
|
329
|
+
let out = '';
|
|
330
|
+
let i = 0;
|
|
331
|
+
while (i < text.length) {
|
|
332
|
+
if (!isQuote(text[i])) {
|
|
333
|
+
out += text[i];
|
|
334
|
+
i++;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const end = literalEnd(i);
|
|
338
|
+
if (end < 0)
|
|
339
|
+
return out + text.slice(i);
|
|
340
|
+
const body = text.slice(i + 1, end - 1);
|
|
341
|
+
out += /['"`\\]|\$\{/.test(body) ? text.slice(i, end) : `"${body}"`;
|
|
342
|
+
i = end;
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
260
346
|
function isBareStringText(text) {
|
|
261
347
|
const members = text.split('|').map((member) => member.trim());
|
|
262
348
|
return (members.includes('string') &&
|
|
@@ -712,6 +798,13 @@ export class TypeInferrer {
|
|
|
712
798
|
// whose contract lives in the yield position. Unwrap it to the yield type so
|
|
713
799
|
// both forms reduce to `Order` before structural expansion / rule matching.
|
|
714
800
|
const awaitedType = this.unwrapAsyncIterableType(this.unwrapPromiseType(returnType));
|
|
801
|
+
// carrick#2200: a `{ statusCode, body: string }` return is transport the
|
|
802
|
+
// handler built itself; its contract is the value it serialised into
|
|
803
|
+
// `body`. Decided before the extraction config, whose rule for the
|
|
804
|
+
// envelope would read `body` and publish `string`.
|
|
805
|
+
if (this.serialisedBodyEnvelopeEngages(awaitedType, func)) {
|
|
806
|
+
return this.inferSerialisedBodyEnvelope(request, func);
|
|
807
|
+
}
|
|
715
808
|
const unwrapResult = this.unwrapTypeWithConfig(awaitedType, func, extractionConfig);
|
|
716
809
|
if (unwrapResult.wasUnwrapped && !unwrapResult.verifiedMachinery) {
|
|
717
810
|
typeString = unwrapResult.typeString;
|
|
@@ -1448,17 +1541,13 @@ export class TypeInferrer {
|
|
|
1448
1541
|
}
|
|
1449
1542
|
this.log(`Response locator at ${request.file_path}:${request.line_number} names an error ` +
|
|
1450
1543
|
'or redirect send and its handler sends no success body; abstaining');
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
'or carries no body a JSON contract can describe, so it publishes no success body',
|
|
1459
|
-
},
|
|
1460
|
-
];
|
|
1461
|
-
return abstain;
|
|
1544
|
+
return this.decidedAbstain(request, this.getNodeLocation(site), {
|
|
1545
|
+
path: '',
|
|
1546
|
+
kind: 'unknown',
|
|
1547
|
+
reason: 'no_success_payload',
|
|
1548
|
+
detail: "every response this route's handler sends states an error or redirect status, " +
|
|
1549
|
+
'or carries no body a JSON contract can describe, so it publishes no success body',
|
|
1550
|
+
});
|
|
1462
1551
|
}
|
|
1463
1552
|
if (recovered && recovered.nodes.some((node) => contains(site, node))) {
|
|
1464
1553
|
return this.inferredFromRecoveredPayload(request, recovered);
|
|
@@ -1516,8 +1605,12 @@ export class TypeInferrer {
|
|
|
1516
1605
|
}
|
|
1517
1606
|
inferCallResult(sourceFile, request, extractionConfig) {
|
|
1518
1607
|
const callExpr = this.resolveTargetCallExpression(sourceFile, request);
|
|
1608
|
+
// A call's result is read off the call. A locator that finds no call names
|
|
1609
|
+
// nothing this can read, and whatever other node its text overlaps is not
|
|
1610
|
+
// the call's result (carrick#2056); the retype abstains on the same miss.
|
|
1519
1611
|
if (!callExpr) {
|
|
1520
|
-
|
|
1612
|
+
this.log(`Call-result locator at ${request.file_path}:${request.line_number} finds no call; leaving unresolved`);
|
|
1613
|
+
return null;
|
|
1521
1614
|
}
|
|
1522
1615
|
// Walk up from the already-found call expression instead of re-searching
|
|
1523
1616
|
const func = this.findContainingFunctionForNode(callExpr);
|
|
@@ -1682,18 +1775,14 @@ export class TypeInferrer {
|
|
|
1682
1775
|
this.log(`Call result at ${request.file_path}:${request.line_number} is only ever read as ` +
|
|
1683
1776
|
`members of ${typeText(callExpr.getType(), callExpr)}; a part of a payload is not ` +
|
|
1684
1777
|
'a payload, so this site states no response contract');
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
'payload a caller receives',
|
|
1694
|
-
},
|
|
1695
|
-
];
|
|
1696
|
-
return abstain;
|
|
1778
|
+
return this.decidedAbstain(request, this.getNodeLocation(callExpr), {
|
|
1779
|
+
path: '',
|
|
1780
|
+
kind: 'unknown',
|
|
1781
|
+
reason: 'projected_value_only',
|
|
1782
|
+
detail: "every read of this call's result takes a member out of it and none reads the " +
|
|
1783
|
+
'value itself, so what this site states is a part of a payload rather than the ' +
|
|
1784
|
+
'payload a caller receives',
|
|
1785
|
+
});
|
|
1697
1786
|
}
|
|
1698
1787
|
typeString = this.unwrapPromise(typeString, returnType);
|
|
1699
1788
|
// #336: consumer analogue of the #306 producer anchor. Anchor on the
|
|
@@ -1928,18 +2017,25 @@ export class TypeInferrer {
|
|
|
1928
2017
|
parent.getInitializer() === current) {
|
|
1929
2018
|
break;
|
|
1930
2019
|
}
|
|
1931
|
-
if (!
|
|
1932
|
-
!Node.isAwaitExpression(parent) &&
|
|
1933
|
-
!Node.isNonNullExpression(parent) &&
|
|
1934
|
-
!Node.isAsExpression(parent) &&
|
|
1935
|
-
!Node.isTypeAssertion(parent) &&
|
|
1936
|
-
!Node.isSatisfiesExpression(parent)) {
|
|
2020
|
+
if (!this.wrapsReadTransparently(parent)) {
|
|
1937
2021
|
return undefined;
|
|
1938
2022
|
}
|
|
1939
2023
|
current = parent;
|
|
1940
2024
|
}
|
|
1941
2025
|
return typeNode;
|
|
1942
2026
|
}
|
|
2027
|
+
/**
|
|
2028
|
+
* A wrapper between a body read and the binding it initialises that leaves
|
|
2029
|
+
* the read's value as it is: `await`, parentheses, `!`, a cast, `satisfies`.
|
|
2030
|
+
*/
|
|
2031
|
+
wrapsReadTransparently(node) {
|
|
2032
|
+
return (Node.isParenthesizedExpression(node) ||
|
|
2033
|
+
Node.isAwaitExpression(node) ||
|
|
2034
|
+
Node.isNonNullExpression(node) ||
|
|
2035
|
+
Node.isAsExpression(node) ||
|
|
2036
|
+
Node.isTypeAssertion(node) ||
|
|
2037
|
+
Node.isSatisfiesExpression(node));
|
|
2038
|
+
}
|
|
1943
2039
|
/**
|
|
1944
2040
|
* A stated type with `any` or `unknown` written anywhere in it (`unknown`,
|
|
1945
2041
|
* `Record<string, unknown>`, `{ items: any[] }`) leaves a position open: it
|
|
@@ -2183,6 +2279,24 @@ export class TypeInferrer {
|
|
|
2183
2279
|
'to one body field by a keyed decorator; publishing the body those fields make up');
|
|
2184
2280
|
return this.declaredRequestInferredType(request, keyedBody, this.getNodeLocation(located));
|
|
2185
2281
|
}
|
|
2282
|
+
// carrick#2057: the locator named a member of the body the handler parsed
|
|
2283
|
+
// (`body.items` after `const body = (await request.json()) as ImportBody`,
|
|
2284
|
+
// or a binding destructured from that read). A member is one field of the
|
|
2285
|
+
// body, so publishing its type states the whole body is that field. The
|
|
2286
|
+
// body is what the source states at the read; where it states nothing,
|
|
2287
|
+
// nothing is published.
|
|
2288
|
+
const bodyRead = this.parsedBodyReadHolding(located);
|
|
2289
|
+
if (bodyRead) {
|
|
2290
|
+
const stated = this.requestStatedAtBodyRead(request, bodyRead);
|
|
2291
|
+
if (stated) {
|
|
2292
|
+
this.log(`Request locator at ${request.file_path}:${request.line_number} names a member of the ` +
|
|
2293
|
+
'body its handler parses; publishing the body the source states at that read');
|
|
2294
|
+
return stated;
|
|
2295
|
+
}
|
|
2296
|
+
this.log(`Request locator at ${request.file_path}:${request.line_number} names a member of the ` +
|
|
2297
|
+
'body its handler parses, and the source states no type at that read; leaving unresolved');
|
|
2298
|
+
return null;
|
|
2299
|
+
}
|
|
2186
2300
|
// Mirror inferCallResult: strip `await`/`as`/parens/`!` so the inner
|
|
2187
2301
|
// expression's type (not the surrounding `Promise<any>`) is read.
|
|
2188
2302
|
let unwrapped = this.unwrapExpressionNode(located);
|
|
@@ -2209,17 +2323,13 @@ export class TypeInferrer {
|
|
|
2209
2323
|
else if (inCall.kind === 'no_body') {
|
|
2210
2324
|
this.log(`Request locator at ${request.file_path}:${request.line_number} is a request config ` +
|
|
2211
2325
|
'that carries no body member; the call sends no request body');
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
'takes its body, so the call sends no request body',
|
|
2220
|
-
},
|
|
2221
|
-
];
|
|
2222
|
-
return abstain;
|
|
2326
|
+
return this.decidedAbstain(request, this.getNodeLocation(unwrapped), {
|
|
2327
|
+
path: '',
|
|
2328
|
+
kind: 'unknown',
|
|
2329
|
+
reason: 'no_request_body',
|
|
2330
|
+
detail: `the call's request config sets no '${inCall.member}' member, which is where the call ` +
|
|
2331
|
+
'takes its body, so the call sends no request body',
|
|
2332
|
+
});
|
|
2223
2333
|
}
|
|
2224
2334
|
// A `fetch` body is almost always `JSON.stringify(payload)`, whose own type
|
|
2225
2335
|
// is the useless `string`. Drill to the serialized argument so the consumer
|
|
@@ -2274,17 +2384,13 @@ export class TypeInferrer {
|
|
|
2274
2384
|
if (nonBodyPart !== undefined) {
|
|
2275
2385
|
this.log(`Request locator at ${request.file_path}:${request.line_number} reads the validated ` +
|
|
2276
2386
|
`'${nonBodyPart}' part, which is not a body; the route declares no request body here`);
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
'so the route states no body contract here',
|
|
2285
|
-
},
|
|
2286
|
-
];
|
|
2287
|
-
return abstain;
|
|
2387
|
+
return this.decidedAbstain(request, this.getNodeLocation(node), {
|
|
2388
|
+
path: '',
|
|
2389
|
+
kind: 'unknown',
|
|
2390
|
+
reason: 'no_request_body',
|
|
2391
|
+
detail: `the located read is the '${nonBodyPart}' part a validator binds, not a request body, ` +
|
|
2392
|
+
'so the route states no body contract here',
|
|
2393
|
+
});
|
|
2288
2394
|
}
|
|
2289
2395
|
}
|
|
2290
2396
|
const payloadType = node.getType();
|
|
@@ -2776,16 +2882,12 @@ export class TypeInferrer {
|
|
|
2776
2882
|
transportAbstain(request, callExpr, detail = "what this call's result carries is transport that the service's wrapper " +
|
|
2777
2883
|
'rules verify and read no payload out of (a library response object around the body), ' +
|
|
2778
2884
|
'so this site states no response contract') {
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
detail,
|
|
2786
|
-
},
|
|
2787
|
-
];
|
|
2788
|
-
return abstain;
|
|
2885
|
+
return this.decidedAbstain(request, this.getNodeLocation(callExpr), {
|
|
2886
|
+
path: '',
|
|
2887
|
+
kind: 'unknown',
|
|
2888
|
+
reason: 'machinery_envelope',
|
|
2889
|
+
detail,
|
|
2890
|
+
});
|
|
2789
2891
|
}
|
|
2790
2892
|
/**
|
|
2791
2893
|
* `Future<T>` -> `T` for a thenable, read off the await protocol rather
|
|
@@ -4109,29 +4211,44 @@ export class TypeInferrer {
|
|
|
4109
4211
|
const payloadNode = this.responseHelperPayloadNode(expression, 0, statedOnly, serialisers);
|
|
4110
4212
|
if (!payloadNode)
|
|
4111
4213
|
continue;
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
continue;
|
|
4126
|
-
}
|
|
4127
|
-
const payloadType = this.unwrapPromiseType(payloadNode.getType());
|
|
4128
|
-
candidates.push({
|
|
4129
|
-
typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode), wire),
|
|
4130
|
-
isExplicit: false,
|
|
4131
|
-
node: payloadNode,
|
|
4132
|
-
anchorType: payloadType,
|
|
4133
|
-
});
|
|
4214
|
+
candidates.push(this.payloadCandidateOf(payloadNode, wire));
|
|
4215
|
+
}
|
|
4216
|
+
return this.joinPayloadCandidates(candidates);
|
|
4217
|
+
}
|
|
4218
|
+
/** How one payload node reads: its stated annotation, else its resolved type. */
|
|
4219
|
+
payloadCandidateOf(payloadNode, wire) {
|
|
4220
|
+
// A `satisfies X` / `as X` on the argument states the contract in source:
|
|
4221
|
+
// read the ANNOTATION, not the literal the compiler widened it from.
|
|
4222
|
+
// `expandAnnotationTypeNode` keeps the bare name when the declaration is
|
|
4223
|
+
// not resolvable, which is the honest answer on a bare checkout.
|
|
4224
|
+
const stated = this.statedTypeNodeOf(payloadNode);
|
|
4225
|
+
if (stated) {
|
|
4226
|
+
return this.statedPayloadCandidate(payloadNode, stated, wire);
|
|
4134
4227
|
}
|
|
4228
|
+
const payloadType = this.unwrapPromiseType(payloadNode.getType());
|
|
4229
|
+
return {
|
|
4230
|
+
typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode), wire),
|
|
4231
|
+
isExplicit: false,
|
|
4232
|
+
node: payloadNode,
|
|
4233
|
+
anchorType: payloadType,
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
/** A payload whose contract a type node in the source states. */
|
|
4237
|
+
statedPayloadCandidate(payloadNode, stated, wire) {
|
|
4238
|
+
return {
|
|
4239
|
+
typeString: this.expandAnnotationTypeNode(stated, wire),
|
|
4240
|
+
isExplicit: true,
|
|
4241
|
+
node: payloadNode,
|
|
4242
|
+
anchorType: this.unwrapPromiseType(stated.getType()),
|
|
4243
|
+
statedTypeNode: stated,
|
|
4244
|
+
};
|
|
4245
|
+
}
|
|
4246
|
+
/**
|
|
4247
|
+
* Join the payloads of several response branches into one contract, as
|
|
4248
|
+
* several return statements are joined: an empty object drops beside a
|
|
4249
|
+
* branch with members, equal texts dedupe, and the rest is a union.
|
|
4250
|
+
*/
|
|
4251
|
+
joinPayloadCandidates(candidates) {
|
|
4135
4252
|
if (candidates.length === 0) {
|
|
4136
4253
|
return null;
|
|
4137
4254
|
}
|
|
@@ -4162,6 +4279,516 @@ export class TypeInferrer {
|
|
|
4162
4279
|
statedTypeNode: distinct.length === 1 ? distinct[0].statedTypeNode : undefined,
|
|
4163
4280
|
};
|
|
4164
4281
|
}
|
|
4282
|
+
// ===========================================================================
|
|
4283
|
+
// Serialised-body envelopes (carrick#2200)
|
|
4284
|
+
// ===========================================================================
|
|
4285
|
+
/**
|
|
4286
|
+
* True when a handler's awaited return, or a member of its union, is a
|
|
4287
|
+
* serialised-body envelope: an object with a numeric `statusCode` or
|
|
4288
|
+
* `status` member and a `body` member typed `string`. Either member may be
|
|
4289
|
+
* optional, as a platform's result type declares them.
|
|
4290
|
+
*
|
|
4291
|
+
* HTTP vocabulary only, the class `RESPONSE_INIT_MEMBER_NAMES` is. A `body`
|
|
4292
|
+
* typed as an object is not an envelope: a runtime that serialises the
|
|
4293
|
+
* handler's return value sends that object as the payload, so the shape is
|
|
4294
|
+
* ambiguous and keeps the ordinary reading.
|
|
4295
|
+
*/
|
|
4296
|
+
typeIsSerialisedBodyEnvelope(type, at) {
|
|
4297
|
+
const members = type.isUnion() ? type.getUnionTypes() : [type];
|
|
4298
|
+
return members.some((member) => {
|
|
4299
|
+
if (!member.isObject() || member.isArray() || member.getCallSignatures().length > 0) {
|
|
4300
|
+
return false;
|
|
4301
|
+
}
|
|
4302
|
+
const body = this.envelopeMemberType(member, 'body', at);
|
|
4303
|
+
if (!body || !(body.isString() || body.isStringLiteral()))
|
|
4304
|
+
return false;
|
|
4305
|
+
return STATUS_MEMBER_NAMES.some((name) => {
|
|
4306
|
+
const status = this.envelopeMemberType(member, name, at);
|
|
4307
|
+
return status !== undefined && (status.isNumber() || statusCodesOfType(status) !== undefined);
|
|
4308
|
+
});
|
|
4309
|
+
});
|
|
4310
|
+
}
|
|
4311
|
+
/** A member's type with `undefined` and `null` removed, or `undefined` if absent. */
|
|
4312
|
+
envelopeMemberType(type, name, at) {
|
|
4313
|
+
const property = type.getProperty(name);
|
|
4314
|
+
if (!property)
|
|
4315
|
+
return undefined;
|
|
4316
|
+
return property.getTypeAtLocation(at).getNonNullableType();
|
|
4317
|
+
}
|
|
4318
|
+
/**
|
|
4319
|
+
* True when a handler's return is a serialised-body envelope AND the source
|
|
4320
|
+
* shows the body is serialised, so the envelope reading applies:
|
|
4321
|
+
* - a returned branch builds an envelope whose `body` is `JSON.stringify`
|
|
4322
|
+
* of a value, inline, through a `const`, or in a builder the reading can
|
|
4323
|
+
* follow (`branchShowsSerialisation`); or
|
|
4324
|
+
* - the envelope type is declared outside the repository (a platform's
|
|
4325
|
+
* result type) with every member optional. Nothing else returns such a
|
|
4326
|
+
* type, so a value of it returned as-is is still transport. The repo's
|
|
4327
|
+
* own all-optional types (`Partial<Article>`) engage only on evidence.
|
|
4328
|
+
*
|
|
4329
|
+
* A domain object that merely has `status` and `body` members (an article,
|
|
4330
|
+
* a message) shows neither and keeps the ordinary reading.
|
|
4331
|
+
*/
|
|
4332
|
+
serialisedBodyEnvelopeEngages(type, func) {
|
|
4333
|
+
if (!this.typeIsSerialisedBodyEnvelope(type, func))
|
|
4334
|
+
return false;
|
|
4335
|
+
const members = type.isUnion() ? type.getUnionTypes() : [type];
|
|
4336
|
+
const platformShaped = members.some((member) => {
|
|
4337
|
+
if (!this.typeIsSerialisedBodyEnvelope(member, func))
|
|
4338
|
+
return false;
|
|
4339
|
+
const properties = member.getProperties();
|
|
4340
|
+
return properties.every((property) => property.hasFlags(ts.SymbolFlags.Optional) &&
|
|
4341
|
+
this.symbolIsLibOrExternalOrigin(property));
|
|
4342
|
+
});
|
|
4343
|
+
if (platformShaped)
|
|
4344
|
+
return true;
|
|
4345
|
+
return this.envelopeBranches(func).some((branch) => this.branchShowsSerialisation(branch, func, 0));
|
|
4346
|
+
}
|
|
4347
|
+
/**
|
|
4348
|
+
* True when a returned branch builds an envelope from a serialised value:
|
|
4349
|
+
* a literal whose `body` is `JSON.stringify(...)`, a call to a builder the
|
|
4350
|
+
* reading can follow, or a call handing on one such envelope.
|
|
4351
|
+
*/
|
|
4352
|
+
branchShowsSerialisation(expression, scope, depth) {
|
|
4353
|
+
if (depth > RESPONSE_HELPER_MAX_DEPTH)
|
|
4354
|
+
return false;
|
|
4355
|
+
const node = this.peelTransparentExpression(expression);
|
|
4356
|
+
if (Node.isObjectLiteralExpression(node)) {
|
|
4357
|
+
return this.serialisedBodyArgument(node, scope) !== undefined;
|
|
4358
|
+
}
|
|
4359
|
+
if (!Node.isCallExpression(node))
|
|
4360
|
+
return false;
|
|
4361
|
+
if (this.envelopeBuilderOf(node) !== undefined)
|
|
4362
|
+
return true;
|
|
4363
|
+
return this.handedOnArguments(node).some((arg) => this.branchShowsSerialisation(arg, scope, depth + 1));
|
|
4364
|
+
}
|
|
4365
|
+
/**
|
|
4366
|
+
* The arguments a call could hand back as its result: every argument but
|
|
4367
|
+
* an object literal, which is the call's input (`create({ body:
|
|
4368
|
+
* JSON.stringify(doc) })` stores a document, it does not respond with it),
|
|
4369
|
+
* and but a function, whose returns are read as the call's callbacks.
|
|
4370
|
+
*/
|
|
4371
|
+
handedOnArguments(call) {
|
|
4372
|
+
return call
|
|
4373
|
+
.getArguments()
|
|
4374
|
+
.map((arg) => this.peelTransparentExpression(arg))
|
|
4375
|
+
.filter((arg) => !Node.isObjectLiteralExpression(arg) &&
|
|
4376
|
+
!Node.isArrowFunction(arg) &&
|
|
4377
|
+
!Node.isFunctionExpression(arg));
|
|
4378
|
+
}
|
|
4379
|
+
/**
|
|
4380
|
+
* The returned branches of an envelope handler: each returned expression,
|
|
4381
|
+
* conditional sides split. A returned call whose callbacks return values
|
|
4382
|
+
* (`settle().then(ok, fail)`) is carried by those returns, plus every
|
|
4383
|
+
* envelope it is handed directly (`withFallback(() => a, b)` can answer
|
|
4384
|
+
* `b`); a call whose callbacks return nothing stays a branch of its own.
|
|
4385
|
+
*/
|
|
4386
|
+
envelopeBranches(func) {
|
|
4387
|
+
const carriedByCallbacks = new Set(this.returnedExpressions(func).filter((expression) => {
|
|
4388
|
+
const call = this.peelTransparentExpression(expression);
|
|
4389
|
+
if (!Node.isCallExpression(call))
|
|
4390
|
+
return false;
|
|
4391
|
+
return call.getArguments().some((arg) => {
|
|
4392
|
+
const callback = this.peelTransparentExpression(arg);
|
|
4393
|
+
return ((Node.isArrowFunction(callback) || Node.isFunctionExpression(callback)) &&
|
|
4394
|
+
this.returnedExpressions(callback).length > 0);
|
|
4395
|
+
});
|
|
4396
|
+
}));
|
|
4397
|
+
const handedEnvelopes = [...carriedByCallbacks].flatMap((expression) => {
|
|
4398
|
+
const call = this.peelTransparentExpression(expression);
|
|
4399
|
+
if (!Node.isCallExpression(call))
|
|
4400
|
+
return [];
|
|
4401
|
+
return call
|
|
4402
|
+
.getArguments()
|
|
4403
|
+
.map((arg) => this.peelTransparentExpression(arg))
|
|
4404
|
+
.filter((arg) => !Node.isArrowFunction(arg) &&
|
|
4405
|
+
!Node.isFunctionExpression(arg) &&
|
|
4406
|
+
this.typeIsSerialisedBodyEnvelope(arg.getType(), arg));
|
|
4407
|
+
});
|
|
4408
|
+
return [
|
|
4409
|
+
...this.responseReturnedExpressions(func).filter((expression) => !carriedByCallbacks.has(expression)),
|
|
4410
|
+
...handedEnvelopes,
|
|
4411
|
+
].flatMap((expression) => this.expandResponseBranches(expression, 0));
|
|
4412
|
+
}
|
|
4413
|
+
/**
|
|
4414
|
+
* The `function_return` answer for a handler that returns a serialised-body
|
|
4415
|
+
* envelope: the union of the values its success branches serialised.
|
|
4416
|
+
*
|
|
4417
|
+
* Each returned branch is read by `readEnvelopeBranch`. Error and redirect
|
|
4418
|
+
* branches contribute nothing (carrick#1161). If any branch cannot be read,
|
|
4419
|
+
* the answer is `unknown` with the decided reason `serialised_body_unread`
|
|
4420
|
+
* and one sentence per cause: the branches that did read are part of the
|
|
4421
|
+
* contract, and neither the envelope nor its `string` body is any of it.
|
|
4422
|
+
*/
|
|
4423
|
+
inferSerialisedBodyEnvelope(request, func) {
|
|
4424
|
+
const wire = this.wireFormatFor(request);
|
|
4425
|
+
const branches = this.envelopeBranches(func);
|
|
4426
|
+
const candidates = [];
|
|
4427
|
+
const causes = new Set();
|
|
4428
|
+
for (const branch of branches) {
|
|
4429
|
+
const read = this.readEnvelopeBranch(branch, func, wire, 0);
|
|
4430
|
+
if (read === 'dropped')
|
|
4431
|
+
continue;
|
|
4432
|
+
if ('unread' in read)
|
|
4433
|
+
causes.add(read.unread);
|
|
4434
|
+
else
|
|
4435
|
+
candidates.push(read);
|
|
4436
|
+
}
|
|
4437
|
+
const location = this.getNodeLocation(func);
|
|
4438
|
+
if (causes.size > 0) {
|
|
4439
|
+
this.log(`Return at ${request.file_path}:${request.line_number} is a serialised-body ` +
|
|
4440
|
+
`envelope and a returned branch was not read (${[...causes].join(', ')}); abstaining`);
|
|
4441
|
+
return this.decidedAbstain(request, location, {
|
|
4442
|
+
path: '',
|
|
4443
|
+
kind: 'unknown',
|
|
4444
|
+
reason: 'serialised_body_unread',
|
|
4445
|
+
detail: [...causes].map((cause) => SERIALISED_BODY_UNREAD_DETAIL[cause]).join(' '),
|
|
4446
|
+
});
|
|
4447
|
+
}
|
|
4448
|
+
const recovered = this.joinPayloadCandidates(candidates);
|
|
4449
|
+
if (!recovered) {
|
|
4450
|
+
return this.decidedAbstain(request, location, {
|
|
4451
|
+
path: '',
|
|
4452
|
+
kind: 'unknown',
|
|
4453
|
+
reason: 'no_success_payload',
|
|
4454
|
+
detail: 'every status and body this handler returns states an error or redirect status, so it publishes no success body',
|
|
4455
|
+
});
|
|
4456
|
+
}
|
|
4457
|
+
return this.inferredFromRecoveredPayload(request, recovered);
|
|
4458
|
+
}
|
|
4459
|
+
/** An `unknown` answer the inferrer decided, carrying its reason at the root. */
|
|
4460
|
+
decidedAbstain(request, location, reason) {
|
|
4461
|
+
const abstain = this.createInferredType(request, 'unknown', false, location);
|
|
4462
|
+
abstain.any_provenance = [reason];
|
|
4463
|
+
return abstain;
|
|
4464
|
+
}
|
|
4465
|
+
/**
|
|
4466
|
+
* What one returned branch of an envelope handler serialised:
|
|
4467
|
+
* - an envelope object literal: the argument of the `JSON.stringify` that
|
|
4468
|
+
* produced its `body`, inline or through a `const` in the same function;
|
|
4469
|
+
* its status member decides the branch;
|
|
4470
|
+
* - a call to a response builder this repository declares
|
|
4471
|
+
* (`envelopeBuilderOf`): the argument the builder serialises; the
|
|
4472
|
+
* argument or literal its status member takes decides the branch;
|
|
4473
|
+
* - any other call handing on exactly one envelope argument (never an
|
|
4474
|
+
* object literal, which is the call's input): that
|
|
4475
|
+
* envelope is read in its place. Its own stated status decides the
|
|
4476
|
+
* branch; only when it states none does a status the outer call's
|
|
4477
|
+
* arguments state decide (`log(ctx, 503, "code", prebuilt)`).
|
|
4478
|
+
* Anything else, and a payload holding `any` or `unknown`, is unread, with
|
|
4479
|
+
* its cause.
|
|
4480
|
+
*/
|
|
4481
|
+
readEnvelopeBranch(expression, scope, wire, depth) {
|
|
4482
|
+
if (depth > RESPONSE_HELPER_MAX_DEPTH)
|
|
4483
|
+
return { unread: 'builder' };
|
|
4484
|
+
const node = this.peelTransparentExpression(expression);
|
|
4485
|
+
if (Node.isObjectLiteralExpression(node)) {
|
|
4486
|
+
const status = this.envelopeLiteralStatus(node);
|
|
4487
|
+
if (status === 'error' || status === 'redirect')
|
|
4488
|
+
return 'dropped';
|
|
4489
|
+
const payload = this.serialisedBodyArgument(node, scope);
|
|
4490
|
+
return payload ? this.envelopePayloadCandidate(payload, wire) : { unread: 'not_serialised' };
|
|
4491
|
+
}
|
|
4492
|
+
if (!Node.isCallExpression(node))
|
|
4493
|
+
return { unread: 'prebuilt' };
|
|
4494
|
+
const builder = this.envelopeBuilderOf(node);
|
|
4495
|
+
if (builder === 'failure')
|
|
4496
|
+
return 'dropped';
|
|
4497
|
+
if (builder) {
|
|
4498
|
+
const status = this.builderCallStatus(node, builder);
|
|
4499
|
+
if (status === 'error' || status === 'redirect')
|
|
4500
|
+
return 'dropped';
|
|
4501
|
+
const argument = node.getArguments()[builder.payloadParameter];
|
|
4502
|
+
if (!argument)
|
|
4503
|
+
return { unread: 'builder' };
|
|
4504
|
+
return this.builderPayloadCandidate(node, builder, argument, wire);
|
|
4505
|
+
}
|
|
4506
|
+
const envelopes = this.handedOnArguments(node).filter((arg) => this.typeIsSerialisedBodyEnvelope(arg.getType(), arg));
|
|
4507
|
+
if (envelopes.length > 1)
|
|
4508
|
+
return { unread: 'builder' };
|
|
4509
|
+
const inner = envelopes[0];
|
|
4510
|
+
const innerStatus = inner ? this.envelopeOwnStatus(inner) : 'undecided';
|
|
4511
|
+
if (innerStatus === 'undecided' || innerStatus === 'variable') {
|
|
4512
|
+
const outer = this.responseSiteStatus(node);
|
|
4513
|
+
if (outer === 'error' || outer === 'redirect')
|
|
4514
|
+
return 'dropped';
|
|
4515
|
+
}
|
|
4516
|
+
if (!inner)
|
|
4517
|
+
return { unread: 'builder' };
|
|
4518
|
+
return this.readEnvelopeBranch(inner, scope, wire, depth + 1);
|
|
4519
|
+
}
|
|
4520
|
+
/** The status a builder call sends: its status argument, or the builder's literal. */
|
|
4521
|
+
builderCallStatus(call, builder) {
|
|
4522
|
+
if (builder.statusParameter !== undefined) {
|
|
4523
|
+
return this.statusOfValue(call.getArguments()[builder.statusParameter]);
|
|
4524
|
+
}
|
|
4525
|
+
return builder.fixedStatus ?? 'undecided';
|
|
4526
|
+
}
|
|
4527
|
+
/**
|
|
4528
|
+
* The status an envelope expression states itself: a literal's status
|
|
4529
|
+
* member, or what a followable builder call sends. `'undecided'` for
|
|
4530
|
+
* anything else (a variable holding an envelope states nothing here).
|
|
4531
|
+
*/
|
|
4532
|
+
envelopeOwnStatus(expression) {
|
|
4533
|
+
const node = this.peelTransparentExpression(expression);
|
|
4534
|
+
if (Node.isObjectLiteralExpression(node))
|
|
4535
|
+
return this.envelopeLiteralStatus(node);
|
|
4536
|
+
if (!Node.isCallExpression(node))
|
|
4537
|
+
return 'undecided';
|
|
4538
|
+
const builder = this.envelopeBuilderOf(node);
|
|
4539
|
+
if (builder === 'failure')
|
|
4540
|
+
return 'error';
|
|
4541
|
+
return builder ? this.builderCallStatus(node, builder) : 'undecided';
|
|
4542
|
+
}
|
|
4543
|
+
/** The status an envelope literal's `statusCode` or `status` member states. */
|
|
4544
|
+
envelopeLiteralStatus(literal) {
|
|
4545
|
+
for (const name of STATUS_MEMBER_NAMES) {
|
|
4546
|
+
const value = this.envelopeMemberValue(literal, name);
|
|
4547
|
+
if (value)
|
|
4548
|
+
return this.statusOfValue(value);
|
|
4549
|
+
}
|
|
4550
|
+
return 'undecided';
|
|
4551
|
+
}
|
|
4552
|
+
/** The status a value states, classified as a send's status argument is. */
|
|
4553
|
+
statusOfValue(value) {
|
|
4554
|
+
if (!value)
|
|
4555
|
+
return 'undecided';
|
|
4556
|
+
const read = this.statedStatusCodes(this.peelTransparentExpression(value));
|
|
4557
|
+
if (read === 'variable')
|
|
4558
|
+
return 'variable';
|
|
4559
|
+
if (!read)
|
|
4560
|
+
return 'undecided';
|
|
4561
|
+
const kind = classifyStatusCodes(read);
|
|
4562
|
+
return kind === 'mixed' ? 'variable' : kind;
|
|
4563
|
+
}
|
|
4564
|
+
/**
|
|
4565
|
+
* The value node an object literal gives a member: a property assignment's
|
|
4566
|
+
* initializer, or a shorthand's own name.
|
|
4567
|
+
*/
|
|
4568
|
+
envelopeMemberValue(literal, name) {
|
|
4569
|
+
const property = literal.getProperty(name);
|
|
4570
|
+
if (property && Node.isPropertyAssignment(property))
|
|
4571
|
+
return property.getInitializer();
|
|
4572
|
+
if (property && Node.isShorthandPropertyAssignment(property))
|
|
4573
|
+
return property.getNameNode();
|
|
4574
|
+
return undefined;
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* The value an envelope literal's `body` serialises: the argument of a
|
|
4578
|
+
* `JSON.stringify` written there, or written as the initializer of a
|
|
4579
|
+
* `const` that `scope` declares and `body` names. `undefined` when `body`
|
|
4580
|
+
* is built any other way.
|
|
4581
|
+
*/
|
|
4582
|
+
serialisedBodyArgument(literal, scope) {
|
|
4583
|
+
const raw = this.envelopeMemberValue(literal, 'body');
|
|
4584
|
+
if (!raw)
|
|
4585
|
+
return undefined;
|
|
4586
|
+
const value = this.peelTransparentExpression(raw);
|
|
4587
|
+
const direct = this.unwrapJsonStringifyArg(value);
|
|
4588
|
+
if (direct !== value)
|
|
4589
|
+
return direct;
|
|
4590
|
+
const initializer = this.constInitializerInScope(value, scope);
|
|
4591
|
+
if (!initializer)
|
|
4592
|
+
return undefined;
|
|
4593
|
+
const bound = this.peelTransparentExpression(initializer);
|
|
4594
|
+
const viaConst = this.unwrapJsonStringifyArg(bound);
|
|
4595
|
+
return viaConst !== bound ? viaConst : undefined;
|
|
4596
|
+
}
|
|
4597
|
+
/**
|
|
4598
|
+
* The initializer of the `const` an identifier (or a shorthand member's
|
|
4599
|
+
* name) refers to, when `scope` itself declares it.
|
|
4600
|
+
*/
|
|
4601
|
+
constInitializerInScope(node, scope) {
|
|
4602
|
+
for (const declaration of this.valueSymbolOf(node)?.getDeclarations() ?? []) {
|
|
4603
|
+
if (!Node.isVariableDeclaration(declaration))
|
|
4604
|
+
continue;
|
|
4605
|
+
if (declaration.getVariableStatement()?.getDeclarationKind() !==
|
|
4606
|
+
VariableDeclarationKind.Const) {
|
|
4607
|
+
continue;
|
|
4608
|
+
}
|
|
4609
|
+
if (this.findContainingFunctionForNode(declaration) !== scope)
|
|
4610
|
+
continue;
|
|
4611
|
+
return declaration.getInitializer();
|
|
4612
|
+
}
|
|
4613
|
+
return undefined;
|
|
4614
|
+
}
|
|
4615
|
+
/**
|
|
4616
|
+
* The symbol of the value a node names: a shorthand member's value symbol
|
|
4617
|
+
* (its own symbol is the property), else the identifier's symbol.
|
|
4618
|
+
*/
|
|
4619
|
+
valueSymbolOf(node) {
|
|
4620
|
+
const parent = node.getParent();
|
|
4621
|
+
if (parent && Node.isShorthandPropertyAssignment(parent) && parent.getNameNode() === node) {
|
|
4622
|
+
return parent.getValueSymbol();
|
|
4623
|
+
}
|
|
4624
|
+
return Node.isIdentifier(node) ? node.getSymbol() : undefined;
|
|
4625
|
+
}
|
|
4626
|
+
/** The index of the `params` entry a node names, or `undefined`. */
|
|
4627
|
+
parameterIndexOf(node, params) {
|
|
4628
|
+
for (const declaration of this.valueSymbolOf(node)?.getDeclarations() ?? []) {
|
|
4629
|
+
const index = params.findIndex((param) => param === declaration);
|
|
4630
|
+
if (index >= 0)
|
|
4631
|
+
return index;
|
|
4632
|
+
}
|
|
4633
|
+
return undefined;
|
|
4634
|
+
}
|
|
4635
|
+
/**
|
|
4636
|
+
* How a call's callee builds an envelope, when the callee is a function this
|
|
4637
|
+
* repository declares (not lib, not an installed package) and every return
|
|
4638
|
+
* it makes is an envelope literal:
|
|
4639
|
+
* - returns whose status member is a literal 4xx or 5xx are its failure
|
|
4640
|
+
* path and are ignored; a builder with nothing else is `'failure'`, so a
|
|
4641
|
+
* call to it is an error branch;
|
|
4642
|
+
* - every other return must serialise the same parameter into `body` and
|
|
4643
|
+
* take its status from the same parameter, or state the same literal
|
|
4644
|
+
* success or redirect status.
|
|
4645
|
+
* `undefined` when the callee is anything else.
|
|
4646
|
+
*/
|
|
4647
|
+
envelopeBuilderOf(call) {
|
|
4648
|
+
let symbol = call.getExpression().getSymbol();
|
|
4649
|
+
if (symbol?.isAlias())
|
|
4650
|
+
symbol = symbol.getAliasedSymbol() ?? symbol;
|
|
4651
|
+
if (!symbol || this.symbolIsLibOrExternalOrigin(symbol))
|
|
4652
|
+
return undefined;
|
|
4653
|
+
const builder = this.builderFunctionOf(symbol);
|
|
4654
|
+
if (!builder)
|
|
4655
|
+
return undefined;
|
|
4656
|
+
const params = builder.getParameters();
|
|
4657
|
+
const returned = this.returnedExpressions(builder).flatMap((expression) => this.expandResponseBranches(expression, 0));
|
|
4658
|
+
let mapping;
|
|
4659
|
+
let sawFailure = false;
|
|
4660
|
+
for (const expression of returned) {
|
|
4661
|
+
const literal = this.peelTransparentExpression(expression);
|
|
4662
|
+
if (!Node.isObjectLiteralExpression(literal))
|
|
4663
|
+
return undefined;
|
|
4664
|
+
const statusValue = STATUS_MEMBER_NAMES.map((name) => this.envelopeMemberValue(literal, name)).find((value) => value !== undefined);
|
|
4665
|
+
if (!statusValue)
|
|
4666
|
+
return undefined;
|
|
4667
|
+
const statusParameter = this.parameterIndexOf(statusValue, params);
|
|
4668
|
+
let fixedStatus;
|
|
4669
|
+
if (statusParameter === undefined) {
|
|
4670
|
+
const codes = this.statedStatusCodes(this.peelTransparentExpression(statusValue));
|
|
4671
|
+
if (!Array.isArray(codes))
|
|
4672
|
+
return undefined;
|
|
4673
|
+
const kind = classifyStatusCodes(codes);
|
|
4674
|
+
if (kind === 'error') {
|
|
4675
|
+
sawFailure = true;
|
|
4676
|
+
continue;
|
|
4677
|
+
}
|
|
4678
|
+
if (kind === 'mixed')
|
|
4679
|
+
return undefined;
|
|
4680
|
+
fixedStatus = kind;
|
|
4681
|
+
}
|
|
4682
|
+
const serialised = this.serialisedBodyArgument(literal, builder);
|
|
4683
|
+
const payloadParameter = serialised ? this.parameterIndexOf(serialised, params) : undefined;
|
|
4684
|
+
if (payloadParameter === undefined)
|
|
4685
|
+
return undefined;
|
|
4686
|
+
const here = { builder, payloadParameter, statusParameter, fixedStatus };
|
|
4687
|
+
if (mapping &&
|
|
4688
|
+
(mapping.payloadParameter !== here.payloadParameter ||
|
|
4689
|
+
mapping.statusParameter !== here.statusParameter ||
|
|
4690
|
+
mapping.fixedStatus !== here.fixedStatus)) {
|
|
4691
|
+
return undefined;
|
|
4692
|
+
}
|
|
4693
|
+
mapping = here;
|
|
4694
|
+
}
|
|
4695
|
+
if (mapping)
|
|
4696
|
+
return mapping;
|
|
4697
|
+
return sawFailure ? 'failure' : undefined;
|
|
4698
|
+
}
|
|
4699
|
+
/**
|
|
4700
|
+
* The single function a callee symbol declares with a body: a function
|
|
4701
|
+
* declaration, or a variable initialised with an arrow or function
|
|
4702
|
+
* expression.
|
|
4703
|
+
*/
|
|
4704
|
+
builderFunctionOf(symbol) {
|
|
4705
|
+
const bodies = [];
|
|
4706
|
+
for (const declaration of symbol.getDeclarations()) {
|
|
4707
|
+
if (Node.isFunctionDeclaration(declaration) && declaration.getBody()) {
|
|
4708
|
+
bodies.push(declaration);
|
|
4709
|
+
}
|
|
4710
|
+
else if (Node.isVariableDeclaration(declaration)) {
|
|
4711
|
+
const initializer = declaration.getInitializer();
|
|
4712
|
+
const fn = initializer ? this.peelTransparentExpression(initializer) : undefined;
|
|
4713
|
+
if (fn && (Node.isArrowFunction(fn) || Node.isFunctionExpression(fn)))
|
|
4714
|
+
bodies.push(fn);
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
return bodies.length === 1 ? bodies[0] : undefined;
|
|
4718
|
+
}
|
|
4719
|
+
/**
|
|
4720
|
+
* The payload a builder call serialises. The source states it, and the
|
|
4721
|
+
* answer is explicit, when the argument carries `satisfies`/`as`, when the
|
|
4722
|
+
* builder's parameter is one of its type parameters and the call passes
|
|
4723
|
+
* type arguments (`reply<AddNoteResult>(201, x)`), or when the parameter is
|
|
4724
|
+
* declared with a type that says something. Otherwise the argument's own
|
|
4725
|
+
* type is read.
|
|
4726
|
+
*/
|
|
4727
|
+
builderPayloadCandidate(call, builder, argument, wire) {
|
|
4728
|
+
const payloadNode = this.peelTransparentExpression(argument);
|
|
4729
|
+
if (this.statedTypeNodeOf(payloadNode)) {
|
|
4730
|
+
return this.envelopePayloadCandidate(payloadNode, wire);
|
|
4731
|
+
}
|
|
4732
|
+
const declared = builder.builder.getParameters()[builder.payloadParameter]?.getTypeNode();
|
|
4733
|
+
if (declared) {
|
|
4734
|
+
const typeParameterIndex = builder.builder
|
|
4735
|
+
.getTypeParameters()
|
|
4736
|
+
.findIndex((tp) => tp.getName() === declared.getText().trim());
|
|
4737
|
+
if (typeParameterIndex >= 0) {
|
|
4738
|
+
const typeArgument = call.getTypeArguments()[typeParameterIndex];
|
|
4739
|
+
if (typeArgument) {
|
|
4740
|
+
return this.checkedEnvelopeCandidate(this.statedPayloadCandidate(payloadNode, typeArgument, wire));
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
else if (!this.typeNodeStatesNothing(declared)) {
|
|
4744
|
+
return this.checkedEnvelopeCandidate(this.statedPayloadCandidate(payloadNode, declared, wire));
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
return this.envelopePayloadCandidate(payloadNode, wire);
|
|
4748
|
+
}
|
|
4749
|
+
/**
|
|
4750
|
+
* True when a declared parameter type does not say what is sent: a top
|
|
4751
|
+
* type, the `object` keyword, an object type that names no member (`{}`,
|
|
4752
|
+
* `Record<string, unknown>`), a union that admits both primitives and
|
|
4753
|
+
* objects (a JSON-value type such as `string | number | Json[] | { [k:
|
|
4754
|
+
* string]: Json }`), or a type that mentions a type parameter. A builder
|
|
4755
|
+
* that takes "any value" states nothing about the payload a call hands it,
|
|
4756
|
+
* so the argument's own type is read instead.
|
|
4757
|
+
*/
|
|
4758
|
+
typeNodeStatesNothing(typeNode) {
|
|
4759
|
+
const type = typeNode.getType();
|
|
4760
|
+
if (type.isAny() || type.isUnknown() || type.isTypeParameter())
|
|
4761
|
+
return true;
|
|
4762
|
+
if (type.getFlags() & ts.TypeFlags.NonPrimitive)
|
|
4763
|
+
return true;
|
|
4764
|
+
if (type.isObject() && !type.isArray() && type.getProperties().length === 0) {
|
|
4765
|
+
return true;
|
|
4766
|
+
}
|
|
4767
|
+
if (type.isUnion()) {
|
|
4768
|
+
const members = type.getUnionTypes();
|
|
4769
|
+
const primitive = members.some((member) => member.isString() ||
|
|
4770
|
+
member.isNumber() ||
|
|
4771
|
+
member.isBoolean() ||
|
|
4772
|
+
member.isBooleanLiteral());
|
|
4773
|
+
if (primitive && members.some((member) => member.isObject()))
|
|
4774
|
+
return true;
|
|
4775
|
+
}
|
|
4776
|
+
return [typeNode, ...typeNode.getDescendants()].some((node) => Node.isTypeReference(node) && node.getType().isTypeParameter());
|
|
4777
|
+
}
|
|
4778
|
+
/** A serialised value's candidate, unread when it holds a top type. */
|
|
4779
|
+
envelopePayloadCandidate(payloadNode, wire) {
|
|
4780
|
+
return this.checkedEnvelopeCandidate(this.payloadCandidateOf(payloadNode, wire));
|
|
4781
|
+
}
|
|
4782
|
+
/**
|
|
4783
|
+
* A payload candidate, or unread when its printed type is or holds `any`
|
|
4784
|
+
* or `unknown` anywhere (`{ [x: string]: any }`, `{ data: any }`). A
|
|
4785
|
+
* contract with a hole in it is not published on this path: the capture
|
|
4786
|
+
* would refuse the text and re-read the line, which can publish the
|
|
4787
|
+
* envelope.
|
|
4788
|
+
*/
|
|
4789
|
+
checkedEnvelopeCandidate(candidate) {
|
|
4790
|
+
return TOP_TYPE_TOKEN.test(candidate.typeString) ? { unread: 'top_type' } : candidate;
|
|
4791
|
+
}
|
|
4165
4792
|
/**
|
|
4166
4793
|
* The request field each member of a joined union answers
|
|
4167
4794
|
* (`response-modes.ts`). Case texts are joined by the published union's
|
|
@@ -4560,16 +5187,9 @@ export class TypeInferrer {
|
|
|
4560
5187
|
const receiver = this.peelTransparentExpression(callee.getExpression());
|
|
4561
5188
|
if (!Node.isCallExpression(receiver))
|
|
4562
5189
|
return undefined;
|
|
4563
|
-
const
|
|
4564
|
-
if (
|
|
4565
|
-
|
|
4566
|
-
if (read === 'variable')
|
|
4567
|
-
return 'variable';
|
|
4568
|
-
if (read) {
|
|
4569
|
-
const kind = classifyStatusCodes(read);
|
|
4570
|
-
return kind === 'mixed' ? 'variable' : kind;
|
|
4571
|
-
}
|
|
4572
|
-
}
|
|
5190
|
+
const status = this.statusOfValue(receiver.getArguments()[0]);
|
|
5191
|
+
if (status !== 'undecided')
|
|
5192
|
+
return status;
|
|
4573
5193
|
callee = receiver.getExpression();
|
|
4574
5194
|
}
|
|
4575
5195
|
return undefined;
|
|
@@ -4681,6 +5301,25 @@ export class TypeInferrer {
|
|
|
4681
5301
|
}
|
|
4682
5302
|
return [expression];
|
|
4683
5303
|
}
|
|
5304
|
+
/**
|
|
5305
|
+
* The symbol an anchor is named from: the type's own symbol, unless that
|
|
5306
|
+
* symbol's name is synthetic (`__type`, `__object`, ...), in which case the
|
|
5307
|
+
* alias symbol (carrick#2148).
|
|
5308
|
+
*
|
|
5309
|
+
* `type Widget = { id: string }` and an instantiated `Page<Order>` carry
|
|
5310
|
+
* the anonymous `__type` as their own symbol and the alias as their alias
|
|
5311
|
+
* symbol. Reading the own symbol first and stopping because it is truthy
|
|
5312
|
+
* left every object-literal alias without an anchor, while the same shape
|
|
5313
|
+
* declared as an interface had one. The own symbol still wins whenever it
|
|
5314
|
+
* has a real name, so every type anchored before keeps its anchor.
|
|
5315
|
+
*/
|
|
5316
|
+
anchorSymbolOf(type) {
|
|
5317
|
+
const own = type.getSymbol();
|
|
5318
|
+
if (own && !own.getName().startsWith('__')) {
|
|
5319
|
+
return own;
|
|
5320
|
+
}
|
|
5321
|
+
return type.getAliasSymbol() ?? own;
|
|
5322
|
+
}
|
|
4684
5323
|
/**
|
|
4685
5324
|
* The deterministic source symbol of a resolved type (`Payment` for a payload
|
|
4686
5325
|
* typed `Payment`), or `undefined` when there is no single user-defined
|
|
@@ -4691,7 +5330,7 @@ export class TypeInferrer {
|
|
|
4691
5330
|
* `primary_type_symbol` so the manifest anchor no longer depends on the LLM.
|
|
4692
5331
|
*/
|
|
4693
5332
|
primaryTypeSymbol(type) {
|
|
4694
|
-
const name =
|
|
5333
|
+
const name = this.anchorSymbolOf(type)?.getName();
|
|
4695
5334
|
// Reject the compiler's synthetic names for anonymous shapes (`__type`,
|
|
4696
5335
|
// `__object`, `__function`, …). They are not user-facing type names, so
|
|
4697
5336
|
// they would be a meaningless — and non-resolvable — anchor.
|
|
@@ -4711,7 +5350,8 @@ export class TypeInferrer {
|
|
|
4711
5350
|
* declarations IN its `source_file`, so the request must point at the file
|
|
4712
5351
|
* that actually declares the tsc-witnessed payload type.
|
|
4713
5352
|
*
|
|
4714
|
-
* The declarations read are those of the
|
|
5353
|
+
* The declarations read are those of the symbol the anchor is named from
|
|
5354
|
+
* (`anchorSymbolOf`: the type's own symbol, or its alias's), so a name
|
|
4715
5355
|
* imported through a barrel reports the file that declares it, and a name
|
|
4716
5356
|
* two files declare reports the one this type resolves to. A declaration in
|
|
4717
5357
|
* an installed package or a TypeScript lib is reported as it is found, as
|
|
@@ -4730,7 +5370,7 @@ export class TypeInferrer {
|
|
|
4730
5370
|
if (this.primaryTypeSymbol(type) === undefined) {
|
|
4731
5371
|
return undefined;
|
|
4732
5372
|
}
|
|
4733
|
-
const decls =
|
|
5373
|
+
const decls = this.anchorSymbolOf(type)?.getDeclarations() ?? [];
|
|
4734
5374
|
const declaring = decls.find((d) => Node.isInterfaceDeclaration(d) ||
|
|
4735
5375
|
Node.isTypeAliasDeclaration(d) ||
|
|
4736
5376
|
Node.isClassDeclaration(d) ||
|
|
@@ -5388,6 +6028,56 @@ export class TypeInferrer {
|
|
|
5388
6028
|
}
|
|
5389
6029
|
return undefined;
|
|
5390
6030
|
}
|
|
6031
|
+
/**
|
|
6032
|
+
* The platform body read whose result `located` is a MEMBER of, or
|
|
6033
|
+
* `undefined` (carrick#2057).
|
|
6034
|
+
*
|
|
6035
|
+
* A member is a property or element access off a binding, or a name
|
|
6036
|
+
* destructured out of one. The binding must be initialised by the body read
|
|
6037
|
+
* `platformBodyReadIn` finds in the function that declares it, with only
|
|
6038
|
+
* transparent wrappers between the two (`await`, parentheses, `!`, a cast,
|
|
6039
|
+
* `satisfies`). The binding itself is not a member, and neither is a member
|
|
6040
|
+
* of anything else (a query object, a parameter, an outbound response).
|
|
6041
|
+
*/
|
|
6042
|
+
parsedBodyReadHolding(located) {
|
|
6043
|
+
let node = this.unwrapExpressionNode(located);
|
|
6044
|
+
let member = false;
|
|
6045
|
+
while (Node.isPropertyAccessExpression(node) ||
|
|
6046
|
+
Node.isElementAccessExpression(node) ||
|
|
6047
|
+
Node.isNonNullExpression(node) ||
|
|
6048
|
+
Node.isParenthesizedExpression(node)) {
|
|
6049
|
+
if (Node.isPropertyAccessExpression(node) || Node.isElementAccessExpression(node)) {
|
|
6050
|
+
member = true;
|
|
6051
|
+
}
|
|
6052
|
+
node = node.getExpression();
|
|
6053
|
+
}
|
|
6054
|
+
if (!Node.isIdentifier(node))
|
|
6055
|
+
return undefined;
|
|
6056
|
+
let declaration = node.getSymbol()?.getDeclarations()?.[0];
|
|
6057
|
+
while (declaration && Node.isBindingElement(declaration)) {
|
|
6058
|
+
member = true;
|
|
6059
|
+
declaration = declaration.getParent()?.getParent();
|
|
6060
|
+
}
|
|
6061
|
+
if (!member || !declaration || !Node.isVariableDeclaration(declaration))
|
|
6062
|
+
return undefined;
|
|
6063
|
+
const handler = this.findContainingFunctionForNode(declaration);
|
|
6064
|
+
const read = handler ? this.platformBodyReadIn(handler) : undefined;
|
|
6065
|
+
if (!read)
|
|
6066
|
+
return undefined;
|
|
6067
|
+
let current = read;
|
|
6068
|
+
for (;;) {
|
|
6069
|
+
const parent = current.getParent();
|
|
6070
|
+
if (!parent)
|
|
6071
|
+
return undefined;
|
|
6072
|
+
if (parent === declaration) {
|
|
6073
|
+
return declaration.getInitializer() === current ? read : undefined;
|
|
6074
|
+
}
|
|
6075
|
+
if (!this.wrapsReadTransparently(parent)) {
|
|
6076
|
+
return undefined;
|
|
6077
|
+
}
|
|
6078
|
+
current = parent;
|
|
6079
|
+
}
|
|
6080
|
+
}
|
|
5391
6081
|
/**
|
|
5392
6082
|
* The request contract the source states AT a body read, or null when it
|
|
5393
6083
|
* states none there (carrick#807).
|
|
@@ -6785,21 +7475,119 @@ export class TypeInferrer {
|
|
|
6785
7475
|
// locator whose exact match failed would bind to the smallest embedded
|
|
6786
7476
|
// sub-expression (pickBestMatch prefers small nodes), e.g. the 8-char
|
|
6787
7477
|
// literal "active" inside a res.json object payload (#335).
|
|
7478
|
+
//
|
|
7479
|
+
// A node the text itself places strictly inside what it names is a
|
|
7480
|
+
// fragment, however much of the text it covers (carrick#2056): the
|
|
7481
|
+
// `new URLSearchParams(…)` inside a call's URL argument, or the object a
|
|
7482
|
+
// callback nested in the named object returns. Only the node the text
|
|
7483
|
+
// names, or a wrapper the text adds around it, may be bound this way. A
|
|
7484
|
+
// fragment still points at what the text names where the source agrees
|
|
7485
|
+
// with the text up to it: the enclosing node of the named kind whose text
|
|
7486
|
+
// opens with the text's own opening up to the fragment (`new Response(`
|
|
7487
|
+
// before a payload, when the text dropped the call's second argument).
|
|
6788
7488
|
const MIN_REVERSE_MATCH_LEN = 8;
|
|
6789
7489
|
const MIN_REVERSE_MATCH_COVERAGE = 0.5;
|
|
6790
|
-
const
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
7490
|
+
const containingMatches = candidates.filter((c) => c.text.includes(normalizedTarget));
|
|
7491
|
+
if (containingMatches.length > 0) {
|
|
7492
|
+
return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
|
|
7493
|
+
}
|
|
7494
|
+
const reverseMatches = candidates.filter((c) => c.text.length >= MIN_REVERSE_MATCH_LEN &&
|
|
7495
|
+
c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
|
|
7496
|
+
normalizedTarget.includes(c.text));
|
|
7497
|
+
if (reverseMatches.length === 0)
|
|
7498
|
+
return undefined;
|
|
7499
|
+
const named = this.locatorNames(normalizedTarget);
|
|
7500
|
+
if (!named) {
|
|
7501
|
+
return this.pickBestMatch(reverseMatches.map((c) => c.node), lineNumber);
|
|
7502
|
+
}
|
|
7503
|
+
const textOf = new Map(candidates.map((c) => [c.node, c.text]));
|
|
7504
|
+
const bound = new Set();
|
|
7505
|
+
for (const c of reverseMatches) {
|
|
7506
|
+
if (named.texts.has(c.text)) {
|
|
7507
|
+
bound.add(c.node);
|
|
7508
|
+
continue;
|
|
7509
|
+
}
|
|
7510
|
+
const at = normalizedTarget.indexOf(c.text, named.rootStart + 1);
|
|
7511
|
+
if (at < 0)
|
|
7512
|
+
continue;
|
|
7513
|
+
const opening = normalizedTarget.slice(named.rootStart, at);
|
|
7514
|
+
const enclosing = c.node.getFirstAncestor((ancestor) => ancestor.getKind() === named.rootKind &&
|
|
7515
|
+
textOf.get(ancestor)?.startsWith(opening) === true);
|
|
7516
|
+
if (enclosing)
|
|
7517
|
+
bound.add(enclosing);
|
|
6801
7518
|
}
|
|
6802
|
-
return undefined;
|
|
7519
|
+
return bound.size > 0 ? this.pickBestMatch([...bound], lineNumber) : undefined;
|
|
7520
|
+
}
|
|
7521
|
+
/**
|
|
7522
|
+
* What a locator's text names, read off its own parse (carrick#2056):
|
|
7523
|
+
*
|
|
7524
|
+
* - `texts`: the normalized text of the expression it names and of each
|
|
7525
|
+
* wrapper it carries around it (`return`, a `const x =` binding, a label,
|
|
7526
|
+
* `await`, parentheses, a cast, `satisfies`, `!`). A node strictly inside
|
|
7527
|
+
* that expression is not among them;
|
|
7528
|
+
* - `rootKind` and `rootStart`: the innermost of those, its kind, and where
|
|
7529
|
+
* its text starts in the locator.
|
|
7530
|
+
*
|
|
7531
|
+
* `null` when the text does not parse as one expression or one statement:
|
|
7532
|
+
* there is no structure to read, and the caller keeps its text rule.
|
|
7533
|
+
*/
|
|
7534
|
+
locatorNames(normalizedTarget) {
|
|
7535
|
+
const parsed = (source) => {
|
|
7536
|
+
const file = ts.createSourceFile('locator.ts', source, ts.ScriptTarget.Latest, true);
|
|
7537
|
+
const diagnostics = file
|
|
7538
|
+
.parseDiagnostics;
|
|
7539
|
+
return diagnostics && diagnostics.length === 0 && file.statements.length === 1 ? file : null;
|
|
7540
|
+
};
|
|
7541
|
+
// An object literal is a block as a statement, so try the text as an
|
|
7542
|
+
// expression first.
|
|
7543
|
+
const EXPRESSION_OPENING = '(\n';
|
|
7544
|
+
const asExpression = parsed(`${EXPRESSION_OPENING}${normalizedTarget}\n);`);
|
|
7545
|
+
let node;
|
|
7546
|
+
let file = asExpression;
|
|
7547
|
+
let offset = 0;
|
|
7548
|
+
if (asExpression) {
|
|
7549
|
+
const statement = asExpression.statements[0];
|
|
7550
|
+
if (ts.isExpressionStatement(statement) && ts.isParenthesizedExpression(statement.expression)) {
|
|
7551
|
+
node = statement.expression.expression;
|
|
7552
|
+
offset = EXPRESSION_OPENING.length;
|
|
7553
|
+
}
|
|
7554
|
+
}
|
|
7555
|
+
else {
|
|
7556
|
+
file = parsed(normalizedTarget);
|
|
7557
|
+
node = file?.statements[0];
|
|
7558
|
+
}
|
|
7559
|
+
if (!file || !node)
|
|
7560
|
+
return null;
|
|
7561
|
+
const texts = new Set();
|
|
7562
|
+
let root;
|
|
7563
|
+
while (node) {
|
|
7564
|
+
if (ts.isLabeledStatement(node)) {
|
|
7565
|
+
node = node.statement;
|
|
7566
|
+
}
|
|
7567
|
+
else if (ts.isExpressionStatement(node) || ts.isReturnStatement(node)) {
|
|
7568
|
+
node = node.expression;
|
|
7569
|
+
}
|
|
7570
|
+
else if (ts.isVariableStatement(node)) {
|
|
7571
|
+
const declarations = node.declarationList.declarations;
|
|
7572
|
+
node = declarations.length === 1 ? declarations[0].initializer : undefined;
|
|
7573
|
+
}
|
|
7574
|
+
else {
|
|
7575
|
+
texts.add(this.normalizeWhitespace(node.getText(file)));
|
|
7576
|
+
root = node;
|
|
7577
|
+
node =
|
|
7578
|
+
ts.isParenthesizedExpression(node) ||
|
|
7579
|
+
ts.isAwaitExpression(node) ||
|
|
7580
|
+
ts.isAsExpression(node) ||
|
|
7581
|
+
ts.isSatisfiesExpression(node) ||
|
|
7582
|
+
ts.isNonNullExpression(node) ||
|
|
7583
|
+
ts.isTypeAssertionExpression(node)
|
|
7584
|
+
? node.expression
|
|
7585
|
+
: undefined;
|
|
7586
|
+
}
|
|
7587
|
+
}
|
|
7588
|
+
if (!root)
|
|
7589
|
+
return null;
|
|
7590
|
+
return { texts, rootKind: root.kind, rootStart: root.getStart(file) - offset };
|
|
6803
7591
|
}
|
|
6804
7592
|
/**
|
|
6805
7593
|
* Pick the best match from a set of candidate nodes:
|
|
@@ -6840,7 +7628,7 @@ export class TypeInferrer {
|
|
|
6840
7628
|
* for the same reason.
|
|
6841
7629
|
*/
|
|
6842
7630
|
normalizeWhitespace(text) {
|
|
6843
|
-
|
|
7631
|
+
const collapsed = text
|
|
6844
7632
|
.replace(/\s+/g, ' ')
|
|
6845
7633
|
// A member chain broken before its dot (`client\n .list(…)`) reads the
|
|
6846
7634
|
// same as `client.list(…)`; a space around `.` / `?.` means nothing
|
|
@@ -6849,6 +7637,8 @@ export class TypeInferrer {
|
|
|
6849
7637
|
.replace(/\s*,?\s*([}\)\]])/g, '$1')
|
|
6850
7638
|
.replace(/([\(\[{])\s+/g, '$1')
|
|
6851
7639
|
.trim();
|
|
7640
|
+
// A string literal reads the same in any quotes (carrick#2056).
|
|
7641
|
+
return canonicalStringQuotes(collapsed);
|
|
6852
7642
|
}
|
|
6853
7643
|
/**
|
|
6854
7644
|
* Format a human-readable location string for error messages.
|