carrick 0.3.109 → 0.3.111

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 (31) hide show
  1. package/dist/render.d.ts +14 -7
  2. package/dist/render.js +49 -36
  3. package/dist/render.js.map +1 -1
  4. package/package.json +6 -6
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/sidecar/dist/src/bundler.d.ts +10 -0
  7. package/sidecar/dist/src/bundler.js +39 -0
  8. package/sidecar/dist/src/capture/anchors.js +35 -0
  9. package/sidecar/dist/src/capture/api.d.ts +5 -0
  10. package/sidecar/dist/src/capture/check-workspace.d.ts +10 -0
  11. package/sidecar/dist/src/capture/check-workspace.js +68 -6
  12. package/sidecar/dist/src/capture/index.d.ts +1 -0
  13. package/sidecar/dist/src/capture/index.js +18 -3
  14. package/sidecar/dist/src/capture/installed-package-directory.d.ts +22 -0
  15. package/sidecar/dist/src/capture/installed-package-directory.js +30 -0
  16. package/sidecar/dist/src/capture/resolution-edges.d.ts +36 -0
  17. package/sidecar/dist/src/capture/resolution-edges.js +150 -0
  18. package/sidecar/dist/src/capture/status-table.d.ts +62 -0
  19. package/sidecar/dist/src/capture/status-table.js +104 -0
  20. package/sidecar/dist/src/failure-path.d.ts +42 -2
  21. package/sidecar/dist/src/failure-path.js +38 -13
  22. package/sidecar/dist/src/library-claims.js +2 -0
  23. package/sidecar/dist/src/line-index.d.ts +9 -0
  24. package/sidecar/dist/src/line-index.js +24 -0
  25. package/sidecar/dist/src/response-modes.d.ts +77 -0
  26. package/sidecar/dist/src/response-modes.js +459 -0
  27. package/sidecar/dist/src/type-inferrer.d.ts +93 -1
  28. package/sidecar/dist/src/type-inferrer.js +436 -97
  29. package/sidecar/dist/src/types.d.ts +20 -1
  30. package/sidecar/dist/src/validators.d.ts +39 -16
  31. package/sidecar/dist/src/validators.js +4 -0
@@ -24,8 +24,11 @@ import { validateInferRequestItem } from './validators.js';
24
24
  import { notePrintedType, PrintedTypes } from './printed-names.js';
25
25
  import { externalImportsOf, isExternalOrigin } from './origin.js';
26
26
  import { reachedOnlyOnFailure } from './failure-path.js';
27
+ import { readResponseModes } from './response-modes.js';
27
28
  import { functionAtLine } from './function-line-index.js';
29
+ import { endLineOf, startLineOf } from './line-index.js';
28
30
  import { elapsedMs, inferTiming, phaseClock, timedPhase } from './infer-timing.js';
31
+ import { classifyStatusCodes, constituents, httpStatus, statesNoBody, statusKeyCode, statusTableOf, successEntries, } from './capture/index.js';
29
32
  import { addedDiagnostics, applyInsertions, fileDiagnostics, literalInsertions, mapBack, mapForward, normalise, pathOf, } from './unwidened.js';
30
33
  import { expandTypeStructural, jsonWireType, } from './type-structural-expander.js';
31
34
  /**
@@ -169,11 +172,19 @@ const RESPONSE_HELPER_MAX_DEPTH = 4;
169
172
  * the branch an error path, whose shape is not the endpoint's contract.
170
173
  */
171
174
  const STATUS_MEMBER_NAMES = ['status', 'statusCode'];
