carrick 0.3.70 → 0.3.73

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 (55) hide show
  1. package/README.md +10 -2
  2. package/bin/carrick.mjs +4 -1
  3. package/dist/contract.d.ts +3 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/init/doctor.d.ts +3 -1
  6. package/dist/init/doctor.js +33 -6
  7. package/dist/init/doctor.js.map +1 -1
  8. package/dist/init/install-id.d.ts +32 -0
  9. package/dist/init/install-id.js +118 -0
  10. package/dist/init/install-id.js.map +1 -0
  11. package/dist/init/mcp.d.ts +59 -5
  12. package/dist/init/mcp.js +133 -27
  13. package/dist/init/mcp.js.map +1 -1
  14. package/dist/init/remove.d.ts +4 -0
  15. package/dist/init/remove.js +26 -5
  16. package/dist/init/remove.js.map +1 -1
  17. package/dist/init/run.d.ts +8 -0
  18. package/dist/init/run.js +20 -2
  19. package/dist/init/run.js.map +1 -1
  20. package/dist/native.d.ts +25 -5
  21. package/dist/native.js +41 -10
  22. package/dist/native.js.map +1 -1
  23. package/dist/render.js +7 -2
  24. package/dist/render.js.map +1 -1
  25. package/package.json +6 -6
  26. package/sidecar/dist/src/capture/anchors.d.ts +15 -0
  27. package/sidecar/dist/src/capture/anchors.js +70 -5
  28. package/sidecar/dist/src/capture/api.d.ts +43 -4
  29. package/sidecar/dist/src/capture/check-classify.d.ts +2 -0
  30. package/sidecar/dist/src/capture/check-classify.js +28 -0
  31. package/sidecar/dist/src/capture/check-deep.js +1 -1
  32. package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
  33. package/sidecar/dist/src/capture/check-probe.js +16 -0
  34. package/sidecar/dist/src/capture/deep-walk.d.ts +33 -6
  35. package/sidecar/dist/src/capture/deep-walk.js +81 -28
  36. package/sidecar/dist/src/capture/index.js +16 -6
  37. package/sidecar/dist/src/capture/installed-package.d.ts +58 -0
  38. package/sidecar/dist/src/capture/installed-package.js +311 -0
  39. package/sidecar/dist/src/capture/lockfile.d.ts +8 -0
  40. package/sidecar/dist/src/capture/lockfile.js +1 -1
  41. package/sidecar/dist/src/capture/node-builder.d.ts +27 -0
  42. package/sidecar/dist/src/capture/node-builder.js +107 -1
  43. package/sidecar/dist/src/capture/paths-rewrite.d.ts +23 -6
  44. package/sidecar/dist/src/capture/paths-rewrite.js +43 -11
  45. package/sidecar/dist/src/capture/self-check.js +12 -3
  46. package/sidecar/dist/src/capture/unresolved.d.ts +28 -0
  47. package/sidecar/dist/src/capture/unresolved.js +107 -0
  48. package/sidecar/dist/src/type-inferrer.d.ts +227 -30
  49. package/sidecar/dist/src/type-inferrer.js +926 -144
  50. package/sidecar/dist/src/type-structural-expander.d.ts +53 -3
  51. package/sidecar/dist/src/type-structural-expander.js +108 -19
  52. package/sidecar/dist/src/type-text-canonicalizer.d.ts +14 -0
  53. package/sidecar/dist/src/type-text-canonicalizer.js +23 -2
  54. package/sidecar/dist/src/validators.d.ts +10 -0
  55. package/sidecar/dist/src/validators.js +1 -0
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { Node, SyntaxKind, ts, } from 'ts-morph';
21
21
  import { validateInferRequestItem } from './validators.js';
22
- import { expandTypeStructural } from './type-structural-expander.js';
22
+ import { expandTypeStructural, } from './type-structural-expander.js';
23
23
  /**
24
24
  * TS/lib globals and primitives that must never be emitted as a deterministic
25
25
  * type anchor (`primary_type_symbol`). A payload whose resolved symbol is one of
@@ -184,6 +184,40 @@ const RESPONSE_HELPER_MAX_DEPTH = 4;
184
184
  * the branch an error path, whose shape is not the endpoint's contract.
185
185
  */
186
186
  const STATUS_MEMBER_NAMES = ['status', 'statusCode'];
