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.
@@ -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
- const registersCallback = args.some((arg) => Node.isArrowFunction(arg) || Node.isFunctionExpression(arg));
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
- typeString = this.expandResolvedTypeStructural(resolved, typeString, this.wireFormatFor(request));
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 not a route registration;
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
- return this.inferExpression(sourceFile, request, extractionConfig);
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 (!Node.isParenthesizedExpression(parent) &&
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 located argument is the call's request config, and it sets no '${inCall.member}' ` +
2151
- 'member, which is where the call takes its body, so the call sends no request body',
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 = (type.getSymbol() || type.getAliasSymbol())?.getName();
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 type's own symbol, so a name
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 = (type.getSymbol() || type.getAliasSymbol())?.getDeclarations() ?? [];
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
- const first = args[0].getType();
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 substringMatches = candidates.filter((c) => c.text.includes(normalizedTarget) ||
6613
- (c.text.length >= MIN_REVERSE_MATCH_LEN &&
6614
- c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
6615
- normalizedTarget.includes(c.text)));
6616
- if (substringMatches.length > 0) {
6617
- // Prefer nodes where the LLM text is contained in the node text
6618
- const containingMatches = substringMatches.filter((c) => c.text.includes(normalizedTarget));
6619
- if (containingMatches.length > 0) {
6620
- return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
6621
- }
6622
- return this.pickBestMatch(substringMatches.map((c) => c.node), lineNumber);
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
- return text
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
- // Only meaningful relative to the anchor symbol: without an element
6881
- // symbol there is nothing for the explicit-bundle correction to match.
6882
- array_depth: primaryTypeSymbol !== undefined && arrayDepth !== undefined && arrayDepth > 0
6883
- ? arrayDepth
6884
- : undefined,
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
  };