172
- /** `value` when it is an integer in the HTTP status range, else `undefined`. */
173
- function httpStatus(value) {
174
- return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
175
- ? value
176
- : undefined;
175
+ /**
176
+ * The items whose whitespace-collapsed texts differ, first of each kept, in
177
+ * order: how a joined union drops a member it already has.
178
+ */
179
+ function distinctByText(items, text) {
180
+ const seen = new Set();
181
+ return items.filter((item) => {
182
+ const key = text(item).replace(/\s+/g, ' ').trim();
183
+ if (seen.has(key))
184
+ return false;
185
+ seen.add(key);
186
+ return true;
187
+ });
177
188
  }
178
189
  /**
179
190
  * The status codes a TYPE fixes: a numeric literal, or a union made only of
@@ -193,16 +204,6 @@ function statusCodesOfType(type) {
193
204
  }
194
205
  return codes.length > 0 ? codes : undefined;
195
206
  }
196
- /** One verdict for a set of status codes, or `mixed` when they disagree. */
197
- function classifyStatusCodes(codes) {
198
- if (codes.every((code) => code >= 400))
199
- return 'error';
200
- if (codes.every((code) => code >= 300 && code < 400))
201
- return 'redirect';
202
- if (codes.every((code) => code < 300))
203
- return 'success';
204
- return 'mixed';
205
- }
206
207
  /**
207
208
  * The parts of a request that ARE the body, as validator middleware names them
208
209
  * (`validate('json', Schema)`). HTTP vocabulary for how a body is carried — the
@@ -973,7 +974,7 @@ export class TypeInferrer {
973
974
  Node.isArrowFunction(ancestor) ||
974
975
  Node.isFunctionExpression(ancestor) ||
975
976
  Node.isMethodDeclaration(ancestor)) &&
976
- ancestor.getStartLineNumber() === request.line_number);
977
+ startLineOf(ancestor) === request.line_number);
977
978
  const declaredHandler = insideFunctionOnLine
978
979
  ? this.handlerDeclaredAtLine(sourceFile, request.line_number)
979
980
  : undefined;
@@ -1047,7 +1048,11 @@ export class TypeInferrer {
1047
1048
  // argument is the route path, not a payload. The span locator falls
1048
1049
  // back to exactly this shape when no payload expression was reported,
1049
1050
  // so drilling here would put the path literal's type in the manifest.
1050
- const registersCallback = args.some((arg) => Node.isArrowFunction(arg) || Node.isFunctionExpression(arg));
1051
+ // A call handed a function whose result is plain data is not a
1052
+ // registration but the payload: `rows.map((r) => ({ ... }))` sends the
1053
+ // list, not the callback's return (carrick#2055).
1054
+ const registersCallback = args.some((arg) => Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) &&
1055
+ !this.callResultIsPlainData(node);
1051
1056
  if (registersCallback) {
1052
1057
  // A registration that DECLARES its response contract in a schema needs
1053
1058
  // no indirection: the declaration is the contract the framework
@@ -1098,7 +1103,9 @@ export class TypeInferrer {
1098
1103
  // `typeText` keeps the bare name `Payment`, which dangles in the
1099
1104
  // source-less cross-repo bundle → `any` → unverifiable. Expand the
1100
1105
  // resolved object structurally so the real members land in the bundle.
1101
- typeString = this.expandResolvedTypeStructural(resolved, typeString, this.wireFormatFor(request));
1106
+ // The name it falls back to is the awaited one: a located call under
1107
+ // `await` sends what it resolves to, never the promise (carrick#2055).
1108
+ typeString = this.expandResolvedTypeStructural(resolved, typeText(resolved, payloadNode), this.wireFormatFor(request));
1102
1109
  }
1103
1110
  const anchor = this.unwrapArrayLevels(this.unwrapPromiseType(payloadType));
1104
1111
  return this.createInferredType(request, typeString, false, this.getNodeLocation(payloadNode), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, this.primaryTypeSymbol(anchor.element), anchor.depth, this.primaryTypeSymbolSource(anchor.element));
@@ -1130,7 +1137,7 @@ export class TypeInferrer {
1130
1137
  */
1131
1138
  exportedFunctionOpeningOn(sourceFile, line) {
1132
1139
  const declared = this.findFunctionByLine(sourceFile, line);
1133
- if (!declared || declared.getStartLineNumber() !== line)
1140
+ if (!declared || startLineOf(declared) !== line)
1134
1141
  return undefined;
1135
1142
  if (Node.isFunctionDeclaration(declared)) {
1136
1143
  return Node.isSourceFile(declared.getParent()) && declared.isExported()
@@ -1256,6 +1263,8 @@ export class TypeInferrer {
1256
1263
  * returns the repo's own type (`toInstance(View, plain)`) is the payload.
1257
1264
  */
1258
1265
  callResultIsPayload(call) {
1266
+ if (this.callResultIsPlainData(call))
1267
+ return true;
1259
1268
  const { element } = this.unwrapArrayLevels(this.unwrapPromiseType(call.getType()));
1260
1269
  if (this.symbolIsLibOrExternalOrigin(element.getSymbol() ?? element.getAliasSymbol())) {
1261
1270
  return false;
@@ -1265,6 +1274,58 @@ export class TypeInferrer {
1265
1274
  // the call that built it would publish what it was built from.
1266
1275
  return this.nodeCarriesPayloadContract(call, false, false);
1267
1276
  }
1277
+ /**
1278
+ * True when a located call evaluates to plain data, so it is the payload
1279
+ * whatever its arguments and wherever its type is declared (carrick#2055).
1280
+ *
1281
+ * `rows.map(toDto)`, `rows.filter(cb)` and `listItems(ownerId)` produce the
1282
+ * list a route sends. Read as a send, the first produced the mapper's
1283
+ * function type, the second a callback's return, and the third `string`.
1284
+ * What a send or a registration hands back is never plain data: a reply
1285
+ * builder or a router carries methods, a bare send returns `void`, and a
1286
+ * serialiser returns a primitive. So the result alone tells them apart.
1287
+ *
1288
+ * Plain data, once promise levels are taken off: an object with at least
1289
+ * one member, no call or construct signature, and no member that is itself
1290
+ * callable; an array of one; or a union whose members other than `null` and
1291
+ * `undefined` all are. Tuples and empty objects are left to the callers'
1292
+ * own reading.
1293
+ */
1294
+ callResultIsPlainData(call) {
1295
+ const isPlainData = (type, depth) => {
1296
+ if (depth > 3)
1297
+ return false;
1298
+ if (type.isAny() || type.isUnknown() || type.isNever())
1299
+ return false;
1300
+ if (type.isUnion()) {
1301
+ const members = type
1302
+ .getUnionTypes()
1303
+ .filter((member) => !member.isNull() && !member.isUndefined());
1304
+ return members.length > 0 && members.every((member) => isPlainData(member, depth + 1));
1305
+ }
1306
+ if (type.isIntersection()) {
1307
+ return type.getIntersectionTypes().every((member) => isPlainData(member, depth + 1));
1308
+ }
1309
+ const element = type.getArrayElementType();
1310
+ if (element)
1311
+ return isPlainData(element, depth + 1);
1312
+ if (!type.isObject() || type.isTuple() || this.isCallableType(type))
1313
+ return false;
1314
+ if (this.typeIsOrContainsResponseMachinery(type))
1315
+ return false;
1316
+ const members = type.getProperties();
1317
+ const indexed = type.getStringIndexType() ?? type.getNumberIndexType();
1318
+ if (members.length === 0 && !indexed)
1319
+ return false;
1320
+ const callable = (held) => held.isUnion()
1321
+ ? held.getUnionTypes().some((member) => this.isCallableType(member))
1322
+ : this.isCallableType(held);
1323
+ if (indexed && callable(indexed))
1324
+ return false;
1325
+ return members.every((member) => !callable(member.getTypeAtLocation(call)));
1326
+ };
1327
+ return isPlainData(this.unwrapPromiseType(call.getType()), 0);
1328
+ }
1268
1329
  /**
1269
1330
  * The wire representation a request's printed type takes. A route response
1270
1331
  * is serialised as JSON by every sender this layer reads a payload out of,
@@ -1299,7 +1360,10 @@ export class TypeInferrer {
1299
1360
  const resolvedSource = resolvedSymbol !== undefined && recoveredAnchor
1300
1361
  ? this.primaryTypeSymbolSource(recoveredAnchor.element)
1301
1362
  : undefined;
1302
- return this.createInferredType(request, recovered.typeString, recovered.isExplicit, this.getNodeLocation(recovered.node), undefined, resolvedSymbol ?? writtenAnchor?.symbol, writtenAnchor ? writtenAnchor.depth : recoveredAnchor?.depth, writtenAnchor ? writtenAnchor.source : resolvedSource);
1363
+ const inferred = this.createInferredType(request, recovered.typeString, recovered.isExplicit, this.getNodeLocation(recovered.node), undefined, resolvedSymbol ?? writtenAnchor?.symbol, writtenAnchor ? writtenAnchor.depth : recoveredAnchor?.depth, writtenAnchor ? writtenAnchor.source : resolvedSource);
1364
+ if (recovered.responseModes)
1365
+ inferred.response_modes = recovered.responseModes;
1366
+ return inferred;
1303
1367
  }
1304
1368
  /**
1305
1369
  * carrick#1161: type a route response from every send in the handler the
@@ -1316,7 +1380,8 @@ export class TypeInferrer {
1316
1380
  *
1317
1381
  * Returns `undefined` when this does not apply, which leaves the caller's
1318
1382
  * own reading of the located expression untouched:
1319
- * - the row's line is not a route registration;
1383
+ * - the row's line is neither a route registration nor a line inside the
1384
+ * handler the located send sits in;
1320
1385
  * - the located expression is neither a send nor the argument of one;
1321
1386
  * - that send is not one the handler returns;
1322
1387
  * - it is a success send and the only one that survives, so the join would
@@ -1329,11 +1394,18 @@ export class TypeInferrer {
1329
1394
  * or the redirect location back in.
1330
1395
  */
1331
1396
  inferFromResponseSites(sourceFile, request, located, extractionConfig) {
1332
- if (!this.registrationAtLine(sourceFile, request.line_number))
1333
- return undefined;
1334
1397
  const func = this.findContainingFunctionForNode(located);
1335
1398
  if (!func)
1336
1399
  return undefined;
1400
+ // The row's line is a registration, or a line inside the handler the
1401
+ // located send sits in (carrick#2005: a file-based route's row names the
1402
+ // send's own line, and a send's line is no registration). A row whose
1403
+ // line is outside that function says nothing about it.
1404
+ if (!this.registrationAtLine(sourceFile, request.line_number) &&
1405
+ !(func.getStartLineNumber() <= request.line_number &&
1406
+ request.line_number <= func.getEndLineNumber())) {
1407
+ return undefined;
1408
+ }
1337
1409
  const branches = this.responseReturnedExpressions(func).flatMap((expression) => this.expandResponseBranches(expression, 0));
1338
1410
  if (branches.length === 0)
1339
1411
  return undefined;
@@ -1348,10 +1420,10 @@ export class TypeInferrer {
1348
1420
  if (!locatedBranch)
1349
1421
  return undefined;
1350
1422
  const dropped = (status) => status === 'error' || status === 'redirect';
1351
- const siteStatus = this.responseSiteStatus(site);
1423
+ const siteStatus = this.responseSiteStatus(site, true);
1352
1424
  const survivors = [];
1353
1425
  for (const branch of branches) {
1354
- const status = branch === locatedBranch ? siteStatus : this.responseSiteStatus(branch);
1426
+ const status = branch === locatedBranch ? siteStatus : this.responseSiteStatus(branch, true);
1355
1427
  if (status === 'variable') {
1356
1428
  this.log(`Response status at ${request.file_path}:${this.getNodeLocation(branch).start_line} ` +
1357
1429
  'is not a literal; keeping that send in the route contract');
@@ -1472,7 +1544,9 @@ export class TypeInferrer {
1472
1544
  // carrier read gives its own. A result that is no carrier keeps the
1473
1545
  // answer it had.
1474
1546
  const where = `${request.file_path}:${request.line_number}`;
1475
- const resultIsCarrier = !explicitType && this.resultCarrierArguments(returnType, terminalNode) !== undefined;
1547
+ const callArgs = this.callResultArguments(callExpr);
1548
+ const resultIsCarrier = !explicitType &&
1549
+ this.resultCarrierArguments(returnType, terminalNode, callArgs) !== undefined;
1476
1550
  // carrick#1877: the terminal holds a thenable the names do not peel, a
1477
1551
  // subclass of `Promise` or a class with a `then` of its own. What a
1478
1552
  // caller receives is what `await` yields for it, as it is for the
@@ -1521,12 +1595,22 @@ export class TypeInferrer {
1521
1595
  const readThroughCarrier = unwrapResult.wasUnwrapped &&
1522
1596
  resultIsCarrier &&
1523
1597
  !!afterRules &&
1524
- this.resultCarrierArguments(afterRules, terminalNode) === undefined;
1525
- const carrierCandidate = explicitType || !afterRules
1598
+ this.resultCarrierArguments(afterRules, terminalNode, callArgs) === undefined;
1599
+ const carrierRead = explicitType || !afterRules
1526
1600
  ? undefined
1527
1601
  : readThroughCarrier
1528
- ? afterRules
1529
- : this.resultCarrierPayload(afterRules, terminalNode, use.projections, where);
1602
+ ? { payload: afterRules }
1603
+ : this.resultCarrierPayload(afterRules, terminalNode, use.projections, where, callArgs);
1604
+ // carrick#1841: a carrier built from a response table whose success side
1605
+ // states no body (a `204` row), or whose side cannot be told, states no
1606
+ // response contract. The abstain is decided for the same reason as the
1607
+ // transport abstain above: left undecided, the capture's own locator
1608
+ // re-reads the raw call and publishes the carrier.
1609
+ if (carrierRead && 'abstain' in carrierRead) {
1610
+ this.log(`Call result at ${where}: ${carrierRead.abstain}`);
1611
+ return this.transportAbstain(request, callExpr, carrierRead.abstain);
1612
+ }
1613
+ const carrierCandidate = carrierRead?.payload;
1530
1614
  // carrick#1841: what the carrier holds goes through the service's wrapper
1531
1615
  // rules before it is published, as the call's own result did. The carrier
1532
1616
  // is found by its shape, so what it holds can still be a library's
@@ -1658,7 +1742,7 @@ export class TypeInferrer {
1658
1742
  const callAfterRules = callUnwrap.wasUnwrapped ? callUnwrap.payloadType : callPayloadType;
1659
1743
  const leftCarrier = (callUnwrap.wasUnwrapped || readByProtocol) &&
1660
1744
  !!callAfterRules &&
1661
- this.resultCarrierArguments(callAfterRules, callExpr) !== undefined;
1745
+ this.resultCarrierArguments(callAfterRules, callExpr, callArgs) !== undefined;
1662
1746
  const anchorSource = carrierPayload ?? (leftCarrier ? undefined : callAfterRules);
1663
1747
  let anchor = anchorSource
1664
1748
  ? this.unwrapArrayLevels(this.unwrapPromiseType(anchorSource))
@@ -2131,8 +2215,8 @@ export class TypeInferrer {
2131
2215
  path: '',
2132
2216
  kind: 'unknown',
2133
2217
  reason: 'no_request_body',
2134
- detail: `the located argument is the call's request config, and it sets no '${inCall.member}' ` +
2135
- 'member, which is where the call takes its body, so the call sends 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',
2136
2220
  },
2137
2221
  ];
2138
2222
  return abstain;
@@ -2466,13 +2550,57 @@ export class TypeInferrer {
2466
2550
  * Where neither decides, the carrier keeps its own answer and the limit is
2467
2551
  * logged: a coin flip published as a contract is worse than an envelope a
2468
2552
  * reader can see is an envelope.
2469
- */
2470
- resultCarrierPayload(type, at, projections, where) {
2471
- const shape = this.resultCarrierArguments(type, at);
2553
+ *
2554
+ * carrick#1841: an argument that is a RESPONSE TABLE (`{ 200: Item }`, every
2555
+ * key a status code) says which side it is by its keys, and that decides
2556
+ * first: the one table whose keys are all 2xx is the success side, and what
2557
+ * a caller receives is the table's body, the member the branch holds, never
2558
+ * the table. A success table whose rows state no body (`{ 204: void }`)
2559
+ * states no response contract. A carrier built from tables, or found
2560
+ * through the call's arguments, that nothing decides states none either:
2561
+ * the union is the client's bookkeeping, not a body, so it is not published
2562
+ * as written.
2563
+ */
2564
+ resultCarrierPayload(type, at, projections, where, callArgs) {
2565
+ const shape = this.resultCarrierArguments(type, at, callArgs);
2472
2566
  if (!shape) {
2473
2567
  return undefined;
2474
2568
  }
2475
- const { carrier, carried } = shape;
2569
+ const { carrier, carried, bodies, readOffCall } = shape;
2570
+ const checker = this.project.getTypeChecker().compilerObject;
2571
+ const tables = carried.map((arg) => {
2572
+ const entries = statusTableOf(checker, arg.compilerType);
2573
+ return {
2574
+ arg,
2575
+ entries,
2576
+ side: entries ? classifyStatusCodes(entries.map((entry) => entry.code)) : undefined,
2577
+ };
2578
+ });
2579
+ const successTables = tables.filter((table) => table.side === 'success');
2580
+ if (successTables.length === 1) {
2581
+ const { arg, entries } = successTables[0];
2582
+ const body = bodies.get(arg) ?? this.singleSuccessValue(arg, entries ?? [], at);
2583
+ if (body && statesNoBody(body.compilerType)) {
2584
+ return {
2585
+ abstain: `the call answers a carrier whose success side is the response table ` +
2586
+ `'${typeText(arg, at)}', and its success rows state no body, so this site ` +
2587
+ 'states no response contract',
2588
+ };
2589
+ }
2590
+ if (body) {
2591
+ return { payload: body };
2592
+ }
2593
+ }
2594
+ const builtFromTables = tables.some((table) => table.side !== undefined);
2595
+ const undecided = (detail) => {
2596
+ this.log(`Call result at ${where} answers a carrier (${typeText(carrier, at)}): ${detail}`);
2597
+ return builtFromTables || readOffCall
2598
+ ? {
2599
+ abstain: `the call answers a carrier (${typeText(carrier, at)}) whose success side ` +
2600
+ 'cannot be told from its failure side, so this site states no response contract',
2601
+ }
2602
+ : undefined;
2603
+ };
2476
2604
  const argTexts = new Map(carried.map((arg) => [arg.getText(), arg]));
2477
2605
  const read = new Set();
2478
2606
  for (const projection of projections) {
@@ -2491,27 +2619,69 @@ export class TypeInferrer {
2491
2619
  // argument out than the shape test picked, the two disagree about which
2492
2620
  // side is which and nothing here knows better than the source does.
2493
2621
  if (byShape && byRead && byShape !== byRead) {
2494
- this.log(`Call result at ${where} answers a carrier (${typeText(carrier, at)}) whose error ` +
2495
- `shape names '${typeText(byShape, at)}' as the payload while the source reads ` +
2496
- `'${typeText(byRead, at)}' out of it. Publishing the carrier as written rather ` +
2497
- 'than picking one');
2622
+ return undecided(`its error shape names '${typeText(byShape, at)}' as the payload while the source ` +
2623
+ `reads '${typeText(byRead, at)}' out of it. Not picking one`);
2624
+ }
2625
+ const decided = byShape ?? byRead;
2626
+ if (decided) {
2627
+ return { payload: bodies.get(decided) ?? decided };
2628
+ }
2629
+ return undecided('its success side cannot be told from its failure side: no single argument is the ' +
2630
+ 'only non-error one and the source reads none of them out. Not guessing which ' +
2631
+ 'argument is the payload');
2632
+ }
2633
+ /**
2634
+ * The value of a response table's one success row, for a table a carrier
2635
+ * holds as itself rather than through its body. `undefined` where the table
2636
+ * has more than one success row: their union has no single type here.
2637
+ */
2638
+ singleSuccessValue(table, entries, at) {
2639
+ const success = successEntries(entries);
2640
+ if (success.length !== 1)
2498
2641
  return undefined;
2642
+ return table.getProperty(success[0].key)?.getTypeAtLocation(at);
2643
+ }
2644
+ /**
2645
+ * carrick#1841: the type arguments a call's result was built from, for a
2646
+ * result that no longer carries them. A client whose result type is a
2647
+ * conditional alias (`Result<TData, TError, ThrowOnError>`) resolves to its
2648
+ * branch at the call, and the branch is an anonymous union with no
2649
+ * arguments of its own. The arguments are still stated in two places the
2650
+ * compiler keeps:
2651
+ *
2652
+ * - the call's own explicit type arguments (`client.get<Table, Errors>(…)`);
2653
+ * - the alias the callee's declared return type was written with, where
2654
+ * the callee is a function wrapping such a call
2655
+ * (`getItem<ThrowOnError>(…)` returning `Result<Table, Errors, ThrowOnError>`).
2656
+ *
2657
+ * An argument read here names a payload only where a branch of the result
2658
+ * holds it (`resultCarrierArguments`), so a type parameter of the callee or
2659
+ * a style flag carries nothing.
2660
+ */
2661
+ callResultArguments(callExpr) {
2662
+ const explicit = callExpr.getTypeArguments().map((node) => node.getType());
2663
+ if (explicit.length > 0)
2664
+ return explicit;
2665
+ try {
2666
+ const declaration = this.project
2667
+ .getTypeChecker()
2668
+ .getResolvedSignature(callExpr)
2669
+ ?.getDeclaration();
2670
+ if (declaration && Node.isReturnTyped(declaration)) {
2671
+ return declaration.getReturnType().getAliasTypeArguments();
2672
+ }
2499
2673
  }
2500
- if (byShape ?? byRead) {
2501
- return byShape ?? byRead;
2674
+ catch {
2675
+ // A signature the checker cannot resolve states no arguments.
2502
2676
  }
2503
- this.log(`Call result at ${where} answers a carrier (${typeText(carrier, at)}) whose success ` +
2504
- 'side cannot be told from its failure side: no single argument is the only ' +
2505
- 'non-error one and the source reads none of them out. Publishing the carrier as ' +
2506
- 'written rather than guessing which argument is the payload');
2507
- return undefined;
2677
+ return [];
2508
2678
  }
2509
2679
  /**
2510
2680
  * The shape test of `resultCarrierPayload`: the carrier `type` is, once a
2511
2681
  * promise-like around it is peeled, and the type arguments a branch of it
2512
2682
  * holds as a member. `undefined` when `type` is not a carrier.
2513
2683
  */
2514
- resultCarrierArguments(type, at) {
2684
+ resultCarrierArguments(type, at, callArgs = []) {
2515
2685
  const carrier = this.unwrapThenableType(this.unwrapPromiseType(type));
2516
2686
  if (!carrier.isUnion()) {
2517
2687
  return undefined;
@@ -2520,26 +2690,80 @@ export class TypeInferrer {
2520
2690
  if (branches.length < 2 || !branches.every((branch) => this.isObjectShape(branch))) {
2521
2691
  return undefined;
2522
2692
  }
2523
- const args = [
2524
- ...carrier.getAliasTypeArguments(),
2525
- ...carrier.getTypeArguments(),
2526
- ];
2693
+ const own = [...carrier.getAliasTypeArguments(), ...carrier.getTypeArguments()];
2694
+ // carrick#1841: a carrier with no arguments of its own is read through the
2695
+ // arguments its call was built from (`callResultArguments`).
2696
+ const readOffCall = own.length === 0;
2697
+ const args = readOffCall ? callArgs : own;
2527
2698
  if (args.length < 2) {
2528
2699
  return undefined;
2529
2700
  }
2701
+ // carrick#1841: on a carrier read off the call, a member every branch
2702
+ // holds with one type (the request and response objects a client hands
2703
+ // back beside the result) is the transport's, not a side of the result.
2704
+ const transport = readOffCall ? this.membersCommonToEveryBranch(branches, at) : new Set();
2705
+ const checker = this.project.getTypeChecker().compilerObject;
2706
+ // What a branch member holding a response table's body looks like: the
2707
+ // table indexed by its own keys (`Table[keyof Table]`), which the compiler
2708
+ // resolves to the union of the table's values.
2709
+ const tableValues = new Map();
2710
+ for (const arg of args) {
2711
+ const entries = statusTableOf(checker, arg.compilerType);
2712
+ if (entries) {
2713
+ tableValues.set(arg, new Set(entries.flatMap((entry) => constituents(checker.getTypeOfSymbolAtLocation(entry.symbol, at.compilerNode)))));
2714
+ }
2715
+ }
2716
+ const bodies = new Map();
2530
2717
  // A type argument only names a payload when a branch actually holds it:
2531
2718
  // a generic that parameterises a status code or a key carries nothing.
2532
- const carried = args.filter((arg) => branches.some((branch) => branch
2533
- .getProperties()
2534
- .some((property) => {
2719
+ const carried = args.filter((arg) => branches.some((branch) => branch.getProperties().some((property) => {
2720
+ if (transport.has(property.getName()))
2721
+ return false;
2535
2722
  try {
2536
- return property.getTypeAtLocation(at).getText() === arg.getText();
2723
+ const held = property.getTypeAtLocation(at);
2724
+ if (held.getText() === arg.getText())
2725
+ return true;
2726
+ const values = tableValues.get(arg);
2727
+ const heldTypes = constituents(held.compilerType);
2728
+ if (values &&
2729
+ heldTypes.length === values.size &&
2730
+ heldTypes.every((member) => values.has(member))) {
2731
+ bodies.set(arg, held);
2732
+ return true;
2733
+ }
2734
+ return false;
2537
2735
  }
2538
2736
  catch {
2539
2737
  return false;
2540
2738
  }
2541
2739
  })));
2542
- return carried.length === 0 ? undefined : { carrier, carried };
2740
+ return carried.length === 0 ? undefined : { carrier, carried, bodies, readOffCall };
2741
+ }
2742
+ /** The member names every branch holds, each with one and the same type. */
2743
+ membersCommonToEveryBranch(branches, at) {
2744
+ const [first, ...rest] = branches;
2745
+ const common = new Set();
2746
+ for (const property of first.getProperties()) {
2747
+ const name = property.getName();
2748
+ let held;
2749
+ try {
2750
+ held = property.getTypeAtLocation(at);
2751
+ }
2752
+ catch {
2753
+ continue;
2754
+ }
2755
+ const same = rest.every((branch) => {
2756
+ try {
2757
+ return branch.getProperty(name)?.getTypeAtLocation(at).compilerType === held.compilerType;
2758
+ }
2759
+ catch {
2760
+ return false;
2761
+ }
2762
+ });
2763
+ if (same)
2764
+ common.add(name);
2765
+ }
2766
+ return common;
2543
2767
  }
2544
2768
  /**
2545
2769
  * The decided abstain of a call whose result carries transport the
@@ -2549,16 +2773,16 @@ export class TypeInferrer {
2549
2773
  * re-reading the raw call (`inference_decided_no_contract`,
2550
2774
  * engine/type_compat_v2.rs).
2551
2775
  */
2552
- transportAbstain(request, callExpr) {
2776
+ transportAbstain(request, callExpr, detail = "what this call's result carries is transport that the service's wrapper " +
2777
+ 'rules verify and read no payload out of (a library response object around the body), ' +
2778
+ 'so this site states no response contract') {
2553
2779
  const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(callExpr));
2554
2780
  abstain.any_provenance = [
2555
2781
  {
2556
2782
  path: '',
2557
2783
  kind: 'unknown',
2558
2784
  reason: 'machinery_envelope',
2559
- detail: "what this call's result carries is transport that the service's wrapper rules " +
2560
- 'verify and read no payload out of (a library response object around the body), ' +
2561
- 'so this site states no response contract',
2785
+ detail,
2562
2786
  },
2563
2787
  ];
2564
2788
  return abstain;
@@ -3918,17 +4142,13 @@ export class TypeInferrer {
3918
4142
  const isEmptyObject = (text) => text.replace(/\s+/g, '') === '{}';
3919
4143
  const withMembers = candidates.filter((c) => !isEmptyObject(c.typeString));
3920
4144
  const kept = withMembers.length > 0 ? withMembers : candidates;
3921
- // Dedupe on the whitespace-collapsed text, preserving source order.
3922
- const seen = new Set();
3923
- const distinct = kept.filter((c) => {
3924
- const key = c.typeString.replace(/\s+/g, ' ').trim();
3925
- if (seen.has(key))
3926
- return false;
3927
- seen.add(key);
3928
- return true;
3929
- });
4145
+ const distinct = distinctByText(kept, (c) => c.typeString);
3930
4146
  const typeString = distinct.map((c) => c.typeString).join(' | ');
3931
4147
  return {
4148
+ // carrick#2054: which request field picks each member of the union.
4149
+ responseModes: distinct.length >= 2
4150
+ ? this.responseModesOf(kept.map((c) => ({ node: c.node, type_string: c.typeString })))
4151
+ : undefined,
3932
4152
  typeString,
3933
4153
  // Every payload node that fed the union, deduped ones included: a
3934
4154
  // caller asks whether the expression it located is one of them.
@@ -3942,6 +4162,20 @@ export class TypeInferrer {
3942
4162
  statedTypeNode: distinct.length === 1 ? distinct[0].statedTypeNode : undefined,
3943
4163
  };
3944
4164
  }
4165
+ /**
4166
+ * The request field each member of a joined union answers
4167
+ * (`response-modes.ts`). Case texts are joined by the published union's
4168
+ * rule. A reading that fails is no reading: it must never cost the union.
4169
+ */
4170
+ responseModesOf(bodies) {
4171
+ try {
4172
+ return readResponseModes(bodies, (texts) => distinctByText(texts, (text) => text).join(' | '), (symbol) => this.symbolIsLibOrExternalOrigin(symbol));
4173
+ }
4174
+ catch (error) {
4175
+ this.log(`Response modes not read: ${error instanceof Error ? error.message : String(error)}`);
4176
+ return undefined;
4177
+ }
4178
+ }
3945
4179
  /**
3946
4180
  * The expressions a function returns, excluding returns belonging to nested
3947
4181
  * functions (a `.map(x => …)` callback returns a row, not the response).
@@ -4270,7 +4504,7 @@ export class TypeInferrer {
4270
4504
  * A status in the FIRST argument is a field of the body and is never read.
4271
4505
  * No method or framework name is consulted anywhere.
4272
4506
  */
4273
- responseSiteStatus(expression) {
4507
+ responseSiteStatus(expression, readChain = false) {
4274
4508
  const call = this.peelTransparentExpression(expression);
4275
4509
  if (!Node.isCallExpression(call) && !Node.isNewExpression(call)) {
4276
4510
  return 'undecided';
@@ -4293,6 +4527,14 @@ export class TypeInferrer {
4293
4527
  }
4294
4528
  if (variable)
4295
4529
  return 'variable';
4530
+ // carrick#2005: a status an earlier call of the same chain states
4531
+ // (`reply.status(401).json(body)`). Read only where the caller asks for
4532
+ // it; the return walk does not read it yet (carrick#1962).
4533
+ if (readChain) {
4534
+ const chained = this.chainStatus(call);
4535
+ if (chained)
4536
+ return chained;
4537
+ }
4296
4538
  const typed = this.sendResultStatusCodes(call);
4297
4539
  if (typed) {
4298
4540
  const kind = classifyStatusCodes(typed);
@@ -4301,6 +4543,37 @@ export class TypeInferrer {
4301
4543
  }
4302
4544
  return 'undecided';
4303
4545
  }
4546
+ /**
4547
+ * The status an earlier call of a send's own chain states: the receiver
4548
+ * calls `call` is invoked on (`a.status(401).json(body)` has `a.status(401)`),
4549
+ * innermost last. Each receiver call's first argument is read by the rule
4550
+ * an argument past a body is, so a numeric literal or a literal-typed value
4551
+ * counts and a plain `number` is `variable`. `undefined` when no receiver
4552
+ * call states a status. No method name is consulted: a receiver call that
4553
+ * is handed no status-shaped value says nothing.
4554
+ */
4555
+ chainStatus(call) {
4556
+ if (!Node.isCallExpression(call))
4557
+ return undefined;
4558
+ let callee = call.getExpression();
4559
+ while (Node.isPropertyAccessExpression(callee)) {
4560
+ const receiver = this.peelTransparentExpression(callee.getExpression());
4561
+ if (!Node.isCallExpression(receiver))
4562
+ 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
+ }
4573
+ callee = receiver.getExpression();
4574
+ }
4575
+ return undefined;
4576
+ }
4304
4577
  /**
4305
4578
  * The HTTP status codes an argument states, `'variable'` when it is a
4306
4579
  * status-shaped value the source does not fix, or `undefined` when it says
@@ -4822,7 +5095,7 @@ export class TypeInferrer {
4822
5095
  for (const node of sourceFile.getDescendants()) {
4823
5096
  if ((Node.isCallExpression(node) ||
4824
5097
  Node.isObjectLiteralExpression(node)) &&
4825
- node.getStartLineNumber() === line) {
5098
+ startLineOf(node) === line) {
4826
5099
  const handler = this.resolveRegisteredHandler(node);
4827
5100
  if (handler) {
4828
5101
  return { registration: node, handler };
@@ -5399,14 +5672,11 @@ export class TypeInferrer {
5399
5672
  requestBodyInCall(node) {
5400
5673
  if (Node.isCallExpression(node)) {
5401
5674
  const args = node.getArguments();
5675
+ if (args.length === 1)
5676
+ return this.requestObjectBody(args[0]);
5402
5677
  if (args.length < 2)
5403
5678
  return { kind: 'none' };
5404
- const first = args[0].getType();
5405
- const stringLike = first.isString() ||
5406
- first.isStringLiteral() ||
5407
- first.isTemplateLiteral() ||
5408
- (first.isUnion() && first.getUnionTypes().every((t) => t.isString() || t.isStringLiteral()));
5409
- if (!stringLike)
5679
+ if (!this.isStringLikeType(args[0].getType()))
5410
5680
  return { kind: 'none' };
5411
5681
  const slots = this.requestBodySlots(node);
5412
5682
  for (let i = 1; i < args.length; i++) {
@@ -5442,6 +5712,80 @@ export class TypeInferrer {
5442
5712
  }
5443
5713
  return { kind: 'none' };
5444
5714
  }
5715
+ /** A string, a string literal, a template literal, or a union of strings. */
5716
+ isStringLikeType(type) {
5717
+ return (type.isString() ||
5718
+ type.isStringLiteral() ||
5719
+ type.isTemplateLiteral() ||
5720
+ (type.isUnion() && type.getUnionTypes().every((t) => t.isString() || t.isStringLiteral())));
5721
+ }
5722
+ /**
5723
+ * carrick#1841, request half: a request call that states its URL on its one
5724
+ * object argument (`client.post({ url: '/items', ...options })`, the way a
5725
+ * generated client issues every operation). The method's own parameter
5726
+ * types the body `unknown`, so the object is the only statement of it: its
5727
+ * `body` member, the member `RequestInit` defines, written on the object or
5728
+ * carried in by a spread whose type says what it is.
5729
+ *
5730
+ * - A `body` written after the object's last spread is the body.
5731
+ * - Otherwise the object's own type has the answer. No `body` member, or
5732
+ * one typed `undefined` or `never`, means the call sends no body.
5733
+ * - A member typed `any`, `unknown` or a type parameter, or one only the
5734
+ * compiler's default library declares (a spread `RequestInit` carries the
5735
+ * serialised `BodyInit`, not a payload), states no payload: abstain. So
5736
+ * does an object that carries another body-named member and no `body`.
5737
+ *
5738
+ * An object with no string-typed `url` member is not a request object.
5739
+ */
5740
+ requestObjectBody(arg) {
5741
+ const literal = this.unwrapExpressionNode(arg);
5742
+ if (!Node.isObjectLiteralExpression(literal))
5743
+ return { kind: 'none' };
5744
+ const objectType = literal.getType();
5745
+ const url = objectType.getProperty('url');
5746
+ if (!url || !this.isStringLikeType(url.getTypeAtLocation(literal).getNonNullableType())) {
5747
+ return { kind: 'none' };
5748
+ }
5749
+ const from = "the request call's request object";
5750
+ const member = 'body';
5751
+ const properties = literal.getProperties();
5752
+ const written = properties.findIndex((p) => (Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p)) &&
5753
+ p.getName() === member);
5754
+ const spreadAfter = properties.some((p, i) => i > written && Node.isSpreadAssignment(p));
5755
+ if (written >= 0 && !spreadAfter) {
5756
+ const value = this.objectLiteralMemberValue(literal, member);
5757
+ if (value)
5758
+ return { kind: 'body', node: value, from };
5759
+ }
5760
+ const property = objectType.getProperty(member);
5761
+ if (!property) {
5762
+ const otherBodyMember = [...BODY_MEMBER_NAMES].some((name) => name !== member && objectType.getProperty(name));
5763
+ return otherBodyMember
5764
+ ? { kind: 'abstain', why: `the request object carries no '${member}' member but another body-named one` }
5765
+ : { kind: 'no_body', member };
5766
+ }
5767
+ const type = property.getTypeAtLocation(literal);
5768
+ if (type.isAny() || type.isUnknown() || type.isTypeParameter()) {
5769
+ return { kind: 'abstain', why: `the request object's '${member}' member is not typed` };
5770
+ }
5771
+ const bare = type.getNonNullableType();
5772
+ if (bare.isNever() || bare.isUndefined() || bare.isVoid()) {
5773
+ return { kind: 'no_body', member };
5774
+ }
5775
+ if (bare.isAny() || bare.isUnknown() || bare.isTypeParameter()) {
5776
+ return { kind: 'abstain', why: `the request object's '${member}' member is not typed` };
5777
+ }
5778
+ const program = this.project.getProgram().compilerObject;
5779
+ const declarations = property.getDeclarations();
5780
+ if (declarations.length > 0 &&
5781
+ declarations.every((d) => program.isSourceFileDefaultLibrary(d.getSourceFile().compilerNode))) {
5782
+ return {
5783
+ kind: 'abstain',
5784
+ why: `the request object's '${member}' member is the platform's serialised body, not a payload`,
5785
+ };
5786
+ }
5787
+ return { kind: 'body_type', type: bare, at: literal, from };
5788
+ }
5445
5789
  /**
5446
5790
  * The body a config-slot argument supplies through `member`: an object
5447
5791
  * literal's own member, or a variable's member read off its type.
@@ -5963,14 +6307,9 @@ export class TypeInferrer {
5963
6307
  if (!Node.isPropertyAssignment(prop)) {
5964
6308
  continue;
5965
6309
  }
5966
- const name = prop.getName().replace(/['"`]/g, '').toLowerCase();
5967
- // `2xx` is a status RANGE key; treat it as the bottom of its range.
5968
- const code = /^\d{3}$/.test(name)
5969
- ? Number(name)
5970
- : /^\dxx$/.test(name)
5971
- ? Number(name[0]) * 100
5972
- : NaN;
5973
- if (!Number.isFinite(code) || code < 200 || code > 299) {
6310
+ // `2xx` is a status RANGE key; it reads as the bottom of its range.
6311
+ const code = statusKeyCode(prop.getName());
6312
+ if (code === undefined || code < 200 || code > 299) {
5974
6313
  continue;
5975
6314
  }
5976
6315
  const initializer = prop.getInitializer();
@@ -6418,7 +6757,7 @@ export class TypeInferrer {
6418
6757
  // Filter to nodes within the search window and pre-compute normalized text
6419
6758
  const candidates = (lineNumber
6420
6759
  ? nodes.filter((node) => {
6421
- const nodeLine = node.getStartLineNumber();
6760
+ const nodeLine = startLineOf(node);
6422
6761
  return nodeLine >= lineNumber - searchRadius && nodeLine <= lineNumber + searchRadius;
6423
6762
  })
6424
6763
  : nodes).map((node) => ({ node, text: this.normalizeWhitespace(node.getText()) }));
@@ -6476,8 +6815,8 @@ export class TypeInferrer {
6476
6815
  }
6477
6816
  // Tie-break by proximity to target line
6478
6817
  if (targetLine !== undefined) {
6479
- const bestDist = Math.abs(best.getStartLineNumber() - targetLine);
6480
- const currentDist = Math.abs(current.getStartLineNumber() - targetLine);
6818
+ const bestDist = Math.abs(startLineOf(best) - targetLine);
6819
+ const currentDist = Math.abs(startLineOf(current) - targetLine);
6481
6820
  return currentDist < bestDist ? current : best;
6482
6821
  }
6483
6822
  return best;
@@ -6693,8 +7032,8 @@ export class TypeInferrer {
6693
7032
  // Result Building
6694
7033
  // ===========================================================================
6695
7034
  getNodeLocation(node) {
6696
- const startLinePos = node.getStartLineNumber();
6697
- const endLinePos = node.getEndLineNumber();
7035
+ const startLinePos = startLineOf(node);
7036
+ const endLinePos = endLineOf(node);
6698
7037
  const location = {
6699
7038
  file_path: node.getSourceFile().getFilePath(),
6700
7039
  start_line: startLinePos,
@@ -6716,11 +7055,11 @@ export class TypeInferrer {
6716
7055
  infer_kind: request.infer_kind,
6717
7056
  payload_type_string: payloadTypeString,
6718
7057
  primary_type_symbol: primaryTypeSymbol,
6719
- // Only meaningful relative to the anchor symbol: without an element
6720
- // symbol there is nothing for the explicit-bundle correction to match.
6721
- array_depth: primaryTypeSymbol !== undefined && arrayDepth !== undefined && arrayDepth > 0
6722
- ? arrayDepth
6723
- : undefined,
7058
+ // Reported whether or not the element has a symbol (carrick#1967): an
7059
+ // array whose element prints structurally is still a list. The Rust
7060
+ // depth join copies it onto the model's symbol when the symbols agree,
7061
+ // or, at a handler's send, when the element has no symbol to disagree.
7062
+ array_depth: arrayDepth !== undefined && arrayDepth > 0 ? arrayDepth : undefined,
6724
7063
  // Same gate: a declaration source without an anchor symbol is useless.
6725
7064
  primary_type_symbol_source: primaryTypeSymbol !== undefined ? primaryTypeSymbolSource : undefined,
6726
7065
  };