carrick 0.3.110 → 0.3.112
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 +36 -9
- package/dist/render.js +102 -38
- package/dist/render.js.map +1 -1
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +10 -0
- package/sidecar/dist/src/bundler.js +4 -7
- package/sidecar/dist/src/capture/anchors.js +5 -2
- 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/self-check.d.ts +20 -1
- package/sidecar/dist/src/capture/self-check.js +30 -11
- package/sidecar/dist/src/type-inferrer.d.ts +112 -2
- package/sidecar/dist/src/type-inferrer.js +498 -44
- package/sidecar/dist/src/types.d.ts +4 -1
|
@@ -257,6 +257,83 @@ function typeText(type, enclosingNode) {
|
|
|
257
257
|
* `text` is `string`, alone or beside `null` and `undefined` in either order:
|
|
258
258
|
* the only payload a raw-text read publishes (carrick#1842).
|
|
259
259
|
*/
|
|
260
|
+
/**
|
|
261
|
+
* Source text with every plain string literal written in double quotes:
|
|
262
|
+
* `'/a'`, `"/a"` and an interpolation-free `` `/a` `` are one value, so text
|
|
263
|
+
* that differs only in its quotes compares equal (carrick#2056).
|
|
264
|
+
*
|
|
265
|
+
* The text is read left to right, pairing quotes the way the scanner does,
|
|
266
|
+
* so a template's closing backtick never pairs with the next template's
|
|
267
|
+
* opening one. A literal holding a quote, a backslash or an interpolation is
|
|
268
|
+
* copied as it is, and so is an unterminated one with everything after it.
|
|
269
|
+
*/
|
|
270
|
+
export function canonicalStringQuotes(text) {
|
|
271
|
+
const isQuote = (ch) => ch === "'" || ch === '"' || ch === '`';
|
|
272
|
+
// The index just past the literal opening at `start`, or -1.
|
|
273
|
+
const literalEnd = (start) => {
|
|
274
|
+
const quote = text[start];
|
|
275
|
+
let j = start + 1;
|
|
276
|
+
while (j < text.length) {
|
|
277
|
+
const ch = text[j];
|
|
278
|
+
if (ch === '\\') {
|
|
279
|
+
j += 2;
|
|
280
|
+
}
|
|
281
|
+
else if (ch === quote) {
|
|
282
|
+
return j + 1;
|
|
283
|
+
}
|
|
284
|
+
else if (quote === '`' && ch === '$' && text[j + 1] === '{') {
|
|
285
|
+
const close = codeEnd(j + 2);
|
|
286
|
+
if (close < 0)
|
|
287
|
+
return -1;
|
|
288
|
+
j = close + 1;
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
j++;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return -1;
|
|
295
|
+
};
|
|
296
|
+
// The index of the `}` that closes an interpolation whose code starts at `start`, or -1.
|
|
297
|
+
const codeEnd = (start) => {
|
|
298
|
+
let depth = 0;
|
|
299
|
+
let j = start;
|
|
300
|
+
while (j < text.length) {
|
|
301
|
+
const ch = text[j];
|
|
302
|
+
if (isQuote(ch)) {
|
|
303
|
+
const end = literalEnd(j);
|
|
304
|
+
if (end < 0)
|
|
305
|
+
return -1;
|
|
306
|
+
j = end;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === '{')
|
|
310
|
+
depth++;
|
|
311
|
+
if (ch === '}') {
|
|
312
|
+
if (depth === 0)
|
|
313
|
+
return j;
|
|
314
|
+
depth--;
|
|
315
|
+
}
|
|
316
|
+
j++;
|
|
317
|
+
}
|
|
318
|
+
return -1;
|
|
319
|
+
};
|
|
320
|
+
let out = '';
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (i < text.length) {
|
|
323
|
+
if (!isQuote(text[i])) {
|
|
324
|
+
out += text[i];
|
|
325
|
+
i++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const end = literalEnd(i);
|
|
329
|
+
if (end < 0)
|
|
330
|
+
return out + text.slice(i);
|
|
331
|
+
const body = text.slice(i + 1, end - 1);
|
|
332
|
+
out += /['"`\\]|\$\{/.test(body) ? text.slice(i, end) : `"${body}"`;
|
|
333
|
+
i = end;
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
260
337
|
function isBareStringText(text) {
|
|
261
338
|
const members = text.split('|').map((member) => member.trim());
|
|
262
339
|
return (members.includes('string') &&
|
|
@@ -1048,7 +1125,11 @@ export class TypeInferrer {
|
|
|
1048
1125
|
// argument is the route path, not a payload. The span locator falls
|
|
1049
1126
|
// back to exactly this shape when no payload expression was reported,
|
|
1050
1127
|
// so drilling here would put the path literal's type in the manifest.
|
|
1051
|
-
|
|
1128
|
+
// A call handed a function whose result is plain data is not a
|
|
1129
|
+
// registration but the payload: `rows.map((r) => ({ ... }))` sends the
|
|
1130
|
+
// list, not the callback's return (carrick#2055).
|
|
1131
|
+
const registersCallback = args.some((arg) => Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) &&
|
|
1132
|
+
!this.callResultIsPlainData(node);
|
|
1052
1133
|
if (registersCallback) {
|
|
1053
1134
|
// A registration that DECLARES its response contract in a schema needs
|
|
1054
1135
|
// no indirection: the declaration is the contract the framework
|
|
@@ -1099,7 +1180,9 @@ export class TypeInferrer {
|
|
|
1099
1180
|
// `typeText` keeps the bare name `Payment`, which dangles in the
|
|
1100
1181
|
// source-less cross-repo bundle → `any` → unverifiable. Expand the
|
|
1101
1182
|
// resolved object structurally so the real members land in the bundle.
|
|
1102
|
-
|
|
1183
|
+
// The name it falls back to is the awaited one: a located call under
|
|
1184
|
+
// `await` sends what it resolves to, never the promise (carrick#2055).
|
|
1185
|
+
typeString = this.expandResolvedTypeStructural(resolved, typeText(resolved, payloadNode), this.wireFormatFor(request));
|
|
1103
1186
|
}
|
|
1104
1187
|
const anchor = this.unwrapArrayLevels(this.unwrapPromiseType(payloadType));
|
|
1105
1188
|
return this.createInferredType(request, typeString, false, this.getNodeLocation(payloadNode), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, this.primaryTypeSymbol(anchor.element), anchor.depth, this.primaryTypeSymbolSource(anchor.element));
|
|
@@ -1257,6 +1340,8 @@ export class TypeInferrer {
|
|
|
1257
1340
|
* returns the repo's own type (`toInstance(View, plain)`) is the payload.
|
|
1258
1341
|
*/
|
|
1259
1342
|
callResultIsPayload(call) {
|
|
1343
|
+
if (this.callResultIsPlainData(call))
|
|
1344
|
+
return true;
|
|
1260
1345
|
const { element } = this.unwrapArrayLevels(this.unwrapPromiseType(call.getType()));
|
|
1261
1346
|
if (this.symbolIsLibOrExternalOrigin(element.getSymbol() ?? element.getAliasSymbol())) {
|
|
1262
1347
|
return false;
|
|
@@ -1266,6 +1351,58 @@ export class TypeInferrer {
|
|
|
1266
1351
|
// the call that built it would publish what it was built from.
|
|
1267
1352
|
return this.nodeCarriesPayloadContract(call, false, false);
|
|
1268
1353
|
}
|
|
1354
|
+
/**
|
|
1355
|
+
* True when a located call evaluates to plain data, so it is the payload
|
|
1356
|
+
* whatever its arguments and wherever its type is declared (carrick#2055).
|
|
1357
|
+
*
|
|
1358
|
+
* `rows.map(toDto)`, `rows.filter(cb)` and `listItems(ownerId)` produce the
|
|
1359
|
+
* list a route sends. Read as a send, the first produced the mapper's
|
|
1360
|
+
* function type, the second a callback's return, and the third `string`.
|
|
1361
|
+
* What a send or a registration hands back is never plain data: a reply
|
|
1362
|
+
* builder or a router carries methods, a bare send returns `void`, and a
|
|
1363
|
+
* serialiser returns a primitive. So the result alone tells them apart.
|
|
1364
|
+
*
|
|
1365
|
+
* Plain data, once promise levels are taken off: an object with at least
|
|
1366
|
+
* one member, no call or construct signature, and no member that is itself
|
|
1367
|
+
* callable; an array of one; or a union whose members other than `null` and
|
|
1368
|
+
* `undefined` all are. Tuples and empty objects are left to the callers'
|
|
1369
|
+
* own reading.
|
|
1370
|
+
*/
|
|
1371
|
+
callResultIsPlainData(call) {
|
|
1372
|
+
const isPlainData = (type, depth) => {
|
|
1373
|
+
if (depth > 3)
|
|
1374
|
+
return false;
|
|
1375
|
+
if (type.isAny() || type.isUnknown() || type.isNever())
|
|
1376
|
+
return false;
|
|
1377
|
+
if (type.isUnion()) {
|
|
1378
|
+
const members = type
|
|
1379
|
+
.getUnionTypes()
|
|
1380
|
+
.filter((member) => !member.isNull() && !member.isUndefined());
|
|
1381
|
+
return members.length > 0 && members.every((member) => isPlainData(member, depth + 1));
|
|
1382
|
+
}
|
|
1383
|
+
if (type.isIntersection()) {
|
|
1384
|
+
return type.getIntersectionTypes().every((member) => isPlainData(member, depth + 1));
|
|
1385
|
+
}
|
|
1386
|
+
const element = type.getArrayElementType();
|
|
1387
|
+
if (element)
|
|
1388
|
+
return isPlainData(element, depth + 1);
|
|
1389
|
+
if (!type.isObject() || type.isTuple() || this.isCallableType(type))
|
|
1390
|
+
return false;
|
|
1391
|
+
if (this.typeIsOrContainsResponseMachinery(type))
|
|
1392
|
+
return false;
|
|
1393
|
+
const members = type.getProperties();
|
|
1394
|
+
const indexed = type.getStringIndexType() ?? type.getNumberIndexType();
|
|
1395
|
+
if (members.length === 0 && !indexed)
|
|
1396
|
+
return false;
|
|
1397
|
+
const callable = (held) => held.isUnion()
|
|
1398
|
+
? held.getUnionTypes().some((member) => this.isCallableType(member))
|
|
1399
|
+
: this.isCallableType(held);
|
|
1400
|
+
if (indexed && callable(indexed))
|
|
1401
|
+
return false;
|
|
1402
|
+
return members.every((member) => !callable(member.getTypeAtLocation(call)));
|
|
1403
|
+
};
|
|
1404
|
+
return isPlainData(this.unwrapPromiseType(call.getType()), 0);
|
|
1405
|
+
}
|
|
1269
1406
|
/**
|
|
1270
1407
|
* The wire representation a request's printed type takes. A route response
|
|
1271
1408
|
* is serialised as JSON by every sender this layer reads a payload out of,
|
|
@@ -1320,7 +1457,8 @@ export class TypeInferrer {
|
|
|
1320
1457
|
*
|
|
1321
1458
|
* Returns `undefined` when this does not apply, which leaves the caller's
|
|
1322
1459
|
* own reading of the located expression untouched:
|
|
1323
|
-
* - the row's line is
|
|
1460
|
+
* - the row's line is neither a route registration nor a line inside the
|
|
1461
|
+
* handler the located send sits in;
|
|
1324
1462
|
* - the located expression is neither a send nor the argument of one;
|
|
1325
1463
|
* - that send is not one the handler returns;
|
|
1326
1464
|
* - it is a success send and the only one that survives, so the join would
|
|
@@ -1333,11 +1471,18 @@ export class TypeInferrer {
|
|
|
1333
1471
|
* or the redirect location back in.
|
|
1334
1472
|
*/
|
|
1335
1473
|
inferFromResponseSites(sourceFile, request, located, extractionConfig) {
|
|
1336
|
-
if (!this.registrationAtLine(sourceFile, request.line_number))
|
|
1337
|
-
return undefined;
|
|
1338
1474
|
const func = this.findContainingFunctionForNode(located);
|
|
1339
1475
|
if (!func)
|
|
1340
1476
|
return undefined;
|
|
1477
|
+
// The row's line is a registration, or a line inside the handler the
|
|
1478
|
+
// located send sits in (carrick#2005: a file-based route's row names the
|
|
1479
|
+
// send's own line, and a send's line is no registration). A row whose
|
|
1480
|
+
// line is outside that function says nothing about it.
|
|
1481
|
+
if (!this.registrationAtLine(sourceFile, request.line_number) &&
|
|
1482
|
+
!(func.getStartLineNumber() <= request.line_number &&
|
|
1483
|
+
request.line_number <= func.getEndLineNumber())) {
|
|
1484
|
+
return undefined;
|
|
1485
|
+
}
|
|
1341
1486
|
const branches = this.responseReturnedExpressions(func).flatMap((expression) => this.expandResponseBranches(expression, 0));
|
|
1342
1487
|
if (branches.length === 0)
|
|
1343
1488
|
return undefined;
|
|
@@ -1352,10 +1497,10 @@ export class TypeInferrer {
|
|
|
1352
1497
|
if (!locatedBranch)
|
|
1353
1498
|
return undefined;
|
|
1354
1499
|
const dropped = (status) => status === 'error' || status === 'redirect';
|
|
1355
|
-
const siteStatus = this.responseSiteStatus(site);
|
|
1500
|
+
const siteStatus = this.responseSiteStatus(site, true);
|
|
1356
1501
|
const survivors = [];
|
|
1357
1502
|
for (const branch of branches) {
|
|
1358
|
-
const status = branch === locatedBranch ? siteStatus : this.responseSiteStatus(branch);
|
|
1503
|
+
const status = branch === locatedBranch ? siteStatus : this.responseSiteStatus(branch, true);
|
|
1359
1504
|
if (status === 'variable') {
|
|
1360
1505
|
this.log(`Response status at ${request.file_path}:${this.getNodeLocation(branch).start_line} ` +
|
|
1361
1506
|
'is not a literal; keeping that send in the route contract');
|
|
@@ -1448,8 +1593,12 @@ export class TypeInferrer {
|
|
|
1448
1593
|
}
|
|
1449
1594
|
inferCallResult(sourceFile, request, extractionConfig) {
|
|
1450
1595
|
const callExpr = this.resolveTargetCallExpression(sourceFile, request);
|
|
1596
|
+
// A call's result is read off the call. A locator that finds no call names
|
|
1597
|
+
// nothing this can read, and whatever other node its text overlaps is not
|
|
1598
|
+
// the call's result (carrick#2056); the retype abstains on the same miss.
|
|
1451
1599
|
if (!callExpr) {
|
|
1452
|
-
|
|
1600
|
+
this.log(`Call-result locator at ${request.file_path}:${request.line_number} finds no call; leaving unresolved`);
|
|
1601
|
+
return null;
|
|
1453
1602
|
}
|
|
1454
1603
|
// Walk up from the already-found call expression instead of re-searching
|
|
1455
1604
|
const func = this.findContainingFunctionForNode(callExpr);
|
|
@@ -1860,18 +2009,25 @@ export class TypeInferrer {
|
|
|
1860
2009
|
parent.getInitializer() === current) {
|
|
1861
2010
|
break;
|
|
1862
2011
|
}
|
|
1863
|
-
if (!
|
|
1864
|
-
!Node.isAwaitExpression(parent) &&
|
|
1865
|
-
!Node.isNonNullExpression(parent) &&
|
|
1866
|
-
!Node.isAsExpression(parent) &&
|
|
1867
|
-
!Node.isTypeAssertion(parent) &&
|
|
1868
|
-
!Node.isSatisfiesExpression(parent)) {
|
|
2012
|
+
if (!this.wrapsReadTransparently(parent)) {
|
|
1869
2013
|
return undefined;
|
|
1870
2014
|
}
|
|
1871
2015
|
current = parent;
|
|
1872
2016
|
}
|
|
1873
2017
|
return typeNode;
|
|
1874
2018
|
}
|
|
2019
|
+
/**
|
|
2020
|
+
* A wrapper between a body read and the binding it initialises that leaves
|
|
2021
|
+
* the read's value as it is: `await`, parentheses, `!`, a cast, `satisfies`.
|
|
2022
|
+
*/
|
|
2023
|
+
wrapsReadTransparently(node) {
|
|
2024
|
+
return (Node.isParenthesizedExpression(node) ||
|
|
2025
|
+
Node.isAwaitExpression(node) ||
|
|
2026
|
+
Node.isNonNullExpression(node) ||
|
|
2027
|
+
Node.isAsExpression(node) ||
|
|
2028
|
+
Node.isTypeAssertion(node) ||
|
|
2029
|
+
Node.isSatisfiesExpression(node));
|
|
2030
|
+
}
|
|
1875
2031
|
/**
|
|
1876
2032
|
* A stated type with `any` or `unknown` written anywhere in it (`unknown`,
|
|
1877
2033
|
* `Record<string, unknown>`, `{ items: any[] }`) leaves a position open: it
|
|
@@ -2115,6 +2271,24 @@ export class TypeInferrer {
|
|
|
2115
2271
|
'to one body field by a keyed decorator; publishing the body those fields make up');
|
|
2116
2272
|
return this.declaredRequestInferredType(request, keyedBody, this.getNodeLocation(located));
|
|
2117
2273
|
}
|
|
2274
|
+
// carrick#2057: the locator named a member of the body the handler parsed
|
|
2275
|
+
// (`body.items` after `const body = (await request.json()) as ImportBody`,
|
|
2276
|
+
// or a binding destructured from that read). A member is one field of the
|
|
2277
|
+
// body, so publishing its type states the whole body is that field. The
|
|
2278
|
+
// body is what the source states at the read; where it states nothing,
|
|
2279
|
+
// nothing is published.
|
|
2280
|
+
const bodyRead = this.parsedBodyReadHolding(located);
|
|
2281
|
+
if (bodyRead) {
|
|
2282
|
+
const stated = this.requestStatedAtBodyRead(request, bodyRead);
|
|
2283
|
+
if (stated) {
|
|
2284
|
+
this.log(`Request locator at ${request.file_path}:${request.line_number} names a member of the ` +
|
|
2285
|
+
'body its handler parses; publishing the body the source states at that read');
|
|
2286
|
+
return stated;
|
|
2287
|
+
}
|
|
2288
|
+
this.log(`Request locator at ${request.file_path}:${request.line_number} names a member of the ` +
|
|
2289
|
+
'body its handler parses, and the source states no type at that read; leaving unresolved');
|
|
2290
|
+
return null;
|
|
2291
|
+
}
|
|
2118
2292
|
// Mirror inferCallResult: strip `await`/`as`/parens/`!` so the inner
|
|
2119
2293
|
// expression's type (not the surrounding `Promise<any>`) is read.
|
|
2120
2294
|
let unwrapped = this.unwrapExpressionNode(located);
|
|
@@ -2147,8 +2321,8 @@ export class TypeInferrer {
|
|
|
2147
2321
|
path: '',
|
|
2148
2322
|
kind: 'unknown',
|
|
2149
2323
|
reason: 'no_request_body',
|
|
2150
|
-
detail: `the
|
|
2151
|
-
'
|
|
2324
|
+
detail: `the call's request config sets no '${inCall.member}' member, which is where the call ` +
|
|
2325
|
+
'takes its body, so the call sends no request body',
|
|
2152
2326
|
},
|
|
2153
2327
|
];
|
|
2154
2328
|
return abstain;
|
|
@@ -4436,7 +4610,7 @@ export class TypeInferrer {
|
|
|
4436
4610
|
* A status in the FIRST argument is a field of the body and is never read.
|
|
4437
4611
|
* No method or framework name is consulted anywhere.
|
|
4438
4612
|
*/
|
|
4439
|
-
responseSiteStatus(expression) {
|
|
4613
|
+
responseSiteStatus(expression, readChain = false) {
|
|
4440
4614
|
const call = this.peelTransparentExpression(expression);
|
|
4441
4615
|
if (!Node.isCallExpression(call) && !Node.isNewExpression(call)) {
|
|
4442
4616
|
return 'undecided';
|
|
@@ -4459,6 +4633,14 @@ export class TypeInferrer {
|
|
|
4459
4633
|
}
|
|
4460
4634
|
if (variable)
|
|
4461
4635
|
return 'variable';
|
|
4636
|
+
// carrick#2005: a status an earlier call of the same chain states
|
|
4637
|
+
// (`reply.status(401).json(body)`). Read only where the caller asks for
|
|
4638
|
+
// it; the return walk does not read it yet (carrick#1962).
|
|
4639
|
+
if (readChain) {
|
|
4640
|
+
const chained = this.chainStatus(call);
|
|
4641
|
+
if (chained)
|
|
4642
|
+
return chained;
|
|
4643
|
+
}
|
|
4462
4644
|
const typed = this.sendResultStatusCodes(call);
|
|
4463
4645
|
if (typed) {
|
|
4464
4646
|
const kind = classifyStatusCodes(typed);
|
|
@@ -4467,6 +4649,37 @@ export class TypeInferrer {
|
|
|
4467
4649
|
}
|
|
4468
4650
|
return 'undecided';
|
|
4469
4651
|
}
|
|
4652
|
+
/**
|
|
4653
|
+
* The status an earlier call of a send's own chain states: the receiver
|
|
4654
|
+
* calls `call` is invoked on (`a.status(401).json(body)` has `a.status(401)`),
|
|
4655
|
+
* innermost last. Each receiver call's first argument is read by the rule
|
|
4656
|
+
* an argument past a body is, so a numeric literal or a literal-typed value
|
|
4657
|
+
* counts and a plain `number` is `variable`. `undefined` when no receiver
|
|
4658
|
+
* call states a status. No method name is consulted: a receiver call that
|
|
4659
|
+
* is handed no status-shaped value says nothing.
|
|
4660
|
+
*/
|
|
4661
|
+
chainStatus(call) {
|
|
4662
|
+
if (!Node.isCallExpression(call))
|
|
4663
|
+
return undefined;
|
|
4664
|
+
let callee = call.getExpression();
|
|
4665
|
+
while (Node.isPropertyAccessExpression(callee)) {
|
|
4666
|
+
const receiver = this.peelTransparentExpression(callee.getExpression());
|
|
4667
|
+
if (!Node.isCallExpression(receiver))
|
|
4668
|
+
return undefined;
|
|
4669
|
+
const first = receiver.getArguments()[0];
|
|
4670
|
+
if (first) {
|
|
4671
|
+
const read = this.statedStatusCodes(this.peelTransparentExpression(first));
|
|
4672
|
+
if (read === 'variable')
|
|
4673
|
+
return 'variable';
|
|
4674
|
+
if (read) {
|
|
4675
|
+
const kind = classifyStatusCodes(read);
|
|
4676
|
+
return kind === 'mixed' ? 'variable' : kind;
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
callee = receiver.getExpression();
|
|
4680
|
+
}
|
|
4681
|
+
return undefined;
|
|
4682
|
+
}
|
|
4470
4683
|
/**
|
|
4471
4684
|
* The HTTP status codes an argument states, `'variable'` when it is a
|
|
4472
4685
|
* status-shaped value the source does not fix, or `undefined` when it says
|
|
@@ -4574,6 +4787,25 @@ export class TypeInferrer {
|
|
|
4574
4787
|
}
|
|
4575
4788
|
return [expression];
|
|
4576
4789
|
}
|
|
4790
|
+
/**
|
|
4791
|
+
* The symbol an anchor is named from: the type's own symbol, unless that
|
|
4792
|
+
* symbol's name is synthetic (`__type`, `__object`, ...), in which case the
|
|
4793
|
+
* alias symbol (carrick#2148).
|
|
4794
|
+
*
|
|
4795
|
+
* `type Widget = { id: string }` and an instantiated `Page<Order>` carry
|
|
4796
|
+
* the anonymous `__type` as their own symbol and the alias as their alias
|
|
4797
|
+
* symbol. Reading the own symbol first and stopping because it is truthy
|
|
4798
|
+
* left every object-literal alias without an anchor, while the same shape
|
|
4799
|
+
* declared as an interface had one. The own symbol still wins whenever it
|
|
4800
|
+
* has a real name, so every type anchored before keeps its anchor.
|
|
4801
|
+
*/
|
|
4802
|
+
anchorSymbolOf(type) {
|
|
4803
|
+
const own = type.getSymbol();
|
|
4804
|
+
if (own && !own.getName().startsWith('__')) {
|
|
4805
|
+
return own;
|
|
4806
|
+
}
|
|
4807
|
+
return type.getAliasSymbol() ?? own;
|
|
4808
|
+
}
|
|
4577
4809
|
/**
|
|
4578
4810
|
* The deterministic source symbol of a resolved type (`Payment` for a payload
|
|
4579
4811
|
* typed `Payment`), or `undefined` when there is no single user-defined
|
|
@@ -4584,7 +4816,7 @@ export class TypeInferrer {
|
|
|
4584
4816
|
* `primary_type_symbol` so the manifest anchor no longer depends on the LLM.
|
|
4585
4817
|
*/
|
|
4586
4818
|
primaryTypeSymbol(type) {
|
|
4587
|
-
const name =
|
|
4819
|
+
const name = this.anchorSymbolOf(type)?.getName();
|
|
4588
4820
|
// Reject the compiler's synthetic names for anonymous shapes (`__type`,
|
|
4589
4821
|
// `__object`, `__function`, …). They are not user-facing type names, so
|
|
4590
4822
|
// they would be a meaningless — and non-resolvable — anchor.
|
|
@@ -4604,7 +4836,8 @@ export class TypeInferrer {
|
|
|
4604
4836
|
* declarations IN its `source_file`, so the request must point at the file
|
|
4605
4837
|
* that actually declares the tsc-witnessed payload type.
|
|
4606
4838
|
*
|
|
4607
|
-
* The declarations read are those of the
|
|
4839
|
+
* The declarations read are those of the symbol the anchor is named from
|
|
4840
|
+
* (`anchorSymbolOf`: the type's own symbol, or its alias's), so a name
|
|
4608
4841
|
* imported through a barrel reports the file that declares it, and a name
|
|
4609
4842
|
* two files declare reports the one this type resolves to. A declaration in
|
|
4610
4843
|
* an installed package or a TypeScript lib is reported as it is found, as
|
|
@@ -4623,7 +4856,7 @@ export class TypeInferrer {
|
|
|
4623
4856
|
if (this.primaryTypeSymbol(type) === undefined) {
|
|
4624
4857
|
return undefined;
|
|
4625
4858
|
}
|
|
4626
|
-
const decls =
|
|
4859
|
+
const decls = this.anchorSymbolOf(type)?.getDeclarations() ?? [];
|
|
4627
4860
|
const declaring = decls.find((d) => Node.isInterfaceDeclaration(d) ||
|
|
4628
4861
|
Node.isTypeAliasDeclaration(d) ||
|
|
4629
4862
|
Node.isClassDeclaration(d) ||
|
|
@@ -5281,6 +5514,56 @@ export class TypeInferrer {
|
|
|
5281
5514
|
}
|
|
5282
5515
|
return undefined;
|
|
5283
5516
|
}
|
|
5517
|
+
/**
|
|
5518
|
+
* The platform body read whose result `located` is a MEMBER of, or
|
|
5519
|
+
* `undefined` (carrick#2057).
|
|
5520
|
+
*
|
|
5521
|
+
* A member is a property or element access off a binding, or a name
|
|
5522
|
+
* destructured out of one. The binding must be initialised by the body read
|
|
5523
|
+
* `platformBodyReadIn` finds in the function that declares it, with only
|
|
5524
|
+
* transparent wrappers between the two (`await`, parentheses, `!`, a cast,
|
|
5525
|
+
* `satisfies`). The binding itself is not a member, and neither is a member
|
|
5526
|
+
* of anything else (a query object, a parameter, an outbound response).
|
|
5527
|
+
*/
|
|
5528
|
+
parsedBodyReadHolding(located) {
|
|
5529
|
+
let node = this.unwrapExpressionNode(located);
|
|
5530
|
+
let member = false;
|
|
5531
|
+
while (Node.isPropertyAccessExpression(node) ||
|
|
5532
|
+
Node.isElementAccessExpression(node) ||
|
|
5533
|
+
Node.isNonNullExpression(node) ||
|
|
5534
|
+
Node.isParenthesizedExpression(node)) {
|
|
5535
|
+
if (Node.isPropertyAccessExpression(node) || Node.isElementAccessExpression(node)) {
|
|
5536
|
+
member = true;
|
|
5537
|
+
}
|
|
5538
|
+
node = node.getExpression();
|
|
5539
|
+
}
|
|
5540
|
+
if (!Node.isIdentifier(node))
|
|
5541
|
+
return undefined;
|
|
5542
|
+
let declaration = node.getSymbol()?.getDeclarations()?.[0];
|
|
5543
|
+
while (declaration && Node.isBindingElement(declaration)) {
|
|
5544
|
+
member = true;
|
|
5545
|
+
declaration = declaration.getParent()?.getParent();
|
|
5546
|
+
}
|
|
5547
|
+
if (!member || !declaration || !Node.isVariableDeclaration(declaration))
|
|
5548
|
+
return undefined;
|
|
5549
|
+
const handler = this.findContainingFunctionForNode(declaration);
|
|
5550
|
+
const read = handler ? this.platformBodyReadIn(handler) : undefined;
|
|
5551
|
+
if (!read)
|
|
5552
|
+
return undefined;
|
|
5553
|
+
let current = read;
|
|
5554
|
+
for (;;) {
|
|
5555
|
+
const parent = current.getParent();
|
|
5556
|
+
if (!parent)
|
|
5557
|
+
return undefined;
|
|
5558
|
+
if (parent === declaration) {
|
|
5559
|
+
return declaration.getInitializer() === current ? read : undefined;
|
|
5560
|
+
}
|
|
5561
|
+
if (!this.wrapsReadTransparently(parent)) {
|
|
5562
|
+
return undefined;
|
|
5563
|
+
}
|
|
5564
|
+
current = parent;
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5284
5567
|
/**
|
|
5285
5568
|
* The request contract the source states AT a body read, or null when it
|
|
5286
5569
|
* states none there (carrick#807).
|
|
@@ -5565,14 +5848,11 @@ export class TypeInferrer {
|
|
|
5565
5848
|
requestBodyInCall(node) {
|
|
5566
5849
|
if (Node.isCallExpression(node)) {
|
|
5567
5850
|
const args = node.getArguments();
|
|
5851
|
+
if (args.length === 1)
|
|
5852
|
+
return this.requestObjectBody(args[0]);
|
|
5568
5853
|
if (args.length < 2)
|
|
5569
5854
|
return { kind: 'none' };
|
|
5570
|
-
|
|
5571
|
-
const stringLike = first.isString() ||
|
|
5572
|
-
first.isStringLiteral() ||
|
|
5573
|
-
first.isTemplateLiteral() ||
|
|
5574
|
-
(first.isUnion() && first.getUnionTypes().every((t) => t.isString() || t.isStringLiteral()));
|
|
5575
|
-
if (!stringLike)
|
|
5855
|
+
if (!this.isStringLikeType(args[0].getType()))
|
|
5576
5856
|
return { kind: 'none' };
|
|
5577
5857
|
const slots = this.requestBodySlots(node);
|
|
5578
5858
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -5608,6 +5888,80 @@ export class TypeInferrer {
|
|
|
5608
5888
|
}
|
|
5609
5889
|
return { kind: 'none' };
|
|
5610
5890
|
}
|
|
5891
|
+
/** A string, a string literal, a template literal, or a union of strings. */
|
|
5892
|
+
isStringLikeType(type) {
|
|
5893
|
+
return (type.isString() ||
|
|
5894
|
+
type.isStringLiteral() ||
|
|
5895
|
+
type.isTemplateLiteral() ||
|
|
5896
|
+
(type.isUnion() && type.getUnionTypes().every((t) => t.isString() || t.isStringLiteral())));
|
|
5897
|
+
}
|
|
5898
|
+
/**
|
|
5899
|
+
* carrick#1841, request half: a request call that states its URL on its one
|
|
5900
|
+
* object argument (`client.post({ url: '/items', ...options })`, the way a
|
|
5901
|
+
* generated client issues every operation). The method's own parameter
|
|
5902
|
+
* types the body `unknown`, so the object is the only statement of it: its
|
|
5903
|
+
* `body` member, the member `RequestInit` defines, written on the object or
|
|
5904
|
+
* carried in by a spread whose type says what it is.
|
|
5905
|
+
*
|
|
5906
|
+
* - A `body` written after the object's last spread is the body.
|
|
5907
|
+
* - Otherwise the object's own type has the answer. No `body` member, or
|
|
5908
|
+
* one typed `undefined` or `never`, means the call sends no body.
|
|
5909
|
+
* - A member typed `any`, `unknown` or a type parameter, or one only the
|
|
5910
|
+
* compiler's default library declares (a spread `RequestInit` carries the
|
|
5911
|
+
* serialised `BodyInit`, not a payload), states no payload: abstain. So
|
|
5912
|
+
* does an object that carries another body-named member and no `body`.
|
|
5913
|
+
*
|
|
5914
|
+
* An object with no string-typed `url` member is not a request object.
|
|
5915
|
+
*/
|
|
5916
|
+
requestObjectBody(arg) {
|
|
5917
|
+
const literal = this.unwrapExpressionNode(arg);
|
|
5918
|
+
if (!Node.isObjectLiteralExpression(literal))
|
|
5919
|
+
return { kind: 'none' };
|
|
5920
|
+
const objectType = literal.getType();
|
|
5921
|
+
const url = objectType.getProperty('url');
|
|
5922
|
+
if (!url || !this.isStringLikeType(url.getTypeAtLocation(literal).getNonNullableType())) {
|
|
5923
|
+
return { kind: 'none' };
|
|
5924
|
+
}
|
|
5925
|
+
const from = "the request call's request object";
|
|
5926
|
+
const member = 'body';
|
|
5927
|
+
const properties = literal.getProperties();
|
|
5928
|
+
const written = properties.findIndex((p) => (Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p)) &&
|
|
5929
|
+
p.getName() === member);
|
|
5930
|
+
const spreadAfter = properties.some((p, i) => i > written && Node.isSpreadAssignment(p));
|
|
5931
|
+
if (written >= 0 && !spreadAfter) {
|
|
5932
|
+
const value = this.objectLiteralMemberValue(literal, member);
|
|
5933
|
+
if (value)
|
|
5934
|
+
return { kind: 'body', node: value, from };
|
|
5935
|
+
}
|
|
5936
|
+
const property = objectType.getProperty(member);
|
|
5937
|
+
if (!property) {
|
|
5938
|
+
const otherBodyMember = [...BODY_MEMBER_NAMES].some((name) => name !== member && objectType.getProperty(name));
|
|
5939
|
+
return otherBodyMember
|
|
5940
|
+
? { kind: 'abstain', why: `the request object carries no '${member}' member but another body-named one` }
|
|
5941
|
+
: { kind: 'no_body', member };
|
|
5942
|
+
}
|
|
5943
|
+
const type = property.getTypeAtLocation(literal);
|
|
5944
|
+
if (type.isAny() || type.isUnknown() || type.isTypeParameter()) {
|
|
5945
|
+
return { kind: 'abstain', why: `the request object's '${member}' member is not typed` };
|
|
5946
|
+
}
|
|
5947
|
+
const bare = type.getNonNullableType();
|
|
5948
|
+
if (bare.isNever() || bare.isUndefined() || bare.isVoid()) {
|
|
5949
|
+
return { kind: 'no_body', member };
|
|
5950
|
+
}
|
|
5951
|
+
if (bare.isAny() || bare.isUnknown() || bare.isTypeParameter()) {
|
|
5952
|
+
return { kind: 'abstain', why: `the request object's '${member}' member is not typed` };
|
|
5953
|
+
}
|
|
5954
|
+
const program = this.project.getProgram().compilerObject;
|
|
5955
|
+
const declarations = property.getDeclarations();
|
|
5956
|
+
if (declarations.length > 0 &&
|
|
5957
|
+
declarations.every((d) => program.isSourceFileDefaultLibrary(d.getSourceFile().compilerNode))) {
|
|
5958
|
+
return {
|
|
5959
|
+
kind: 'abstain',
|
|
5960
|
+
why: `the request object's '${member}' member is the platform's serialised body, not a payload`,
|
|
5961
|
+
};
|
|
5962
|
+
}
|
|
5963
|
+
return { kind: 'body_type', type: bare, at: literal, from };
|
|
5964
|
+
}
|
|
5611
5965
|
/**
|
|
5612
5966
|
* The body a config-slot argument supplies through `member`: an object
|
|
5613
5967
|
* literal's own member, or a variable's member read off its type.
|
|
@@ -6607,21 +6961,119 @@ export class TypeInferrer {
|
|
|
6607
6961
|
// locator whose exact match failed would bind to the smallest embedded
|
|
6608
6962
|
// sub-expression (pickBestMatch prefers small nodes), e.g. the 8-char
|
|
6609
6963
|
// literal "active" inside a res.json object payload (#335).
|
|
6964
|
+
//
|
|
6965
|
+
// A node the text itself places strictly inside what it names is a
|
|
6966
|
+
// fragment, however much of the text it covers (carrick#2056): the
|
|
6967
|
+
// `new URLSearchParams(…)` inside a call's URL argument, or the object a
|
|
6968
|
+
// callback nested in the named object returns. Only the node the text
|
|
6969
|
+
// names, or a wrapper the text adds around it, may be bound this way. A
|
|
6970
|
+
// fragment still points at what the text names where the source agrees
|
|
6971
|
+
// with the text up to it: the enclosing node of the named kind whose text
|
|
6972
|
+
// opens with the text's own opening up to the fragment (`new Response(`
|
|
6973
|
+
// before a payload, when the text dropped the call's second argument).
|
|
6610
6974
|
const MIN_REVERSE_MATCH_LEN = 8;
|
|
6611
6975
|
const MIN_REVERSE_MATCH_COVERAGE = 0.5;
|
|
6612
|
-
const
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6976
|
+
const containingMatches = candidates.filter((c) => c.text.includes(normalizedTarget));
|
|
6977
|
+
if (containingMatches.length > 0) {
|
|
6978
|
+
return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
|
|
6979
|
+
}
|
|
6980
|
+
const reverseMatches = candidates.filter((c) => c.text.length >= MIN_REVERSE_MATCH_LEN &&
|
|
6981
|
+
c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
|
|
6982
|
+
normalizedTarget.includes(c.text));
|
|
6983
|
+
if (reverseMatches.length === 0)
|
|
6984
|
+
return undefined;
|
|
6985
|
+
const named = this.locatorNames(normalizedTarget);
|
|
6986
|
+
if (!named) {
|
|
6987
|
+
return this.pickBestMatch(reverseMatches.map((c) => c.node), lineNumber);
|
|
6988
|
+
}
|
|
6989
|
+
const textOf = new Map(candidates.map((c) => [c.node, c.text]));
|
|
6990
|
+
const bound = new Set();
|
|
6991
|
+
for (const c of reverseMatches) {
|
|
6992
|
+
if (named.texts.has(c.text)) {
|
|
6993
|
+
bound.add(c.node);
|
|
6994
|
+
continue;
|
|
6995
|
+
}
|
|
6996
|
+
const at = normalizedTarget.indexOf(c.text, named.rootStart + 1);
|
|
6997
|
+
if (at < 0)
|
|
6998
|
+
continue;
|
|
6999
|
+
const opening = normalizedTarget.slice(named.rootStart, at);
|
|
7000
|
+
const enclosing = c.node.getFirstAncestor((ancestor) => ancestor.getKind() === named.rootKind &&
|
|
7001
|
+
textOf.get(ancestor)?.startsWith(opening) === true);
|
|
7002
|
+
if (enclosing)
|
|
7003
|
+
bound.add(enclosing);
|
|
6623
7004
|
}
|
|
6624
|
-
return undefined;
|
|
7005
|
+
return bound.size > 0 ? this.pickBestMatch([...bound], lineNumber) : undefined;
|
|
7006
|
+
}
|
|
7007
|
+
/**
|
|
7008
|
+
* What a locator's text names, read off its own parse (carrick#2056):
|
|
7009
|
+
*
|
|
7010
|
+
* - `texts`: the normalized text of the expression it names and of each
|
|
7011
|
+
* wrapper it carries around it (`return`, a `const x =` binding, a label,
|
|
7012
|
+
* `await`, parentheses, a cast, `satisfies`, `!`). A node strictly inside
|
|
7013
|
+
* that expression is not among them;
|
|
7014
|
+
* - `rootKind` and `rootStart`: the innermost of those, its kind, and where
|
|
7015
|
+
* its text starts in the locator.
|
|
7016
|
+
*
|
|
7017
|
+
* `null` when the text does not parse as one expression or one statement:
|
|
7018
|
+
* there is no structure to read, and the caller keeps its text rule.
|
|
7019
|
+
*/
|
|
7020
|
+
locatorNames(normalizedTarget) {
|
|
7021
|
+
const parsed = (source) => {
|
|
7022
|
+
const file = ts.createSourceFile('locator.ts', source, ts.ScriptTarget.Latest, true);
|
|
7023
|
+
const diagnostics = file
|
|
7024
|
+
.parseDiagnostics;
|
|
7025
|
+
return diagnostics && diagnostics.length === 0 && file.statements.length === 1 ? file : null;
|
|
7026
|
+
};
|
|
7027
|
+
// An object literal is a block as a statement, so try the text as an
|
|
7028
|
+
// expression first.
|
|
7029
|
+
const EXPRESSION_OPENING = '(\n';
|
|
7030
|
+
const asExpression = parsed(`${EXPRESSION_OPENING}${normalizedTarget}\n);`);
|
|
7031
|
+
let node;
|
|
7032
|
+
let file = asExpression;
|
|
7033
|
+
let offset = 0;
|
|
7034
|
+
if (asExpression) {
|
|
7035
|
+
const statement = asExpression.statements[0];
|
|
7036
|
+
if (ts.isExpressionStatement(statement) && ts.isParenthesizedExpression(statement.expression)) {
|
|
7037
|
+
node = statement.expression.expression;
|
|
7038
|
+
offset = EXPRESSION_OPENING.length;
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
else {
|
|
7042
|
+
file = parsed(normalizedTarget);
|
|
7043
|
+
node = file?.statements[0];
|
|
7044
|
+
}
|
|
7045
|
+
if (!file || !node)
|
|
7046
|
+
return null;
|
|
7047
|
+
const texts = new Set();
|
|
7048
|
+
let root;
|
|
7049
|
+
while (node) {
|
|
7050
|
+
if (ts.isLabeledStatement(node)) {
|
|
7051
|
+
node = node.statement;
|
|
7052
|
+
}
|
|
7053
|
+
else if (ts.isExpressionStatement(node) || ts.isReturnStatement(node)) {
|
|
7054
|
+
node = node.expression;
|
|
7055
|
+
}
|
|
7056
|
+
else if (ts.isVariableStatement(node)) {
|
|
7057
|
+
const declarations = node.declarationList.declarations;
|
|
7058
|
+
node = declarations.length === 1 ? declarations[0].initializer : undefined;
|
|
7059
|
+
}
|
|
7060
|
+
else {
|
|
7061
|
+
texts.add(this.normalizeWhitespace(node.getText(file)));
|
|
7062
|
+
root = node;
|
|
7063
|
+
node =
|
|
7064
|
+
ts.isParenthesizedExpression(node) ||
|
|
7065
|
+
ts.isAwaitExpression(node) ||
|
|
7066
|
+
ts.isAsExpression(node) ||
|
|
7067
|
+
ts.isSatisfiesExpression(node) ||
|
|
7068
|
+
ts.isNonNullExpression(node) ||
|
|
7069
|
+
ts.isTypeAssertionExpression(node)
|
|
7070
|
+
? node.expression
|
|
7071
|
+
: undefined;
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
if (!root)
|
|
7075
|
+
return null;
|
|
7076
|
+
return { texts, rootKind: root.kind, rootStart: root.getStart(file) - offset };
|
|
6625
7077
|
}
|
|
6626
7078
|
/**
|
|
6627
7079
|
* Pick the best match from a set of candidate nodes:
|
|
@@ -6662,7 +7114,7 @@ export class TypeInferrer {
|
|
|
6662
7114
|
* for the same reason.
|
|
6663
7115
|
*/
|
|
6664
7116
|
normalizeWhitespace(text) {
|
|
6665
|
-
|
|
7117
|
+
const collapsed = text
|
|
6666
7118
|
.replace(/\s+/g, ' ')
|
|
6667
7119
|
// A member chain broken before its dot (`client\n .list(…)`) reads the
|
|
6668
7120
|
// same as `client.list(…)`; a space around `.` / `?.` means nothing
|
|
@@ -6671,6 +7123,8 @@ export class TypeInferrer {
|
|
|
6671
7123
|
.replace(/\s*,?\s*([}\)\]])/g, '$1')
|
|
6672
7124
|
.replace(/([\(\[{])\s+/g, '$1')
|
|
6673
7125
|
.trim();
|
|
7126
|
+
// A string literal reads the same in any quotes (carrick#2056).
|
|
7127
|
+
return canonicalStringQuotes(collapsed);
|
|
6674
7128
|
}
|
|
6675
7129
|
/**
|
|
6676
7130
|
* Format a human-readable location string for error messages.
|
|
@@ -6877,11 +7331,11 @@ export class TypeInferrer {
|
|
|
6877
7331
|
infer_kind: request.infer_kind,
|
|
6878
7332
|
payload_type_string: payloadTypeString,
|
|
6879
7333
|
primary_type_symbol: primaryTypeSymbol,
|
|
6880
|
-
//
|
|
6881
|
-
//
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
7334
|
+
// Reported whether or not the element has a symbol (carrick#1967): an
|
|
7335
|
+
// array whose element prints structurally is still a list. The Rust
|
|
7336
|
+
// depth join copies it onto the model's symbol when the symbols agree,
|
|
7337
|
+
// or, at a handler's send, when the element has no symbol to disagree.
|
|
7338
|
+
array_depth: arrayDepth !== undefined && arrayDepth > 0 ? arrayDepth : undefined,
|
|
6885
7339
|
// Same gate: a declaration source without an anchor symbol is useless.
|
|
6886
7340
|
primary_type_symbol_source: primaryTypeSymbol !== undefined ? primaryTypeSymbolSource : undefined,
|
|
6887
7341
|
};
|