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.
- package/README.md +10 -2
- package/bin/carrick.mjs +4 -1
- package/dist/contract.d.ts +3 -0
- package/dist/contract.js.map +1 -1
- package/dist/init/doctor.d.ts +3 -1
- package/dist/init/doctor.js +33 -6
- package/dist/init/doctor.js.map +1 -1
- package/dist/init/install-id.d.ts +32 -0
- package/dist/init/install-id.js +118 -0
- package/dist/init/install-id.js.map +1 -0
- package/dist/init/mcp.d.ts +59 -5
- package/dist/init/mcp.js +133 -27
- package/dist/init/mcp.js.map +1 -1
- package/dist/init/remove.d.ts +4 -0
- package/dist/init/remove.js +26 -5
- package/dist/init/remove.js.map +1 -1
- package/dist/init/run.d.ts +8 -0
- package/dist/init/run.js +20 -2
- package/dist/init/run.js.map +1 -1
- package/dist/native.d.ts +25 -5
- package/dist/native.js +41 -10
- package/dist/native.js.map +1 -1
- package/dist/render.js +7 -2
- package/dist/render.js.map +1 -1
- 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 +43 -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 +227 -30
- package/sidecar/dist/src/type-inferrer.js +926 -144
- package/sidecar/dist/src/type-structural-expander.d.ts +53 -3
- package/sidecar/dist/src/type-structural-expander.js +108 -19
- 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) {
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2328
|
-
if (!
|
|
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' &&
|
|
2335
|
-
|
|
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
|
-
*
|
|
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
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
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
|
-
|
|
2352
|
-
const
|
|
2353
|
-
|
|
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,
|
|
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
|
|
2361
|
-
* values in the HTTP range count
|
|
2362
|
-
* 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.
|
|
2363
2657
|
*/
|
|
2364
|
-
|
|
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
|
-
|
|
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
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
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
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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;
|
|
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
|
-
:
|
|
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
|
|
3130
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
3180
|
-
|
|
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
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
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
|
|
3250
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
3271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3278
|
-
if (
|
|
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
|
|
3282
|
-
if (
|
|
3283
|
-
return
|
|
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.
|
|
3295
|
-
*
|
|
3296
|
-
* when the
|
|
3297
|
-
*
|
|
3298
|
-
*
|
|
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
|
-
|
|
3809
|
+
routeSchemaEntryValues(registration, part) {
|
|
3301
3810
|
const schemaObject = this.routeSchemaObject(registration);
|
|
3302
3811
|
if (!schemaObject) {
|
|
3303
|
-
return
|
|
3812
|
+
return [];
|
|
3304
3813
|
}
|
|
3305
3814
|
const entry = schemaObject.getProperty(part);
|
|
3306
3815
|
if (!entry || !Node.isPropertyAssignment(entry)) {
|
|
3307
|
-
return
|
|
3816
|
+
return [];
|
|
3308
3817
|
}
|
|
3309
3818
|
const initializer = entry.getInitializer();
|
|
3310
3819
|
if (!initializer) {
|
|
3311
|
-
return
|
|
3820
|
+
return [];
|
|
3312
3821
|
}
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
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.
|
|
3387
|
-
*
|
|
3388
|
-
*
|
|
3389
|
-
*
|
|
3390
|
-
* and
|
|
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
|
-
|
|
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
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
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.
|
|
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
|
|
3483
|
-
*
|
|
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
|
-
|
|
4228
|
+
const declared = usable(outputSymbol.getTypeAtLocation(at));
|
|
4229
|
+
if (declared) {
|
|
4230
|
+
return declared;
|
|
4231
|
+
}
|
|
3511
4232
|
}
|
|
3512
4233
|
catch {
|
|
3513
|
-
|
|
4234
|
+
// fall through to the Standard Schema member
|
|
3514
4235
|
}
|
|
3515
4236
|
}
|
|
3516
|
-
return
|
|
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
|
-
|
|
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) {
|