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.
- package/package.json +6 -6
- package/sidecar/dist/src/capture/anchors.d.ts +15 -0
- package/sidecar/dist/src/capture/anchors.js +70 -5
- package/sidecar/dist/src/capture/api.d.ts +39 -4
- package/sidecar/dist/src/capture/check-classify.d.ts +2 -0
- package/sidecar/dist/src/capture/check-classify.js +28 -0
- package/sidecar/dist/src/capture/check-deep.js +1 -1
- package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
- package/sidecar/dist/src/capture/check-probe.js +16 -0
- package/sidecar/dist/src/capture/deep-walk.d.ts +33 -6
- package/sidecar/dist/src/capture/deep-walk.js +81 -28
- package/sidecar/dist/src/capture/index.js +16 -6
- package/sidecar/dist/src/capture/installed-package.d.ts +58 -0
- package/sidecar/dist/src/capture/installed-package.js +311 -0
- package/sidecar/dist/src/capture/lockfile.d.ts +8 -0
- package/sidecar/dist/src/capture/lockfile.js +1 -1
- package/sidecar/dist/src/capture/node-builder.d.ts +27 -0
- package/sidecar/dist/src/capture/node-builder.js +107 -1
- package/sidecar/dist/src/capture/paths-rewrite.d.ts +23 -6
- package/sidecar/dist/src/capture/paths-rewrite.js +43 -11
- package/sidecar/dist/src/capture/self-check.js +12 -3
- package/sidecar/dist/src/capture/unresolved.d.ts +28 -0
- package/sidecar/dist/src/capture/unresolved.js +107 -0
- package/sidecar/dist/src/type-inferrer.d.ts +108 -11
- package/sidecar/dist/src/type-inferrer.js +513 -90
- package/sidecar/dist/src/type-structural-expander.d.ts +23 -2
- package/sidecar/dist/src/type-structural-expander.js +55 -17
- package/sidecar/dist/src/type-text-canonicalizer.d.ts +14 -0
- package/sidecar/dist/src/type-text-canonicalizer.js +23 -2
- package/sidecar/dist/src/validators.d.ts +10 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2343
|
-
if (!
|
|
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' &&
|
|
2350
|
-
|
|
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
|
-
*
|
|
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
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
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
|
-
|
|
2367
|
-
const
|
|
2368
|
-
|
|
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,
|
|
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
|
|
2376
|
-
* values in the HTTP range count
|
|
2377
|
-
* domain object (`{ status: 2 }`)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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;
|
|
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
|
-
:
|
|
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
|
-
|
|
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) {
|