carrick 0.3.72 → 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 (31) hide show
  1. package/package.json +6 -6
  2. package/sidecar/dist/src/capture/anchors.d.ts +15 -0
  3. package/sidecar/dist/src/capture/anchors.js +70 -5
  4. package/sidecar/dist/src/capture/api.d.ts +39 -4
  5. package/sidecar/dist/src/capture/check-classify.d.ts +2 -0
  6. package/sidecar/dist/src/capture/check-classify.js +28 -0
  7. package/sidecar/dist/src/capture/check-deep.js +1 -1
  8. package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
  9. package/sidecar/dist/src/capture/check-probe.js +16 -0
  10. package/sidecar/dist/src/capture/deep-walk.d.ts +33 -6
  11. package/sidecar/dist/src/capture/deep-walk.js +81 -28
  12. package/sidecar/dist/src/capture/index.js +16 -6
  13. package/sidecar/dist/src/capture/installed-package.d.ts +58 -0
  14. package/sidecar/dist/src/capture/installed-package.js +311 -0
  15. package/sidecar/dist/src/capture/lockfile.d.ts +8 -0
  16. package/sidecar/dist/src/capture/lockfile.js +1 -1
  17. package/sidecar/dist/src/capture/node-builder.d.ts +27 -0
  18. package/sidecar/dist/src/capture/node-builder.js +107 -1
  19. package/sidecar/dist/src/capture/paths-rewrite.d.ts +23 -6
  20. package/sidecar/dist/src/capture/paths-rewrite.js +43 -11
  21. package/sidecar/dist/src/capture/self-check.js +12 -3
  22. package/sidecar/dist/src/capture/unresolved.d.ts +28 -0
  23. package/sidecar/dist/src/capture/unresolved.js +107 -0
  24. package/sidecar/dist/src/type-inferrer.d.ts +108 -11
  25. package/sidecar/dist/src/type-inferrer.js +513 -90
  26. package/sidecar/dist/src/type-structural-expander.d.ts +23 -2
  27. package/sidecar/dist/src/type-structural-expander.js +55 -17
  28. package/sidecar/dist/src/type-text-canonicalizer.d.ts +14 -0
  29. package/sidecar/dist/src/type-text-canonicalizer.js +23 -2
  30. package/sidecar/dist/src/validators.d.ts +10 -0
  31. 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) {
@@ -980,6 +1161,28 @@ export class TypeInferrer {
980
1161
  "of its registration's body schema; publishing the schema's input");
981
1162
  return this.declaredRequestInferredType(request, validatedRead, this.getNodeLocation(node));
982
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
+ }
983
1186
  }
984
1187
  const payloadType = node.getType();
985
1188
  let typeString = typeText(payloadType, node);
@@ -1019,6 +1222,22 @@ export class TypeInferrer {
1019
1222
  typeString = explicitType;
1020
1223
  isExplicit = true;
1021
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
+ }
1022
1241
  // Publication guard (carrick#964): the locator landed on machinery — a
1023
1242
  // callable member of the handler's context, a method reference, a handler
1024
1243
  // binding — and neither a wrapper rule nor a declared type recovered a
