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.
Files changed (41) hide show
  1. package/bin/carrick.mjs +3 -0
  2. package/dist/contract.d.ts +6 -0
  3. package/dist/contract.js +5 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/hook/post-edit.js +11 -2
  6. package/dist/hook/post-edit.js.map +1 -1
  7. package/dist/hook/post-read.d.ts +2 -0
  8. package/dist/hook/post-read.js +89 -0
  9. package/dist/hook/post-read.js.map +1 -0
  10. package/dist/hook/reuse.d.ts +24 -0
  11. package/dist/hook/reuse.js +58 -2
  12. package/dist/hook/reuse.js.map +1 -1
  13. package/dist/init/settings.js +6 -0
  14. package/dist/init/settings.js.map +1 -1
  15. package/dist/render.d.ts +22 -2
  16. package/dist/render.js +53 -2
  17. package/dist/render.js.map +1 -1
  18. package/dist/update.js +3 -0
  19. package/dist/update.js.map +1 -1
  20. package/package.json +62 -7
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/hooks/hooks.json +10 -0
  23. package/scripts/install-scanned-deps.sh +539 -0
  24. package/sidecar/dist/src/capture/api.d.ts +22 -1
  25. package/sidecar/dist/src/capture/check-probe.d.ts +8 -0
  26. package/sidecar/dist/src/capture/check-probe.js +9 -1
  27. package/sidecar/dist/src/capture/check.d.ts +32 -0
  28. package/sidecar/dist/src/capture/check.js +74 -5
  29. package/sidecar/dist/src/capture/checkout.d.ts +38 -0
  30. package/sidecar/dist/src/capture/checkout.js +139 -0
  31. package/sidecar/dist/src/capture/index.d.ts +1 -0
  32. package/sidecar/dist/src/capture/index.js +22 -7
  33. package/sidecar/dist/src/capture/machinery.js +1 -30
  34. package/sidecar/dist/src/capture/project-references.js +2 -1
  35. package/sidecar/dist/src/capture/self-check.d.ts +20 -1
  36. package/sidecar/dist/src/capture/self-check.js +30 -11
  37. package/sidecar/dist/src/origin.js +1 -29
  38. package/sidecar/dist/src/project-loader.d.ts +6 -0
  39. package/sidecar/dist/src/project-loader.js +64 -1
  40. package/sidecar/dist/src/type-inferrer.d.ts +235 -1
  41. 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
- const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(site));
1452
- abstain.any_provenance = [
1453
- {
1454
- path: '',
1455
- kind: 'unknown',
1456
- reason: 'no_success_payload',
1457
- detail: "every response this route's handler sends states an error or redirect status, " +
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
- return this.inferExpression(sourceFile, request, extractionConfig);
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
- const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(callExpr));
1686
- abstain.any_provenance = [
1687
- {
1688
- path: '',
1689
- kind: 'unknown',
1690
- reason: 'projected_value_only',
1691
- detail: "every read of this call's result takes a member out of it and none reads the " +
1692
- 'value itself, so what this site states is a part of a payload rather than the ' +
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 (!Node.isParenthesizedExpression(parent) &&
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
- const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(unwrapped));
2213
- abstain.any_provenance = [
2214
- {
2215
- path: '',
2216
- kind: 'unknown',
2217
- reason: 'no_request_body',
2218
- detail: `the call's request config sets no '${inCall.member}' member, which is where the call ` +
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
- const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(node));
2278
- abstain.any_provenance = [
2279
- {
2280
- path: '',
2281
- kind: 'unknown',
2282
- reason: 'no_request_body',
2283
- detail: `the located read is the '${nonBodyPart}' part a validator binds, not a request body, ` +
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
- const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(callExpr));
2780
- abstain.any_provenance = [
2781
- {
2782
- path: '',
2783
- kind: 'unknown',
2784
- reason: 'machinery_envelope',
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
- // A `satisfies X` / `as X` on the argument states the contract in source:
4113
- // read the ANNOTATION, not the literal the compiler widened it from.
4114
- // `expandAnnotationTypeNode` keeps the bare name when the declaration is
4115
- // not resolvable, which is the honest answer on a bare checkout.
4116
- const stated = this.statedTypeNodeOf(payloadNode);
4117
- if (stated) {
4118
- candidates.push({
4119
- typeString: this.expandAnnotationTypeNode(stated, wire),
4120
- isExplicit: true,
4121
- node: payloadNode,
4122
- anchorType: this.unwrapPromiseType(stated.getType()),
4123
- statedTypeNode: stated,
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 first = receiver.getArguments()[0];
4564
- if (first) {
4565
- const read = this.statedStatusCodes(this.peelTransparentExpression(first));
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 = (type.getSymbol() || type.getAliasSymbol())?.getName();
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 type's own symbol, so a name
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 = (type.getSymbol() || type.getAliasSymbol())?.getDeclarations() ?? [];
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 substringMatches = candidates.filter((c) => c.text.includes(normalizedTarget) ||
6791
- (c.text.length >= MIN_REVERSE_MATCH_LEN &&
6792
- c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
6793
- normalizedTarget.includes(c.text)));
6794
- if (substringMatches.length > 0) {
6795
- // Prefer nodes where the LLM text is contained in the node text
6796
- const containingMatches = substringMatches.filter((c) => c.text.includes(normalizedTarget));
6797
- if (containingMatches.length > 0) {
6798
- return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
6799
- }
6800
- return this.pickBestMatch(substringMatches.map((c) => c.node), lineNumber);
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
- return text
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.