187
+ /** `value` when it is an integer in the HTTP status range, else `undefined`. */
188
+ function httpStatus(value) {
189
+ return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
190
+ ? value
191
+ : undefined;
192
+ }
193
+ /**
194
+ * The status codes a TYPE fixes: a numeric literal, or a union made only of
195
+ * them, every one in the HTTP range. `undefined` for anything else, including
196
+ * a plain `number`.
197
+ */
198
+ function statusCodesOfType(type) {
199
+ const members = type.isUnion() ? type.getUnionTypes() : [type];
200
+ const codes = [];
201
+ for (const member of members) {
202
+ if (!member.isNumberLiteral())
203
+ return undefined;
204
+ const code = httpStatus(member.getLiteralValue());
205
+ if (code === undefined)
206
+ return undefined;
207
+ codes.push(code);
208
+ }
209
+ return codes.length > 0 ? codes : undefined;
210
+ }
211
+ /** One verdict for a set of status codes, or `mixed` when they disagree. */
212
+ function classifyStatusCodes(codes) {
213
+ if (codes.every((code) => code >= 400))
214
+ return 'error';
215
+ if (codes.every((code) => code >= 300 && code < 400))
216
+ return 'redirect';
217
+ if (codes.every((code) => code < 300))
218
+ return 'success';
219
+ return 'mixed';
220
+ }
187
221
  /**
188
222
  * The parts of a request that ARE the body, as validator middleware names them
189
223
  * (`validate('json', Schema)`). HTTP vocabulary for how a body is carried — the
@@ -410,29 +444,11 @@ export class TypeInferrer {
410
444
  this.typeIsOrContainsResponseMachinery(awaitedType);
411
445
  const unresolvable = awaitedType.isAny() || awaitedType.isUnknown();
412
446
  if (machinery || unresolvable) {
413
- const recovered = this.recoverPayloadFromReturnStatements(func, !machinery);
447
+ const recovered = this.recoverPayloadFromReturnStatements(func, !machinery, this.wireFormatFor(request));
414
448
  if (recovered) {
415
449
  this.log(`Return type at ${request.file_path}:${request.line_number} carries no ` +
416
450
  "contract; recovered the payload from the response helper's argument");
417
- const recoveredAnchor = recovered.anchorType
418
- ? this.unwrapArrayLevels(recovered.anchorType)
419
- : undefined;
420
- const resolvedSymbol = recoveredAnchor
421
- ? this.primaryTypeSymbol(recoveredAnchor.element)
422
- : undefined;
423
- // carrick#768: the resolved type of a stated annotation carries no
424
- // symbol when the alias resolves to an INSTANTIATED type (the
425
- // schema-first `type Body = Infer<typeof Schema>` shape) — the
426
- // compiler answers the synthetic `__type` and the route loses the
427
- // one name a reader could import. The annotation as WRITTEN still
428
- // names it, so read the anchor off the type node when the resolved
429
- // type had none. Fallback only: a resolved symbol is the better
430
- // answer and keeps its precedence.
431
- const stated = recovered.statedTypeNode;
432
- const writtenAnchor = resolvedSymbol === undefined && stated
433
- ? this.writtenAnchorOf(stated)
434
- : undefined;
435
- return this.createInferredType(request, recovered.typeString, recovered.isExplicit, this.getNodeLocation(recovered.node), undefined, resolvedSymbol ?? writtenAnchor?.symbol, writtenAnchor ? writtenAnchor.depth : recoveredAnchor?.depth, writtenAnchor?.source);
451
+ return this.inferredFromRecoveredPayload(request, recovered);
436
452
  }
437
453
  // Nothing recoverable. Reject a wrapper envelope that IS or CONTAINS
438
454
  // framework machinery (carrick#371): `withApiWrapper({ handler: () =>
@@ -468,7 +484,7 @@ export class TypeInferrer {
468
484
  // bare name `Payment`, which dangles in the source-less cross-repo bundle.
469
485
  // Expand the resolved object structurally so the real members reach the
470
486
  // bundle. `unwrapPromise` below is then a no-op on the structural form.
471
- typeString = this.expandResolvedTypeStructural(awaitedType, typeString);
487
+ typeString = this.expandResolvedTypeStructural(awaitedType, typeString, this.wireFormatFor(request));
472
488
  }
473
489
  typeString = this.unwrapPromise(typeString, returnType);
474
490
  // Only the route-registration handler-following RESPONSE path treats a
@@ -627,6 +643,13 @@ export class TypeInferrer {
627
643
  return this.buildFunctionReturnInferredType(request, registryHandler, extractionConfig, true);
628
644
  }
629
645
  }
646
+ // carrick#1161: the locator anchors the HANDLER, not the response. When it
647
+ // names one send among several, the route's contract is every success
648
+ // body the handler sends, whichever of them the model happened to report.
649
+ const fromSites = this.inferFromResponseSites(sourceFile, request, node, extractionConfig);
650
+ if (fromSites !== undefined) {
651
+ return fromSites;
652
+ }
630
653
  // carrick#1017: the located expression evaluates to the transport wrapper
631
654
  // itself — `Response.json(entry)`, `new Response(JSON.stringify(entry))`,
632
655
  // `ctx.json(entry)` all have the platform `Response` as their type, and a
@@ -645,21 +668,11 @@ export class TypeInferrer {
645
668
  (!locatedUnwrap.wasUnwrapped &&
646
669
  this.typeIsOrContainsResponseMachinery(this.unwrapPromiseType(locatedType)));
647
670
  if (machineryAnchor) {
648
- const recovered = this.recoverPayloadFromResponseExpressions([node], false);
671
+ const recovered = this.recoverPayloadFromResponseExpressions([node], false, this.wireFormatFor(request));
649
672
  if (recovered) {
650
673
  this.log(`Response payload at ${request.file_path}:${request.line_number} is the ` +
651
674
  "transport wrapper; recovered the body from the response call's argument");
652
- const recoveredAnchor = recovered.anchorType
653
- ? this.unwrapArrayLevels(recovered.anchorType)
654
- : undefined;
655
- const resolvedSymbol = recoveredAnchor
656
- ? this.primaryTypeSymbol(recoveredAnchor.element)
657
- : undefined;
658
- const stated = recovered.statedTypeNode;
659
- const writtenAnchor = resolvedSymbol === undefined && stated
660
- ? this.writtenAnchorOf(stated)
661
- : undefined;
662
- return this.createInferredType(request, recovered.typeString, recovered.isExplicit, this.getNodeLocation(recovered.node), undefined, resolvedSymbol ?? writtenAnchor?.symbol, writtenAnchor ? writtenAnchor.depth : recoveredAnchor?.depth, writtenAnchor?.source);
675
+ return this.inferredFromRecoveredPayload(request, recovered);
663
676
  }
664
677
  this.log(`Response payload at ${request.file_path}:${request.line_number} is the ` +
665
678
  'transport wrapper and no branch states a body; leaving it unresolved');
@@ -725,11 +738,179 @@ export class TypeInferrer {
725
738
  // `typeText` keeps the bare name `Payment`, which dangles in the
726
739
  // source-less cross-repo bundle → `any` → unverifiable. Expand the
727
740
  // resolved object structurally so the real members land in the bundle.
728
- typeString = this.expandResolvedTypeStructural(resolved, typeString);
741
+ typeString = this.expandResolvedTypeStructural(resolved, typeString, this.wireFormatFor(request));
729
742
  }
730
743
  const anchor = this.unwrapArrayLevels(this.unwrapPromiseType(payloadType));
731
744
  return this.createInferredType(request, typeString, false, this.getNodeLocation(payloadNode), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, this.primaryTypeSymbol(anchor.element), anchor.depth);
732
745
  }
746
+ /**
747
+ * The wire representation a request's printed type takes. A route response
748
+ * is serialised as JSON by every sender this layer reads a payload out of,
749
+ * so it prints `toJSON()` results rather than the objects that declare them
750
+ * (carrick#1163). Everything else prints the declared type.
751
+ */
752
+ wireFormatFor(request) {
753
+ return request.infer_kind === 'response_body' ||
754
+ request.infer_kind === 'function_return'
755
+ ? 'json'
756
+ : 'declared';
757
+ }
758
+ /** An inferred type for a payload read out of response sends. */
759
+ inferredFromRecoveredPayload(request, recovered) {
760
+ const recoveredAnchor = recovered.anchorType
761
+ ? this.unwrapArrayLevels(recovered.anchorType)
762
+ : undefined;
763
+ const resolvedSymbol = recoveredAnchor
764
+ ? this.primaryTypeSymbol(recoveredAnchor.element)
765
+ : undefined;
766
+ // carrick#768: the resolved type of a stated annotation carries no symbol
767
+ // when the alias resolves to an INSTANTIATED type (the schema-first
768
+ // `type Body = Infer<typeof Schema>` shape): the compiler answers the
769
+ // synthetic `__type` and the route loses the one name a reader could
770
+ // import. The annotation as WRITTEN still names it, so read the anchor off
771
+ // the type node when the resolved type had none. Fallback only: a
772
+ // resolved symbol is the better answer and keeps its precedence.
773
+ const stated = recovered.statedTypeNode;
774
+ const writtenAnchor = resolvedSymbol === undefined && stated ? this.writtenAnchorOf(stated) : undefined;
775
+ return this.createInferredType(request, recovered.typeString, recovered.isExplicit, this.getNodeLocation(recovered.node), undefined, resolvedSymbol ?? writtenAnchor?.symbol, writtenAnchor ? writtenAnchor.depth : recoveredAnchor?.depth, writtenAnchor?.source);
776
+ }
777
+ /**
778
+ * carrick#1161: type a route response from every send in the handler the
779
+ * locator anchors, rather than from the one send it names.
780
+ *
781
+ * The analyzer reports one response expression per row, and on a handler
782
+ * that guards before it succeeds that is the guard's body. So the located
783
+ * expression selects the HANDLER: the function that contains it, on a row
784
+ * whose line is a route registration. Its returned sends are enumerated
785
+ * (conditional branches split), each is classified by the status it states
786
+ * (`responseSiteStatus`), the error and redirect branches are dropped, and
787
+ * the success bodies are joined exactly as the payload walk joins return
788
+ * statements.
789
+ *
790
+ * Returns `undefined` when this does not apply, which leaves the caller's
791
+ * own reading of the located expression untouched:
792
+ * - the row's line is not a route registration;
793
+ * - the located expression is neither a send nor the argument of one;
794
+ * - that send is not one the handler returns;
795
+ * - it is a success send and the only one that survives, so the join would
796
+ * be the located body anyway;
797
+ * - no joined body is the located one (a text body beside JSON ones).
798
+ *
799
+ * When the located send is an error or redirect branch and no success body
800
+ * survives, the route sends no body a contract can describe: an explicit
801
+ * `unknown` with its reason, so no later locator re-run reads the error body
802
+ * or the redirect location back in.
803
+ */
804
+ inferFromResponseSites(sourceFile, request, located, extractionConfig) {
805
+ if (!this.registrationAtLine(sourceFile, request.line_number))
806
+ return undefined;
807
+ const func = this.findContainingFunctionForNode(located);
808
+ if (!func)
809
+ return undefined;
810
+ const branches = this.responseReturnedExpressions(func).flatMap((expression) => this.expandResponseBranches(expression, 0));
811
+ if (branches.length === 0)
812
+ return undefined;
813
+ const serialisers = this.calleesProvenSerialiser(branches);
814
+ const isSend = (candidate) => this.isResponseSend(candidate, extractionConfig, serialisers);
815
+ const peeled = this.peelTransparentExpression(located);
816
+ const site = isSend(peeled) ? peeled : this.sendReceivingArgument(located, isSend);
817
+ if (!site)
818
+ return undefined;
819
+ const contains = (outer, inner) => outer.getStart() <= inner.getStart() && inner.getEnd() <= outer.getEnd();
820
+ const locatedBranch = branches.find((branch) => contains(branch, site));
821
+ if (!locatedBranch)
822
+ return undefined;
823
+ const dropped = (status) => status === 'error' || status === 'redirect';
824
+ const siteStatus = this.responseSiteStatus(site);
825
+ const survivors = [];
826
+ for (const branch of branches) {
827
+ const status = branch === locatedBranch ? siteStatus : this.responseSiteStatus(branch);
828
+ if (status === 'variable') {
829
+ this.log(`Response status at ${request.file_path}:${this.getNodeLocation(branch).start_line} ` +
830
+ 'is not a literal; keeping that send in the route contract');
831
+ }
832
+ if (dropped(status))
833
+ continue;
834
+ if (branch !== locatedBranch && !isSend(branch))
835
+ continue;
836
+ survivors.push(branch);
837
+ }
838
+ const locatedDropped = dropped(siteStatus);
839
+ if (!locatedDropped && survivors.length <= 1)
840
+ return undefined;
841
+ const recovered = survivors.length > 0
842
+ ? this.recoverPayloadFromResponseExpressions(survivors, false, this.wireFormatFor(request))
843
+ : null;
844
+ if (locatedDropped) {
845
+ if (recovered) {
846
+ this.log(`Response locator at ${request.file_path}:${request.line_number} names an error ` +
847
+ 'or redirect send; publishing the success sends of its handler instead');
848
+ return this.inferredFromRecoveredPayload(request, recovered);
849
+ }
850
+ this.log(`Response locator at ${request.file_path}:${request.line_number} names an error ` +
851
+ 'or redirect send and its handler sends no success body; abstaining');
852
+ const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(site));
853
+ abstain.any_provenance = [
854
+ {
855
+ path: '',
856
+ kind: 'unknown',
857
+ reason: 'no_success_payload',
858
+ detail: "every response this route's handler sends states an error or redirect status, " +
859
+ 'or carries no body a JSON contract can describe, so it publishes no success body',
860
+ },
861
+ ];
862
+ return abstain;
863
+ }
864
+ if (recovered && recovered.nodes.some((node) => contains(site, node))) {
865
+ return this.inferredFromRecoveredPayload(request, recovered);
866
+ }
867
+ return undefined;
868
+ }
869
+ /**
870
+ * True when `candidate` is a response send: a call or `new` whose result is
871
+ * transport machinery (by the extraction config's verified rule or the
872
+ * structural check), or whose callee this handler's own returns prove is a
873
+ * serialiser (`calleesProvenSerialiser`, which holds on a bare checkout).
874
+ */
875
+ isResponseSend(candidate, extractionConfig, serialisers) {
876
+ const call = this.peelTransparentExpression(candidate);
877
+ if (!Node.isCallExpression(call) && !Node.isNewExpression(call))
878
+ return false;
879
+ const identity = this.calleeIdentity(call);
880
+ if (identity !== undefined && serialisers.has(identity))
881
+ return true;
882
+ const result = this.unwrapPromiseType(call.getType());
883
+ if (result.isAny() || result.isUnknown())
884
+ return false;
885
+ return (this.unwrapTypeWithConfig(result, call, extractionConfig).verifiedMachinery === true ||
886
+ this.typeIsOrContainsResponseMachinery(result));
887
+ }
888
+ /**
889
+ * The send `node` is an argument of, looking through the wrappers that do
890
+ * not change a payload (parentheses, `as`, `satisfies`, `!`, `await`) and a
891
+ * `JSON.stringify` around the body. `undefined` when the parent call is not
892
+ * a send or `node` is its callee.
893
+ */
894
+ sendReceivingArgument(node, isSend) {
895
+ let current = node;
896
+ let parent = current.getParent();
897
+ while (parent &&
898
+ (Node.isParenthesizedExpression(parent) ||
899
+ Node.isAsExpression(parent) ||
900
+ Node.isSatisfiesExpression(parent) ||
901
+ Node.isNonNullExpression(parent) ||
902
+ Node.isAwaitExpression(parent) ||
903
+ (Node.isCallExpression(parent) && this.unwrapJsonStringifyArg(parent) === current))) {
904
+ current = parent;
905
+ parent = current.getParent();
906
+ }
907
+ if (!parent || (!Node.isCallExpression(parent) && !Node.isNewExpression(parent))) {
908
+ return undefined;
909
+ }
910
+ if (!parent.getArguments().includes(current))
911
+ return undefined;
912
+ return isSend(parent) ? parent : undefined;
913
+ }
733
914
  inferCallResult(sourceFile, request, extractionConfig) {
734
915
  const callExpr = this.resolveTargetCallExpression(sourceFile, request);
735
916
  if (!callExpr) {
@@ -861,7 +1042,7 @@ export class TypeInferrer {
861
1042
  if (atLine) {
862
1043
  const requestType = this.requestContractFromRegistration(atLine.registration, atLine.handler);
863
1044
  if (requestType) {
864
- return this.createInferredType(request, requestType, true, this.getNodeLocation(atLine.handler), undefined, undefined);
1045
+ return this.declaredRequestInferredType(request, requestType, this.getNodeLocation(atLine.handler));
865
1046
  }
866
1047
  }
867
1048
  return null;
@@ -879,7 +1060,7 @@ export class TypeInferrer {
879
1060
  if (registrationHandler) {
880
1061
  const requestType = this.requestContractFromRegistration(this.unwrapExpressionNode(located), registrationHandler);
881
1062
  if (requestType) {
882
- return this.createInferredType(request, requestType, true, this.getNodeLocation(registrationHandler), undefined, undefined);
1063
+ return this.declaredRequestInferredType(request, requestType, this.getNodeLocation(registrationHandler));
883
1064
  }
884
1065
  // A genuinely payload-less handler (no typed request read): do NOT fall
885
1066
  // through to read the registration call's return type, which would emit a
@@ -912,7 +1093,7 @@ export class TypeInferrer {
912
1093
  if (declared) {
913
1094
  this.log(`Route registration at ${request.file_path}:${request.line_number} declares its ` +
914
1095
  'request contract; using the declaration over the located expression');
915
- return this.createInferredType(request, declared, true, this.getNodeLocation(declaredAt.handler), undefined, undefined);
1096
+ return this.declaredRequestInferredType(request, declared, this.getNodeLocation(declaredAt.handler));
916
1097
  }
917
1098
  }
918
1099
  // A text locator (Gemini `expression_text` + `expression_line`, as opposed
@@ -966,6 +1147,43 @@ export class TypeInferrer {
966
1147
  }
967
1148
  }
968
1149
  }
1150
+ // carrick#1101: the locator landed on a validated read — a call inside a
1151
+ // registered handler whose type IS the parsed output of a body schema that
1152
+ // the enclosing registration declares. That value is what the handler
1153
+ // receives, not what a caller sends, so publish the schema's input. Only a
1154
+ // direct call is read this way: a value that went through a serialiser or
1155
+ // a variable hop is left to the path below, so a consumer body forwarded
1156
+ // from inside a handler keeps its own type.
1157
+ if (node === unwrapped && Node.isCallExpression(node)) {
1158
+ const validatedRead = this.validatedReadRequestContract(node);
1159
+ if (validatedRead) {
1160
+ this.log(`Request locator at ${request.file_path}:${request.line_number} is a validated read ` +
1161
+ "of its registration's body schema; publishing the schema's input");
1162
+ return this.declaredRequestInferredType(request, validatedRead, this.getNodeLocation(node));
1163
+ }
1164
+ // carrick#1166: a validated read of a part the registration's validator
1165
+ // binds and which is not a body (`valid('param')`) is not what a caller
1166
+ // sends as the body. The route declares no body here, and saying so
1167
+ // keeps a later locator re-run from publishing the parameters instead.
1168
+ const nonBodyPart = declaredAt
1169
+ ? this.nonBodyValidatedPart(node, declaredAt.registration)
1170
+ : undefined;
1171
+ if (nonBodyPart !== undefined) {
1172
+ this.log(`Request locator at ${request.file_path}:${request.line_number} reads the validated ` +
1173
+ `'${nonBodyPart}' part, which is not a body; the route declares no request body here`);
1174
+ const abstain = this.createInferredType(request, 'unknown', false, this.getNodeLocation(node));
1175
+ abstain.any_provenance = [
1176
+ {
1177
+ path: '',
1178
+ kind: 'unknown',
1179
+ reason: 'no_request_body',
1180
+ detail: `the located read is the '${nonBodyPart}' part a validator binds, not a request body, ` +
1181
+ 'so the route states no body contract here',
1182
+ },
1183
+ ];
1184
+ return abstain;
1185
+ }
1186
+ }
969
1187
  const payloadType = node.getType();
970
1188
  let typeString = typeText(payloadType, node);
971
1189
  let isExplicit = false;
@@ -1004,6 +1222,22 @@ export class TypeInferrer {
1004
1222
  typeString = explicitType;
1005
1223
  isExplicit = true;
1006
1224
  }
1225
+ // carrick#1166: the read itself is untyped (`await c.req.json()` is `any`
1226
+ // or `unknown`), and the handler validates it by handing it to a schema:
1227
+ // `Schema.safeParse(body)`, `Schema.parse(await c.req.json())`. The schema
1228
+ // declares what a caller may send, so its INPUT is the request contract,
1229
+ // read the way every other request anchor reads a schema (carrick#1101).
1230
+ const readType = this.unwrapPromiseType(payloadType);
1231
+ if (!explicitType &&
1232
+ !unwrapResult.wasUnwrapped &&
1233
+ (readType.isAny() || readType.isUnknown())) {
1234
+ const parsedWith = this.schemaConsumingRead(node);
1235
+ if (parsedWith) {
1236
+ this.log(`Request locator at ${request.file_path}:${request.line_number} is an untyped read ` +
1237
+ "the handler validates with a schema; publishing the schema's input");
1238
+ return this.declaredRequestInferredType(request, parsedWith, this.getNodeLocation(node));
1239
+ }
1240
+ }
1007
1241
  // Publication guard (carrick#964): the locator landed on machinery — a
1008
1242
  // callable member of the handler's context, a method reference, a handler
1009
1243
  // binding — and neither a wrapper rule nor a declared type recovered a
@@ -1900,15 +2134,21 @@ export class TypeInferrer {
1900
2134
  * expanded to a structural form (primitives, library types, unresolvable
1901
2135
  * references), so a non-object annotation behaves exactly as before.
1902
2136
  */
1903
- expandAnnotationTypeNode(typeNode) {
2137
+ expandAnnotationTypeNode(typeNode, wire = 'declared') {
1904
2138
  const fallback = typeNode.getText();
1905
2139
  try {
1906
2140
  const annotationType = this.unwrapPromiseType(typeNode.getType());
1907
- const expanded = expandTypeStructural(annotationType);
2141
+ const expanded = expandTypeStructural(annotationType, new Set(), 0, undefined, wire);
1908
2142
  // Only prefer the structural form when expansion actually inlined an
1909
2143
  // object shape; otherwise keep the annotation text (e.g. a bare
1910
- // primitive or a library type the expander leaves by name).
1911
- return expanded.startsWith('{') ? expanded : fallback;
2144
+ // primitive or a library type the expander leaves by name). A wire
2145
+ // print that differs from the declared one is a real answer too: a
2146
+ // `Date` annotation sends a string (carrick#1163).
2147
+ if (expanded.startsWith('{'))
2148
+ return expanded;
2149
+ return wire === 'json' && expanded !== expandTypeStructural(annotationType)
2150
+ ? expanded
2151
+ : fallback;
1912
2152
  }
1913
2153
  catch {
1914
2154
  return fallback;
@@ -1930,9 +2170,16 @@ export class TypeInferrer {
1930
2170
  * `fallback` is the already-computed type text (post Promise/wrapper unwrap),
1931
2171
  * preserved verbatim when expansion does not inline an object.
1932
2172
  */
1933
- expandResolvedTypeStructural(type, fallback) {
2173
+ expandResolvedTypeStructural(type, fallback, wire = 'declared') {
1934
2174
  try {
1935
- const expanded = expandTypeStructural(type);
2175
+ const expanded = expandTypeStructural(type, new Set(), 0, undefined, wire);
2176
+ // A wire print that differs from the declared one is the answer even
2177
+ // without an inlined object: a bare `Date` payload sends a string.
2178
+ if (wire === 'json' &&
2179
+ !expanded.includes('{') &&
2180
+ expanded !== expandTypeStructural(type)) {
2181
+ return expanded;
2182
+ }
1936
2183
  // Prefer the expanded form whenever an object got inlined, not only when
1937
2184
  // it leads with `{`. `expandTypeStructural` wraps arrays and unions, so a
1938
2185
  // resolved `Payment[]` or `(Payment | null)[]` renders as `{…}[]` or
@@ -1983,8 +2230,8 @@ export class TypeInferrer {
1983
2230
  * Returns `null` (a logged limitation, never a guess) when no returned
1984
2231
  * expression yields an argument type this may read.
1985
2232
  */
1986
- recoverPayloadFromReturnStatements(func, statedOnly) {
1987
- return this.recoverPayloadFromResponseExpressions(this.responseReturnedExpressions(func), statedOnly);
2233
+ recoverPayloadFromReturnStatements(func, statedOnly, wire) {
2234
+ return this.recoverPayloadFromResponseExpressions(this.responseReturnedExpressions(func), statedOnly, wire);
1988
2235
  }
1989
2236
  /**
1990
2237
  * The response payload carried by a set of response-carrying expressions —
@@ -1997,7 +2244,7 @@ export class TypeInferrer {
1997
2244
  * stated status, and what survives is joined as a union exactly as several
1998
2245
  * return statements are.
1999
2246
  */
2000
- recoverPayloadFromResponseExpressions(expressions, statedOnly) {
2247
+ recoverPayloadFromResponseExpressions(expressions, statedOnly, wire) {
2001
2248
  const candidates = [];
2002
2249
  const returned = expressions.flatMap((expression) => this.expandResponseBranches(expression, 0));
2003
2250
  const serialisers = this.calleesProvenSerialiser(returned);
@@ -2012,7 +2259,7 @@ export class TypeInferrer {
2012
2259
  const stated = this.statedTypeNodeOf(payloadNode);
2013
2260
  if (stated) {
2014
2261
  candidates.push({
2015
- typeString: this.expandAnnotationTypeNode(stated),
2262
+ typeString: this.expandAnnotationTypeNode(stated, wire),
2016
2263
  isExplicit: true,
2017
2264
  node: payloadNode,
2018
2265
  anchorType: this.unwrapPromiseType(stated.getType()),
@@ -2022,7 +2269,7 @@ export class TypeInferrer {
2022
2269
  }
2023
2270
  const payloadType = this.unwrapPromiseType(payloadNode.getType());
2024
2271
  candidates.push({
2025
- typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode)),
2272
+ typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode), wire),
2026
2273
  isExplicit: false,
2027
2274
  node: payloadNode,
2028
2275
  anchorType: payloadType,
@@ -2050,6 +2297,9 @@ export class TypeInferrer {
2050
2297
  const typeString = distinct.map((c) => c.typeString).join(' | ');
2051
2298
  return {
2052
2299
  typeString,
2300
+ // Every payload node that fed the union, deduped ones included: a
2301
+ // caller asks whether the expression it located is one of them.
2302
+ nodes: kept.map((c) => c.node),
2053
2303
  // Only a contract every surviving branch STATES in source is explicit.
2054
2304
  isExplicit: distinct.every((c) => c.isExplicit),
2055
2305
  node: distinct[0].node,
@@ -2157,7 +2407,7 @@ export class TypeInferrer {
2157
2407
  continue;
2158
2408
  const statesStatus = args
2159
2409
  .slice(1)
2160
- .some((arg) => this.statedStatusCode(arg) !== undefined);
2410
+ .some((arg) => Array.isArray(this.statedStatusCodes(arg)));
2161
2411
  if (!statesStatus)
2162
2412
  continue;
2163
2413
  const key = this.calleeIdentity(call);
@@ -2213,7 +2463,9 @@ export class TypeInferrer {
2213
2463
  const args = call.getArguments().map((a) => this.peelTransparentExpression(a));
2214
2464
  if (args.length === 0)
2215
2465
  return undefined;
2216
- if (args.slice(1).some((a) => this.statesErrorStatus(a)))
2466
+ // An error or redirect branch sends no success body (carrick#1161).
2467
+ const status = this.responseSiteStatus(call);
2468
+ if (status === 'error' || status === 'redirect')
2217
2469
  return undefined;
2218
2470
  const identity = this.calleeIdentity(call);
2219
2471
  const proven = identity !== undefined && serialisers.has(identity);
@@ -2319,83 +2571,149 @@ export class TypeInferrer {
2319
2571
  return false;
2320
2572
  let statesInit = false;
2321
2573
  for (const property of properties) {
2322
- if (!Node.isPropertyAssignment(property))
2574
+ // `{ status: 202, headers }` names its headers by shorthand, which
2575
+ // states init exactly as `headers: headers` does.
2576
+ const shorthand = Node.isShorthandPropertyAssignment(property);
2577
+ if (!shorthand && !Node.isPropertyAssignment(property))
2323
2578
  return false;
2324
2579
  const name = property.getName();
2325
2580
  if (!RESPONSE_INIT_MEMBER_NAMES.has(name))
2326
2581
  return false;
2327
- const initializer = property.getInitializer();
2328
- if (!initializer)
2582
+ const value = shorthand ? property.getNameNode() : property.getInitializer();
2583
+ if (!value)
2329
2584
  return false;
2330
2585
  if (name === 'headers') {
2331
2586
  statesInit = true;
2332
2587
  continue;
2333
2588
  }
2334
- if (name === 'status' && Node.isNumericLiteral(initializer)) {
2335
- const value = initializer.getLiteralValue();
2336
- if (Number.isInteger(value) && value >= 100 && value <= 599) {
2337
- statesInit = true;
2338
- }
2589
+ if (name === 'status' && Array.isArray(this.statedStatusCodes(value))) {
2590
+ statesInit = true;
2339
2591
  }
2340
2592
  }
2341
2593
  return statesInit;
2342
2594
  }
2343
2595
  /**
2344
- * True when an argument states a >= 400 status: an options object carrying
2345
- * `status`/`statusCode`, or a bare status code (`send(body, 404)`).
2596
+ * What a response send states about its HTTP status (carrick#1161).
2346
2597
  *
2347
- * Read from the AST first: `{ status: 400 }` in an argument position widens
2348
- * to `{ status: number }`, so the literal only survives syntactically. The
2349
- * type check behind it catches `as const` and hoisted option objects.
2598
+ * Read in order, and the first that states anything decides:
2599
+ *
2600
+ * 1. an argument past the body that carries a status: a numeric literal
2601
+ * (`send(body, 404)`), an options object (`{ status: 401 }`), or any
2602
+ * expression whose TYPE is a status literal or a union of them
2603
+ * (`let code: 400 | 500`). A numeric argument whose type is a plain
2604
+ * `number` states a status the source does not fix, which is
2605
+ * `variable`: the branch fails open into the union and is logged.
2606
+ * 2. the send call's own RESULT type, when a member of it is typed as a
2607
+ * status literal. A framework that types its sends records the status
2608
+ * there, and a redirect helper's default `302` is only visible there.
2609
+ * A result whose status member spans success and error codes (the
2610
+ * default of a typed `json(body, status?)`) says nothing either way.
2611
+ *
2612
+ * A status in the FIRST argument is a field of the body and is never read.
2613
+ * No method or framework name is consulted anywhere.
2350
2614
  */
2351
- statesErrorStatus(node) {
2352
- const code = this.statedStatusCode(node);
2353
- return code !== undefined && code >= 400;
2615
+ responseSiteStatus(expression) {
2616
+ const call = this.peelTransparentExpression(expression);
2617
+ if (!Node.isCallExpression(call) && !Node.isNewExpression(call)) {
2618
+ return 'undecided';
2619
+ }
2620
+ const args = call.getArguments().map((a) => this.peelTransparentExpression(a));
2621
+ const stated = [];
2622
+ let variable = false;
2623
+ for (const arg of args.slice(1)) {
2624
+ const read = this.statedStatusCodes(arg);
2625
+ if (read === 'variable') {
2626
+ variable = true;
2627
+ }
2628
+ else if (read) {
2629
+ stated.push(...read);
2630
+ }
2631
+ }
2632
+ if (stated.length > 0) {
2633
+ const kind = classifyStatusCodes(stated);
2634
+ return kind === 'mixed' ? 'variable' : kind;
2635
+ }
2636
+ if (variable)
2637
+ return 'variable';
2638
+ const typed = this.sendResultStatusCodes(call);
2639
+ if (typed) {
2640
+ const kind = classifyStatusCodes(typed);
2641
+ if (kind !== 'mixed')
2642
+ return kind;
2643
+ }
2644
+ return 'undecided';
2354
2645
  }
2355
2646
  /**
2356
- * The HTTP status an argument states, or `undefined`.
2647
+ * The HTTP status codes an argument states, `'variable'` when it is a
2648
+ * status-shaped value the source does not fix, or `undefined` when it says
2649
+ * nothing about a status.
2357
2650
  *
2358
2651
  * Read from the AST first: `{ status: 400 }` in an argument position widens
2359
2652
  * to `{ status: number }`, so the literal only survives syntactically. The
2360
- * type check behind it catches `as const` and hoisted option objects. Only
2361
- * values in the HTTP range count — an arbitrary number named `status` on a
2362
- * domain object (`{ status: 2 }`) states nothing about transport.
2653
+ * type read behind it catches `as const`, hoisted option objects and
2654
+ * literal-typed variables. Only values in the HTTP range count: an
2655
+ * arbitrary number named `status` on a domain object (`{ status: 2 }`)
2656
+ * states nothing about transport.
2363
2657
  */
2364
- statedStatusCode(node) {
2365
- const asStatus = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
2366
- ? value
2367
- : undefined;
2658
+ statedStatusCodes(node) {
2368
2659
  if (Node.isNumericLiteral(node)) {
2369
- return asStatus(node.getLiteralValue());
2660
+ const code = httpStatus(node.getLiteralValue());
2661
+ return code === undefined ? undefined : [code];
2370
2662
  }
2371
2663
  if (Node.isObjectLiteralExpression(node)) {
2372
2664
  for (const name of STATUS_MEMBER_NAMES) {
2373
2665
  const property = node.getProperty(name);
2374
- if (property && Node.isPropertyAssignment(property)) {
2375
- const initializer = property.getInitializer();
2376
- if (initializer && Node.isNumericLiteral(initializer)) {
2377
- const code = asStatus(initializer.getLiteralValue());
2378
- if (code !== undefined)
2379
- return code;
2380
- }
2381
- }
2666
+ if (!property || !Node.isPropertyAssignment(property))
2667
+ continue;
2668
+ const initializer = property.getInitializer();
2669
+ if (!initializer)
2670
+ continue;
2671
+ const read = this.statedStatusCodes(this.peelTransparentExpression(initializer));
2672
+ if (read)
2673
+ return read;
2382
2674
  }
2675
+ return undefined;
2383
2676
  }
2384
2677
  const type = node.getType();
2678
+ const fromType = statusCodesOfType(type);
2679
+ if (fromType)
2680
+ return fromType;
2681
+ if (type.isNumber())
2682
+ return 'variable';
2385
2683
  for (const name of STATUS_MEMBER_NAMES) {
2386
2684
  const property = type.getProperty(name);
2387
2685
  const declaration = property?.getDeclarations()[0];
2388
2686
  if (!property || !declaration)
2389
2687
  continue;
2390
- const propertyType = property.getTypeAtLocation(declaration);
2391
- if (propertyType.isNumberLiteral()) {
2392
- const code = asStatus(propertyType.getLiteralValue());
2393
- if (code !== undefined)
2394
- return code;
2395
- }
2688
+ const propertyType = property.getTypeAtLocation(declaration).getNonNullableType();
2689
+ const codes = statusCodesOfType(propertyType);
2690
+ if (codes)
2691
+ return codes;
2692
+ if (propertyType.isNumber())
2693
+ return 'variable';
2396
2694
  }
2397
2695
  return undefined;
2398
2696
  }
2697
+ /**
2698
+ * Status codes a send call's result type records: the members (of the
2699
+ * result, or of each part of an intersection result) typed as a status
2700
+ * literal or a union of them. `undefined` when none is.
2701
+ */
2702
+ sendResultStatusCodes(call) {
2703
+ const result = this.unwrapPromiseType(call.getType());
2704
+ const parts = result.isIntersection() ? result.getIntersectionTypes() : [result];
2705
+ const codes = [];
2706
+ for (const part of parts) {
2707
+ if (part.isAny() || part.isUnknown() || !part.isObject())
2708
+ continue;
2709
+ for (const property of part.getProperties()) {
2710
+ const codesHere = statusCodesOfType(property.getTypeAtLocation(call));
2711
+ if (codesHere)
2712
+ codes.push(...codesHere);
2713
+ }
2714
+ }
2715
+ return codes.length > 0 ? codes : undefined;
2716
+ }
2399
2717
  /**
2400
2718
  * Peel the wrappers that do not change an expression's payload: parentheses
2401
2719
  * and `await`. Unlike `unwrapExpressionNode` this KEEPS `as`/`satisfies`,
@@ -2996,10 +3314,16 @@ export class TypeInferrer {
2996
3314
  return decl;
2997
3315
  }
2998
3316
  // `getDefinitionNodes()` may return the name identifier of a `const h = …`
2999
- // binding rather than the declaration; walk to the variable declaration.
3317
+ // binding rather than the declaration; step up to the variable declaration
3318
+ // it NAMES. Only that one step: a parameter of `const h = (input) => …` also
3319
+ // has `h` as an ancestor, and climbing to it made the parameter read as the
3320
+ // function it is declared inside (carrick#1162).
3321
+ const parent = decl.getParent();
3000
3322
  const varDecl = Node.isVariableDeclaration(decl)
3001
3323
  ? decl
3002
- : decl.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
3324
+ : parent && Node.isVariableDeclaration(parent) && parent.getNameNode() === decl
3325
+ ? parent
3326
+ : undefined;
3003
3327
  if (varDecl) {
3004
3328
  const initializer = varDecl.getInitializer();
3005
3329
  if (initializer) {
@@ -3126,12 +3450,13 @@ export class TypeInferrer {
3126
3450
  // of that parameter's type;
3127
3451
  // (b) a validation-schema object passed alongside the handler — `{ schema:
3128
3452
  // { body: ref('CreateWidget'), response: { 200: ref('Widget') } } }` —
3129
- // whose entries reference schema VALUES that carry their parsed output
3130
- // type.
3453
+ // whose entries reference schema VALUES that declare their input and
3454
+ // output types.
3131
3455
  //
3132
3456
  // Both are read structurally: (a) is "the `body` member of a parameter's
3133
3457
  // type", (b) is "a `schema` property on a registration argument, whose
3134
- // entries resolve to a value whose `parse` returns the contract". No
3458
+ // entries resolve to a value that declares its types" (the body reads the
3459
+ // input, the response the output; carrick#1101). No
3135
3460
  // framework, library, or method-name list is involved — a framework whose
3136
3461
  // request object exposes `body` and whose route options carry `schema` is
3137
3462
  // read the same way regardless of which one it is.
@@ -3160,13 +3485,13 @@ export class TypeInferrer {
3160
3485
  * back to following the handler's return.
3161
3486
  */
3162
3487
  declaredResponseInferredType(request, registration) {
3163
- const declared = this.routeSchemaContractText(registration, 'response');
3488
+ const declared = this.routeSchemaContract(registration, 'response');
3164
3489
  if (!declared) {
3165
3490
  return null;
3166
3491
  }
3167
3492
  this.log(`Route registration at ${request.file_path}:${request.line_number} declares its ` +
3168
3493
  'response schema; using the declared contract');
3169
- return this.createInferredType(request, declared, true, this.getNodeLocation(registration), undefined, undefined);
3494
+ return this.createInferredType(request, declared.text, true, this.getNodeLocation(registration), undefined, undefined);
3170
3495
  }
3171
3496
  /**
3172
3497
  * The request contract of a route registration, in anchor order: the
@@ -3176,8 +3501,23 @@ export class TypeInferrer {
3176
3501
  * the route declares its request nowhere we can read.
3177
3502
  */
3178
3503
  requestContractFromRegistration(registration, handler) {
3179
- return (this.declaredRequestContract(registration, handler) ??
3180
- this.inferRequestReadFromHandler(handler));
3504
+ const declared = this.declaredRequestContract(registration, handler);
3505
+ if (declared) {
3506
+ return declared;
3507
+ }
3508
+ const read = this.inferRequestReadFromHandler(handler);
3509
+ return read ? { text: read } : null;
3510
+ }
3511
+ /**
3512
+ * An explicit request `InferredType` for a contract the route declares,
3513
+ * carrying the provenance the reading recorded.
3514
+ */
3515
+ declaredRequestInferredType(request, contract, location) {
3516
+ const inferred = this.createInferredType(request, contract.text, true, location, undefined, undefined);
3517
+ if (contract.provenance && contract.provenance.length > 0) {
3518
+ inferred.any_provenance = contract.provenance;
3519
+ }
3520
+ return inferred;
3181
3521
  }
3182
3522
  /**
3183
3523
  * The request contract a route DECLARES, in anchor order: the handler's
@@ -3191,9 +3531,12 @@ export class TypeInferrer {
3191
3531
  * own locator authoritative when a route declares nothing.
3192
3532
  */
3193
3533
  declaredRequestContract(registration, handler) {
3194
- return (this.requestBodyFromHandlerParams(handler) ??
3195
- this.routeSchemaContractText(registration, 'body') ??
3196
- this.validatedBodyContractText(registration));
3534
+ const annotated = this.requestBodyFromHandlerParams(handler);
3535
+ if (annotated) {
3536
+ return { text: annotated };
3537
+ }
3538
+ return (this.routeSchemaContract(registration, 'body') ??
3539
+ this.validatedBodyContract(registration));
3197
3540
  }
3198
3541
  /**
3199
3542
  * Anchor (a): the request contract declared on the handler's own signature.
@@ -3246,8 +3589,9 @@ export class TypeInferrer {
3246
3589
  * The shape is a CALL among the registration's arguments whose own arguments
3247
3590
  * are a request part and a schema value. Neither the middleware's name nor
3248
3591
  * the schema library is checked: the part is read off the string literal, and
3249
- * the schema is whatever exposes a parsed output (`schemaOutputTypeText`) —
3250
- * the same test anchor (b) already applies to a `schema: { body: … }` object.
3592
+ * the schema is whatever declares its types (`schemaContract`) — the same
3593
+ * test anchor (b) already applies to a `schema: { body: … }` object. The
3594
+ * contract is the schema's INPUT, what a caller sends (carrick#1101).
3251
3595
  *
3252
3596
  * `REQUEST_BODY_PARTS` is HTTP vocabulary for how a body is carried, not a
3253
3597
  * framework list. A middleware bound to any other part (`query`, `param`,
@@ -3255,10 +3599,26 @@ export class TypeInferrer {
3255
3599
  * at all is not read: publishing a query schema as the request contract would
3256
3600
  * be the same confident-and-wrong answer this anchor exists to remove.
3257
3601
  */
3258
- validatedBodyContractText(registration) {
3602
+ validatedBodyContract(registration) {
3603
+ for (const candidate of this.middlewareBodySchemaValues(registration)) {
3604
+ const declared = this.schemaContract(candidate, 'input');
3605
+ if (declared) {
3606
+ return declared;
3607
+ }
3608
+ }
3609
+ return null;
3610
+ }
3611
+ /**
3612
+ * The values a registration's validator middleware binds to a body part, in
3613
+ * argument order: every non-literal argument of a middleware call that names
3614
+ * a `REQUEST_BODY_PARTS` part. Whether each one IS a schema is the reader's
3615
+ * question (`schemaContract`), not this walk's.
3616
+ */
3617
+ middlewareBodySchemaValues(registration) {
3259
3618
  if (!Node.isCallExpression(registration)) {
3260
- return null;
3619
+ return [];
3261
3620
  }
3621
+ const values = [];
3262
3622
  for (const argument of registration.getArguments()) {
3263
3623
  const middleware = this.unwrapExpressionNode(argument);
3264
3624
  if (!Node.isCallExpression(middleware)) {
@@ -3267,21 +3627,157 @@ export class TypeInferrer {
3267
3627
  const middlewareArgs = middleware
3268
3628
  .getArguments()
3269
3629
  .map((arg) => this.unwrapExpressionNode(arg));
3270
- const parts = middlewareArgs.filter((arg) => Node.isStringLiteral(arg));
3271
- if (parts.length === 0) {
3630
+ const bindsBody = middlewareArgs.some((arg) => Node.isStringLiteral(arg) &&
3631
+ REQUEST_BODY_PARTS.has(arg.getLiteralValue().toLowerCase()));
3632
+ if (!bindsBody) {
3272
3633
  continue;
3273
3634
  }
3274
- if (!parts.some((part) => REQUEST_BODY_PARTS.has(part.getLiteralValue().toLowerCase()))) {
3635
+ values.push(...middlewareArgs.filter((arg) => !Node.isStringLiteral(arg)));
3636
+ }
3637
+ return values;
3638
+ }
3639
+ /**
3640
+ * The contract of the schema a handler validates an untyped body read with
3641
+ * (carrick#1166), or null.
3642
+ *
3643
+ * `read` is the located expression: the read call, or the binding that holds
3644
+ * it. Every call in the enclosing function that takes that value as an
3645
+ * argument is a candidate, and the schema is the call's receiver
3646
+ * (`Schema.safeParse(body)`) or one of its other arguments
3647
+ * (`parse(Schema, body)`). A candidate counts only when it declares schema
3648
+ * types (`schemaOutputType` / `schemaInputType`), so a logger or a service
3649
+ * call the body is also handed to is never read as a contract. No method or
3650
+ * library name is matched.
3651
+ */
3652
+ schemaConsumingRead(read) {
3653
+ const func = this.findContainingFunctionForNode(read);
3654
+ if (!func)
3655
+ return null;
3656
+ const bindings = new Set();
3657
+ const addBinding = (identifier) => {
3658
+ const symbol = identifier?.getSymbol()?.compilerSymbol;
3659
+ if (symbol)
3660
+ bindings.add(symbol);
3661
+ };
3662
+ if (Node.isIdentifier(read)) {
3663
+ addBinding(read);
3664
+ }
3665
+ else {
3666
+ const declaration = read.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
3667
+ const initializer = declaration?.getInitializer();
3668
+ if (declaration &&
3669
+ initializer &&
3670
+ this.unwrapExpressionNode(initializer) === read &&
3671
+ Node.isIdentifier(declaration.getNameNode())) {
3672
+ addBinding(declaration.getNameNode());
3673
+ }
3674
+ }
3675
+ const carriesRead = (argument) => argument === read ||
3676
+ (Node.isIdentifier(argument) &&
3677
+ bindings.has(argument.getSymbol()?.compilerSymbol));
3678
+ for (const call of func.getDescendantsOfKind(SyntaxKind.CallExpression)) {
3679
+ const args = call.getArguments().map((arg) => this.unwrapExpressionNode(arg));
3680
+ const carrier = args.findIndex(carriesRead);
3681
+ if (carrier < 0)
3275
3682
  continue;
3683
+ const candidates = [];
3684
+ const callee = call.getExpression();
3685
+ if (Node.isPropertyAccessExpression(callee)) {
3686
+ candidates.push(callee.getExpression());
3276
3687
  }
3277
- for (const candidate of middlewareArgs) {
3278
- if (Node.isStringLiteral(candidate)) {
3688
+ args.forEach((arg, index) => {
3689
+ if (index !== carrier)
3690
+ candidates.push(arg);
3691
+ });
3692
+ for (const candidate of candidates) {
3693
+ const candidateType = candidate.getType();
3694
+ if (!this.schemaOutputType(candidateType, candidate) &&
3695
+ !this.schemaInputType(candidateType, candidate)) {
3279
3696
  continue;
3280
3697
  }
3281
- const declared = this.schemaOutputTypeText(candidate);
3282
- if (declared) {
3283
- return declared;
3698
+ const contract = this.schemaContract(candidate, 'input');
3699
+ if (contract)
3700
+ return contract;
3701
+ }
3702
+ }
3703
+ return null;
3704
+ }
3705
+ /**
3706
+ * The part a validated read names when that part is not a body, or
3707
+ * `undefined` (carrick#1166).
3708
+ *
3709
+ * The read is a call whose only argument is a string literal, and the
3710
+ * registration carries a validator middleware binding that same literal.
3711
+ * The part names match through the source's own literal, so no framework
3712
+ * vocabulary is assumed beyond `REQUEST_BODY_PARTS`, which already decides
3713
+ * what a body is for the middleware anchor.
3714
+ */
3715
+ nonBodyValidatedPart(read, registration) {
3716
+ if (!Node.isCallExpression(read) || !Node.isCallExpression(registration)) {
3717
+ return undefined;
3718
+ }
3719
+ const readArgs = read.getArguments().map((arg) => this.unwrapExpressionNode(arg));
3720
+ if (readArgs.length !== 1 || !Node.isStringLiteral(readArgs[0]))
3721
+ return undefined;
3722
+ const part = readArgs[0].getLiteralValue();
3723
+ if (REQUEST_BODY_PARTS.has(part.toLowerCase()))
3724
+ return undefined;
3725
+ for (const argument of registration.getArguments()) {
3726
+ const middleware = this.unwrapExpressionNode(argument);
3727
+ if (!Node.isCallExpression(middleware))
3728
+ continue;
3729
+ const bindsPart = middleware
3730
+ .getArguments()
3731
+ .map((arg) => this.unwrapExpressionNode(arg))
3732
+ .some((arg) => Node.isStringLiteral(arg) && arg.getLiteralValue() === part);
3733
+ if (bindsPart)
3734
+ return part;
3735
+ }
3736
+ return undefined;
3737
+ }
3738
+ /**
3739
+ * The request contract behind a VALIDATED READ: a call inside a registered
3740
+ * handler whose type is exactly the parsed output of a body schema the
3741
+ * enclosing registration declares (carrick#1101).
3742
+ *
3743
+ * The read's own type is what validation hands the handler, the schema's
3744
+ * output, so a locator that lands on it would publish the output as the
3745
+ * request. The registration is found by walking the read's ancestors to each
3746
+ * call or registry object whose handler contains the read and which declares
3747
+ * a body schema (anchor (b) or (b2)). The match is on the printed type: only
3748
+ * a read whose structural text equals that schema's output is substituted, so
3749
+ * any other expression in the handler (a query read, a payload of a different
3750
+ * shape) keeps its own type.
3751
+ */
3752
+ validatedReadRequestContract(read) {
3753
+ let readText;
3754
+ for (const ancestor of read.getAncestors()) {
3755
+ if (!Node.isCallExpression(ancestor) &&
3756
+ !Node.isObjectLiteralExpression(ancestor)) {
3757
+ continue;
3758
+ }
3759
+ const handler = this.registrationHandlerAt(ancestor);
3760
+ if (!handler ||
3761
+ read.getStart() < handler.getStart() ||
3762
+ read.getEnd() > handler.getEnd()) {
3763
+ continue;
3764
+ }
3765
+ const schemaValues = [
3766
+ ...this.routeSchemaEntryValues(ancestor, 'body'),
3767
+ ...this.middlewareBodySchemaValues(ancestor),
3768
+ ];
3769
+ for (const value of schemaValues) {
3770
+ const output = this.schemaContract(value, 'output');
3771
+ if (!output) {
3772
+ continue;
3773
+ }
3774
+ if (readText === undefined) {
3775
+ readText = this.structuralTextFromType(read.getType(), read);
3284
3776
  }
3777
+ if (readText !== output.text) {
3778
+ continue;
3779
+ }
3780
+ return this.schemaContract(value, 'input');
3285
3781
  }
3286
3782
  }
3287
3783
  return null;
@@ -3291,29 +3787,43 @@ export class TypeInferrer {
3291
3787
  *
3292
3788
  * `part` is `'body'` for the request contract and `'response'` for the
3293
3789
  * response contract; a `response` entry keyed by status code resolves to its
3294
- * success entry. Returns null when the registration carries no schema, when
3295
- * the entry references something whose parsed output cannot be resolved, or
3296
- * when the schema is a plain JSON-schema literal (whose own object type is
3297
- * the JSON-Schema document, not the payload — emitting that would be worse
3298
- * than abstaining).
3790
+ * success entry. The body reads the schema's input and the response its
3791
+ * output (carrick#1101). Returns null when the registration carries no
3792
+ * schema, when the entry references something whose declared types cannot be
3793
+ * resolved, or when the schema is a plain JSON-schema literal (whose own
3794
+ * object type is the JSON-Schema document, not the payload — emitting that
3795
+ * would be worse than abstaining).
3796
+ */
3797
+ routeSchemaContract(registration, part) {
3798
+ const [value] = this.routeSchemaEntryValues(registration, part);
3799
+ if (!value) {
3800
+ return null;
3801
+ }
3802
+ return this.schemaContract(value, part === 'body' ? 'input' : 'output');
3803
+ }
3804
+ /**
3805
+ * The value a route `schema` object declares for `part`, as a zero- or
3806
+ * one-element list: the `body` entry itself, or the success entry of a
3807
+ * status-keyed `response` map.
3299
3808
  */
3300
- routeSchemaContractText(registration, part) {
3809
+ routeSchemaEntryValues(registration, part) {
3301
3810
  const schemaObject = this.routeSchemaObject(registration);
3302
3811
  if (!schemaObject) {
3303
- return null;
3812
+ return [];
3304
3813
  }
3305
3814
  const entry = schemaObject.getProperty(part);
3306
3815
  if (!entry || !Node.isPropertyAssignment(entry)) {
3307
- return null;
3816
+ return [];
3308
3817
  }
3309
3818
  const initializer = entry.getInitializer();
3310
3819
  if (!initializer) {
3311
- return null;
3820
+ return [];
3312
3821
  }
3313
- const value = part === 'response'
3314
- ? (this.successStatusEntry(initializer) ?? initializer)
3315
- : initializer;
3316
- return this.schemaOutputTypeText(value);
3822
+ return [
3823
+ part === 'response'
3824
+ ? (this.successStatusEntry(initializer) ?? initializer)
3825
+ : initializer,
3826
+ ];
3317
3827
  }
3318
3828
  /**
3319
3829
  * The `schema` object literal carried by a route registration: scan the
@@ -3379,17 +3889,36 @@ export class TypeInferrer {
3379
3889
  return best?.node;
3380
3890
  }
3381
3891
  /**
3382
- * The payload type a schema entry declares.
3892
+ * The payload type a schema entry declares, read in `direction`.
3383
3893
  *
3384
3894
  * The entry is either a REFERENCE call — `ref('CreateWidget')`, a
3385
3895
  * name-to-schema indirection whose registry is the argument the ref function
3386
- * was built from — or the schema value itself. Either way the payload is the
3387
- * schema's parsed output: the return type of its `parse` method (the shape
3388
- * every schema value exposes as its public validate-and-return API), falling
3389
- * back to a declared `_output` member. A value with neither is not a schema
3390
- * and yields null.
3896
+ * was built from — or the schema value itself. A value that declares no type
3897
+ * in either direction is not a schema and yields null.
3898
+ *
3899
+ * `output` is the parsed value (`schemaOutputType`). `input` is what a caller
3900
+ * sends (`schemaInputType`), and a schema that declares no input member reads
3901
+ * its output instead, which is the whole of what is knowable about it.
3902
+ *
3903
+ * The input is decided member by member (carrick#1105). A member whose input
3904
+ * is `any`/`unknown` where the output is concrete is what a coercion declares
3905
+ * (it accepts any value and parses it into, say, a number). A top type in a
3906
+ * published contract disqualifies the whole row downstream, so THAT member
3907
+ * prints its output type, keeps the input key's optionality, and is recorded
3908
+ * as `coerced_input` provenance. Every other member keeps its input, so a
3909
+ * defaulted key beside a coerced one stays optional to send.
3910
+ *
3911
+ * Limits, each logged where it bites:
3912
+ * - the position walk is bounded (`walkTypePositions`), so a top type below
3913
+ * the bound is not compared and prints as the input declares it;
3914
+ * - a coerced position that cannot be substituted (its output is absent or
3915
+ * differs across union branches, or the printer cannot reach it inside a
3916
+ * tuple, behind a cycle or past the expansion depth) makes the row fall
3917
+ * back to the whole parsed output with every coerced member labelled. That
3918
+ * was the answer before the per-member reading, and it never publishes an
3919
+ * `unknown`.
3391
3920
  */
3392
- schemaOutputTypeText(value) {
3921
+ schemaContract(value, direction) {
3393
3922
  const node = this.unwrapExpressionNode(value);
3394
3923
  let schemaType;
3395
3924
  if (Node.isCallExpression(node)) {
@@ -3403,13 +3932,201 @@ export class TypeInferrer {
3403
3932
  return null;
3404
3933
  }
3405
3934
  }
3935
+ const location = this.getNodeLocation(node);
3936
+ const where = `${location.file_path}:${location.start_line}`;
3406
3937
  const output = this.schemaOutputType(schemaType, node);
3407
- if (!output) {
3408
- this.log(`Route schema entry at ${this.getNodeLocation(node).file_path}:${this.getNodeLocation(node).start_line} declares no resolvable parsed output (schema library types unavailable, ` +
3409
- 'or a plain JSON-Schema literal); leaving unresolved');
3938
+ const input = direction === 'input' ? this.schemaInputType(schemaType, node) : undefined;
3939
+ if (!output && !input) {
3940
+ this.log(`Route schema entry at ${where} declares no resolvable type (schema library ` +
3941
+ 'types unavailable, or a plain JSON-Schema literal); leaving unresolved');
3942
+ return null;
3943
+ }
3944
+ if (direction === 'output' || !input) {
3945
+ if (direction === 'input') {
3946
+ this.log(`Route schema entry at ${where} declares no input type; publishing its parsed output`);
3947
+ }
3948
+ const text = output ? this.structuralTextFromType(output, node) : null;
3949
+ return text ? { text } : null;
3950
+ }
3951
+ const inputTop = this.topTypePositions(input, node, where);
3952
+ const outputTop = output && inputTop.size > 0 ? this.topTypePositions(output, node, where) : undefined;
3953
+ const coerced = outputTop
3954
+ ? [...inputTop.entries()]
3955
+ .filter(([position]) => !outputTop.has(position))
3956
+ .sort(([a], [b]) => a.localeCompare(b))
3957
+ : [];
3958
+ if (!output || coerced.length === 0) {
3959
+ const text = this.structuralTextFromType(input, node);
3960
+ return text ? { text } : null;
3961
+ }
3962
+ const positions = coerced.map(([position]) => position || '<root>').join(', ');
3963
+ const provenance = coerced.map(([position, kind]) => ({
3964
+ path: position,
3965
+ kind,
3966
+ reason: 'coerced_input',
3967
+ detail: `the schema accepts any value here and coerces it, so the published type ` +
3968
+ `is what parsing produces, not a limit on what a caller may send`,
3969
+ }));
3970
+ const text = this.inputWithCoercedMembers(input, output, coerced.map(([position]) => position), node, where);
3971
+ if (text) {
3972
+ this.log(`Route schema entry at ${where} accepts any value at ${positions} on input; ` +
3973
+ 'publishing the parsed type there and the input everywhere else');
3974
+ return { text, provenance };
3975
+ }
3976
+ const whole = this.structuralTextFromType(output, node);
3977
+ if (!whole) {
3978
+ return null;
3979
+ }
3980
+ this.log(`Route schema entry at ${where} accepts any value at ${positions} on input, and not ` +
3981
+ 'every one could be substituted member by member; publishing its whole parsed output');
3982
+ return { text: whole, provenance };
3983
+ }
3984
+ /**
3985
+ * Print a schema's input with each coerced position replaced by the output's
3986
+ * type at that position (carrick#1105). Null when any position cannot be
3987
+ * substituted: its output is missing or differs across union branches, or
3988
+ * the printer never reached it. A partial substitution would publish an
3989
+ * `unknown`, so it is not an answer.
3990
+ */
3991
+ inputWithCoercedMembers(input, output, coerced, at, where) {
3992
+ const wanted = new Set(coerced);
3993
+ const types = new Map();
3994
+ const ambiguous = new Set();
3995
+ this.walkTypePositions(output, at, where, (type, position, unionPart) => {
3996
+ // A union's parts share its position; the union itself is the type there.
3997
+ if (wanted.has(position) && !unionPart) {
3998
+ const seen = types.get(position);
3999
+ if (!seen) {
4000
+ types.set(position, type);
4001
+ }
4002
+ else if (seen.compilerType !== type.compilerType) {
4003
+ ambiguous.add(position);
4004
+ }
4005
+ }
4006
+ return true;
4007
+ });
4008
+ for (const position of ambiguous) {
4009
+ types.delete(position);
4010
+ }
4011
+ const missing = coerced.filter((position) => !types.has(position));
4012
+ if (missing.length > 0) {
4013
+ this.log(`Route schema entry at ${where}: no single parsed type at ` +
4014
+ `${missing.map((position) => position || '<root>').join(', ')} to substitute ` +
4015
+ 'for the coerced input');
4016
+ return null;
4017
+ }
4018
+ const root = types.get('');
4019
+ if (root) {
4020
+ return this.structuralTextFromType(root, at);
4021
+ }
4022
+ const overrides = { types, applied: new Set(), at };
4023
+ let text;
4024
+ try {
4025
+ text = expandTypeStructural(this.unwrapPromiseType(input), new Set(), 0, overrides);
4026
+ }
4027
+ catch {
4028
+ return null;
4029
+ }
4030
+ const unreached = coerced.filter((position) => !overrides.applied.has(position));
4031
+ if (unreached.length > 0) {
4032
+ this.log(`Route schema entry at ${where}: the printer did not reach coerced ` +
4033
+ `${unreached.join(', ')} (tuple, cycle or depth bound)`);
3410
4034
  return null;
3411
4035
  }
3412
- return this.structuralTextFromType(output, node);
4036
+ return this.isUselessType(text) ? null : text;
4037
+ }
4038
+ /**
4039
+ * Member positions inside `root` that are `any` or `unknown`, keyed by the
4040
+ * member-path notation the provenance entries use (`''` for the root,
4041
+ * `sub.field`, `items<0>` for an array element). Union and intersection
4042
+ * members share their parent's position, so `unknown | undefined` on an
4043
+ * optional key reads as `unknown` there.
4044
+ */
4045
+ topTypePositions(root, at, where) {
4046
+ const found = new Map();
4047
+ this.walkTypePositions(root, at, where, (type, position) => {
4048
+ if (type.isAny() || type.isUnknown()) {
4049
+ found.set(position, type.isAny() ? 'any' : 'unknown');
4050
+ return false;
4051
+ }
4052
+ return true;
4053
+ });
4054
+ return found;
4055
+ }
4056
+ /**
4057
+ * Visit every member position of `root`, in the notation `topTypePositions`
4058
+ * documents. `visit` returns false to stop descending below a position;
4059
+ * `unionPart` is true when the type is a union or intersection part visited
4060
+ * at its parent's position. Callables are not descended into.
4061
+ *
4062
+ * Bounded and cycle-safe. It only has to tell a schema's input apart from its
4063
+ * output, so a subtree past the bound is not compared, and that is logged:
4064
+ * a coercion below the bound prints as its input declares it.
4065
+ */
4066
+ walkTypePositions(root, at, where, visit) {
4067
+ const MAX_DEPTH = 8;
4068
+ const MAX_VISITED = 512;
4069
+ const onPath = new Set();
4070
+ let visited = 0;
4071
+ let bounded = false;
4072
+ const walk = (type, position, depth, unionPart) => {
4073
+ if (depth > MAX_DEPTH || visited > MAX_VISITED) {
4074
+ bounded = true;
4075
+ return;
4076
+ }
4077
+ if (!visit(type, position, unionPart)) {
4078
+ return;
4079
+ }
4080
+ const compilerType = type.compilerType;
4081
+ if (onPath.has(compilerType)) {
4082
+ return;
4083
+ }
4084
+ onPath.add(compilerType);
4085
+ visited += 1;
4086
+ try {
4087
+ const parts = type.isUnion()
4088
+ ? type.getUnionTypes()
4089
+ : type.isIntersection()
4090
+ ? type.getIntersectionTypes()
4091
+ : undefined;
4092
+ if (parts) {
4093
+ for (const part of parts) {
4094
+ walk(part, position, depth + 1, true);
4095
+ }
4096
+ return;
4097
+ }
4098
+ if (type.isArray()) {
4099
+ const element = type.getArrayElementType();
4100
+ if (element) {
4101
+ walk(element, `${position}<0>`, depth + 1, false);
4102
+ }
4103
+ return;
4104
+ }
4105
+ if (!type.isObject() || this.isCallableType(type)) {
4106
+ return;
4107
+ }
4108
+ for (const property of type.getProperties()) {
4109
+ let propertyType;
4110
+ try {
4111
+ propertyType = property.getTypeAtLocation(at);
4112
+ }
4113
+ catch {
4114
+ continue;
4115
+ }
4116
+ const name = property.getName();
4117
+ walk(propertyType, position === '' ? name : `${position}.${name}`, depth + 1, false);
4118
+ }
4119
+ }
4120
+ finally {
4121
+ onPath.delete(compilerType);
4122
+ }
4123
+ };
4124
+ walk(root, '', 0, false);
4125
+ if (bounded) {
4126
+ this.log(`Schema type at ${where} is deeper or wider than the position walk bound ` +
4127
+ `(depth ${MAX_DEPTH}, ${MAX_VISITED} members); members past it are not ` +
4128
+ 'compared for coercion');
4129
+ }
3413
4130
  }
3414
4131
  /**
3415
4132
  * Follow a schema REFERENCE call (`ref('CreateWidget')`) to the schema value
@@ -3479,8 +4196,9 @@ export class TypeInferrer {
3479
4196
  }
3480
4197
  /**
3481
4198
  * The parsed output type of a schema value: the return type of its `parse`
3482
- * method, else a declared `_output` member. Returns undefined when neither
3483
- * carries a usable type — including when the schema library's own types are
4199
+ * method, else a declared `_output` member, else the Standard Schema member
4200
+ * `~standard.types.output` (a library that exposes nothing else). Returns
4201
+ * undefined when none carries a usable type — including when the schema library's own types are
3484
4202
  * unavailable (an uninstalled dependency resolves the schema to `any`), which
3485
4203
  * must abstain rather than publish `any` as a contract.
3486
4204
  */
@@ -3507,13 +4225,62 @@ export class TypeInferrer {
3507
4225
  const outputSymbol = schemaType.getProperty('_output');
3508
4226
  if (outputSymbol) {
3509
4227
  try {
3510
- return usable(outputSymbol.getTypeAtLocation(at));
4228
+ const declared = usable(outputSymbol.getTypeAtLocation(at));
4229
+ if (declared) {
4230
+ return declared;
4231
+ }
3511
4232
  }
3512
4233
  catch {
3513
- return undefined;
4234
+ // fall through to the Standard Schema member
3514
4235
  }
3515
4236
  }
3516
- return undefined;
4237
+ return usable(this.standardSchemaType(schemaType, at, 'output'));
4238
+ }
4239
+ /**
4240
+ * The input type of a schema value, what a caller may send (carrick#1101):
4241
+ * the Standard Schema member `~standard.types.input`, else a declared
4242
+ * `_input` member. Undefined when the schema declares neither.
4243
+ *
4244
+ * Unlike the output, an input of `unknown` is kept: it is a declared fact
4245
+ * (a coercion accepts any value), and `schemaContract` decides what to
4246
+ * publish for it. An `any` that is really an unresolved library still reads
4247
+ * as no input, because an unresolved schema type has no members to read.
4248
+ */
4249
+ schemaInputType(schemaType, at) {
4250
+ const standard = this.standardSchemaType(schemaType, at, 'input');
4251
+ if (standard) {
4252
+ return standard;
4253
+ }
4254
+ const inputSymbol = schemaType.getProperty('_input');
4255
+ if (!inputSymbol) {
4256
+ return undefined;
4257
+ }
4258
+ try {
4259
+ return inputSymbol.getTypeAtLocation(at);
4260
+ }
4261
+ catch {
4262
+ return undefined;
4263
+ }
4264
+ }
4265
+ /**
4266
+ * `~standard.types.<side>` on a schema value: the Standard Schema
4267
+ * specification's library-neutral statement of a schema's input and output
4268
+ * types. `types` is declared optional, so its `undefined` is stripped before
4269
+ * the side is read.
4270
+ */
4271
+ standardSchemaType(schemaType, at, side) {
4272
+ try {
4273
+ const standard = schemaType.getProperty('~standard');
4274
+ const types = standard?.getTypeAtLocation(at).getProperty('types');
4275
+ const member = types
4276
+ ?.getTypeAtLocation(at)
4277
+ .getNonNullableType()
4278
+ .getProperty(side);
4279
+ return member?.getTypeAtLocation(at);
4280
+ }
4281
+ catch {
4282
+ return undefined;
4283
+ }
3517
4284
  }
3518
4285
  /**
3519
4286
  * Find a node by matching expression text near a target line.
@@ -3639,6 +4406,10 @@ export class TypeInferrer {
3639
4406
  normalizeWhitespace(text) {
3640
4407
  return text
3641
4408
  .replace(/\s+/g, ' ')
4409
+ // A member chain broken before its dot (`client\n .list(…)`) reads the
4410
+ // same as `client.list(…)`; a space around `.` / `?.` means nothing
4411
+ // (carrick#1162).
4412
+ .replace(/\s*(\?\.|\.)\s*/g, '$1')
3642
4413
  .replace(/\s*,?\s*([}\)\]])/g, '$1')
3643
4414
  .replace(/([\(\[{])\s+/g, '$1')
3644
4415
  .trim();
@@ -3695,7 +4466,18 @@ export class TypeInferrer {
3695
4466
  // Prefer exact matches, then smallest containing
3696
4467
  const bestDelta = Math.abs(bestRange - (spanEnd - spanStart));
3697
4468
  const currentDelta = Math.abs(currentRange - (spanEnd - spanStart));
3698
- return currentDelta < bestDelta ? current : best;
4469
+ if (currentDelta !== bestDelta) {
4470
+ return currentDelta < bestDelta ? current : best;
4471
+ }
4472
+ // carrick#1166: on a tie keep the INNER node. Without a trailing
4473
+ // semicolon a statement spans exactly the bytes of its expression, and
4474
+ // the statement's own type is `any`, so a registration located by its
4475
+ // span published `any` as its request body. Descendants follow their
4476
+ // ancestors in `getDescendants()` order, so a tied later node inside the
4477
+ // current best is the deeper one.
4478
+ return current.getStart() >= best.getStart() && current.getEnd() <= best.getEnd()
4479
+ ? current
4480
+ : best;
3699
4481
  });
3700
4482
  }
3701
4483
  findCallExpressionAtSpan(sourceFile, spanStart, spanEnd) {