@@ -1915,15 +2134,21 @@ export class TypeInferrer {
1915
2134
  * expanded to a structural form (primitives, library types, unresolvable
1916
2135
  * references), so a non-object annotation behaves exactly as before.
1917
2136
  */
1918
- expandAnnotationTypeNode(typeNode) {
2137
+ expandAnnotationTypeNode(typeNode, wire = 'declared') {
1919
2138
  const fallback = typeNode.getText();
1920
2139
  try {
1921
2140
  const annotationType = this.unwrapPromiseType(typeNode.getType());
1922
- const expanded = expandTypeStructural(annotationType);
2141
+ const expanded = expandTypeStructural(annotationType, new Set(), 0, undefined, wire);
1923
2142
  // Only prefer the structural form when expansion actually inlined an
1924
2143
  // object shape; otherwise keep the annotation text (e.g. a bare
1925
- // primitive or a library type the expander leaves by name).
1926
- 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;
1927
2152
  }
1928
2153
  catch {
1929
2154
  return fallback;
@@ -1945,9 +2170,16 @@ export class TypeInferrer {
1945
2170
  * `fallback` is the already-computed type text (post Promise/wrapper unwrap),
1946
2171
  * preserved verbatim when expansion does not inline an object.
1947
2172
  */
1948
- expandResolvedTypeStructural(type, fallback) {
2173
+ expandResolvedTypeStructural(type, fallback, wire = 'declared') {
1949
2174
  try {
1950
- 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
+ }
1951
2183
  // Prefer the expanded form whenever an object got inlined, not only when
1952
2184
  // it leads with `{`. `expandTypeStructural` wraps arrays and unions, so a
1953
2185
  // resolved `Payment[]` or `(Payment | null)[]` renders as `{…}[]` or
@@ -1998,8 +2230,8 @@ export class TypeInferrer {
1998
2230
  * Returns `null` (a logged limitation, never a guess) when no returned
1999
2231
  * expression yields an argument type this may read.
2000
2232
  */
2001
- recoverPayloadFromReturnStatements(func, statedOnly) {
2002
- return this.recoverPayloadFromResponseExpressions(this.responseReturnedExpressions(func), statedOnly);
2233
+ recoverPayloadFromReturnStatements(func, statedOnly, wire) {
2234
+ return this.recoverPayloadFromResponseExpressions(this.responseReturnedExpressions(func), statedOnly, wire);
2003
2235
  }
2004
2236
  /**
2005
2237
  * The response payload carried by a set of response-carrying expressions —
@@ -2012,7 +2244,7 @@ export class TypeInferrer {
2012
2244
  * stated status, and what survives is joined as a union exactly as several
2013
2245
  * return statements are.
2014
2246
  */
2015
- recoverPayloadFromResponseExpressions(expressions, statedOnly) {
2247
+ recoverPayloadFromResponseExpressions(expressions, statedOnly, wire) {
2016
2248
  const candidates = [];
2017
2249
  const returned = expressions.flatMap((expression) => this.expandResponseBranches(expression, 0));
2018
2250
  const serialisers = this.calleesProvenSerialiser(returned);
@@ -2027,7 +2259,7 @@ export class TypeInferrer {
2027
2259
  const stated = this.statedTypeNodeOf(payloadNode);
2028
2260
  if (stated) {
2029
2261
  candidates.push({
2030
- typeString: this.expandAnnotationTypeNode(stated),
2262
+ typeString: this.expandAnnotationTypeNode(stated, wire),
2031
2263
  isExplicit: true,
2032
2264
  node: payloadNode,
2033
2265
  anchorType: this.unwrapPromiseType(stated.getType()),
@@ -2037,7 +2269,7 @@ export class TypeInferrer {
2037
2269
  }
2038
2270
  const payloadType = this.unwrapPromiseType(payloadNode.getType());
2039
2271
  candidates.push({
2040
- typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode)),
2272
+ typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode), wire),
2041
2273
  isExplicit: false,
2042
2274
  node: payloadNode,
2043
2275
  anchorType: payloadType,
@@ -2065,6 +2297,9 @@ export class TypeInferrer {
2065
2297
  const typeString = distinct.map((c) => c.typeString).join(' | ');
2066
2298
  return {
2067
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),
2068
2303
  // Only a contract every surviving branch STATES in source is explicit.
2069
2304
  isExplicit: distinct.every((c) => c.isExplicit),
2070
2305
  node: distinct[0].node,
@@ -2172,7 +2407,7 @@ export class TypeInferrer {
2172
2407
  continue;
2173
2408
  const statesStatus = args
2174
2409
  .slice(1)
2175
- .some((arg) => this.statedStatusCode(arg) !== undefined);
2410
+ .some((arg) => Array.isArray(this.statedStatusCodes(arg)));
2176
2411
  if (!statesStatus)
2177
2412
  continue;
2178
2413
  const key = this.calleeIdentity(call);
@@ -2228,7 +2463,9 @@ export class TypeInferrer {
2228
2463
  const args = call.getArguments().map((a) => this.peelTransparentExpression(a));
2229
2464
  if (args.length === 0)
2230
2465
  return undefined;
2231
- 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')
2232
2469
  return undefined;
2233
2470
  const identity = this.calleeIdentity(call);
2234
2471
  const proven = identity !== undefined && serialisers.has(identity);
@@ -2334,83 +2571,149 @@ export class TypeInferrer {
2334
2571
  return false;
2335
2572
  let statesInit = false;
2336
2573
  for (const property of properties) {
2337
- 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))
2338
2578
  return false;
2339
2579
  const name = property.getName();
2340
2580
  if (!RESPONSE_INIT_MEMBER_NAMES.has(name))
2341
2581
  return false;
2342
- const initializer = property.getInitializer();
2343
- if (!initializer)
2582
+ const value = shorthand ? property.getNameNode() : property.getInitializer();
2583
+ if (!value)
2344
2584
  return false;
2345
2585
  if (name === 'headers') {
2346
2586
  statesInit = true;
2347
2587
  continue;
2348
2588
  }
2349
- if (name === 'status' && Node.isNumericLiteral(initializer)) {
2350
- const value = initializer.getLiteralValue();
2351
- if (Number.isInteger(value) && value >= 100 && value <= 599) {
2352
- statesInit = true;
2353
- }
2589
+ if (name === 'status' && Array.isArray(this.statedStatusCodes(value))) {
2590
+ statesInit = true;
2354
2591
  }
2355
2592
  }
2356
2593
  return statesInit;
2357
2594
  }
2358
2595
  /**
2359
- * True when an argument states a >= 400 status: an options object carrying
2360
- * `status`/`statusCode`, or a bare status code (`send(body, 404)`).
2596
+ * What a response send states about its HTTP status (carrick#1161).
2361
2597
  *
2362
- * Read from the AST first: `{ status: 400 }` in an argument position widens
2363
- * to `{ status: number }`, so the literal only survives syntactically. The
2364
- * 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.
2365
2614
  */
2366
- statesErrorStatus(node) {
2367
- const code = this.statedStatusCode(node);
2368
- 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';
2369
2645
  }
2370
2646
  /**
2371
- * 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.
2372
2650
  *
2373
2651
  * Read from the AST first: `{ status: 400 }` in an argument position widens
2374
2652
  * to `{ status: number }`, so the literal only survives syntactically. The
2375
- * type check behind it catches `as const` and hoisted option objects. Only
2376
- * values in the HTTP range count — an arbitrary number named `status` on a
2377
- * 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.
2378
2657
  */
2379
- statedStatusCode(node) {
2380
- const asStatus = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
2381
- ? value
2382
- : undefined;
2658
+ statedStatusCodes(node) {
2383
2659
  if (Node.isNumericLiteral(node)) {
2384
- return asStatus(node.getLiteralValue());
2660
+ const code = httpStatus(node.getLiteralValue());
2661
+ return code === undefined ? undefined : [code];
2385
2662
  }
2386
2663
  if (Node.isObjectLiteralExpression(node)) {
2387
2664
  for (const name of STATUS_MEMBER_NAMES) {
2388
2665
  const property = node.getProperty(name);
2389
- if (property && Node.isPropertyAssignment(property)) {
2390
- const initializer = property.getInitializer();
2391
- if (initializer && Node.isNumericLiteral(initializer)) {
2392
- const code = asStatus(initializer.getLiteralValue());
2393
- if (code !== undefined)
2394
- return code;
2395
- }
2396
- }
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;
2397
2674
  }
2675
+ return undefined;
2398
2676
  }
2399
2677
  const type = node.getType();
2678
+ const fromType = statusCodesOfType(type);
2679
+ if (fromType)
2680
+ return fromType;
2681
+ if (type.isNumber())
2682
+ return 'variable';
2400
2683
  for (const name of STATUS_MEMBER_NAMES) {
2401
2684
  const property = type.getProperty(name);
2402
2685
  const declaration = property?.getDeclarations()[0];
2403
2686
  if (!property || !declaration)
2404
2687
  continue;
2405
- const propertyType = property.getTypeAtLocation(declaration);
2406
- if (propertyType.isNumberLiteral()) {
2407
- const code = asStatus(propertyType.getLiteralValue());
2408
- if (code !== undefined)
2409
- return code;
2410
- }
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';
2411
2694
  }
2412
2695
  return undefined;
2413
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
+ }
2414
2717
  /**
2415
2718
  * Peel the wrappers that do not change an expression's payload: parentheses
2416
2719
  * and `await`. Unlike `unwrapExpressionNode` this KEEPS `as`/`satisfies`,
@@ -3011,10 +3314,16 @@ export class TypeInferrer {
3011
3314
  return decl;
3012
3315
  }
3013
3316
  // `getDefinitionNodes()` may return the name identifier of a `const h = …`
3014
- // 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();
3015
3322
  const varDecl = Node.isVariableDeclaration(decl)
3016
3323
  ? decl
3017
- : decl.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
3324
+ : parent && Node.isVariableDeclaration(parent) && parent.getNameNode() === decl
3325
+ ? parent
3326
+ : undefined;
3018
3327
  if (varDecl) {
3019
3328
  const initializer = varDecl.getInitializer();
3020
3329
  if (initializer) {
@@ -3327,6 +3636,105 @@ export class TypeInferrer {
3327
3636
  }
3328
3637
  return values;
3329
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)
3682
+ continue;
3683
+ const candidates = [];
3684
+ const callee = call.getExpression();
3685
+ if (Node.isPropertyAccessExpression(callee)) {
3686
+ candidates.push(callee.getExpression());
3687
+ }
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)) {
3696
+ continue;
3697
+ }
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
+ }
3330
3738
  /**
3331
3739
  * The request contract behind a VALIDATED READ: a call inside a registered
3332
3740
  * handler whose type is exactly the parsed output of a body schema the
@@ -3998,6 +4406,10 @@ export class TypeInferrer {
3998
4406
  normalizeWhitespace(text) {
3999
4407
  return text
4000
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')
4001
4413
  .replace(/\s*,?\s*([}\)\]])/g, '$1')
4002
4414
  .replace(/([\(\[{])\s+/g, '$1')
4003
4415
  .trim();
@@ -4054,7 +4466,18 @@ export class TypeInferrer {
4054
4466
  // Prefer exact matches, then smallest containing
4055
4467
  const bestDelta = Math.abs(bestRange - (spanEnd - spanStart));
4056
4468
  const currentDelta = Math.abs(currentRange - (spanEnd - spanStart));
4057
- 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;
4058
4481
  });
4059
4482
  }
4060
4483
  findCallExpressionAtSpan(sourceFile, spanStart, spanEnd) {