carrick 0.3.112 → 0.3.113
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/dist/update.js +3 -0
- package/dist/update.js.map +1 -1
- package/package.json +62 -7
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/scripts/install-scanned-deps.sh +539 -0
- package/sidecar/dist/src/capture/api.d.ts +22 -1
- package/sidecar/dist/src/capture/check.d.ts +32 -0
- package/sidecar/dist/src/capture/check.js +74 -5
- package/sidecar/dist/src/capture/checkout.d.ts +38 -0
- package/sidecar/dist/src/capture/checkout.js +139 -0
- package/sidecar/dist/src/capture/index.d.ts +1 -0
- package/sidecar/dist/src/capture/index.js +22 -7
- package/sidecar/dist/src/capture/machinery.js +1 -30
- package/sidecar/dist/src/capture/project-references.js +2 -1
- package/sidecar/dist/src/origin.js +1 -29
- package/sidecar/dist/src/project-loader.d.ts +6 -0
- package/sidecar/dist/src/project-loader.js +64 -1
- package/sidecar/dist/src/type-inferrer.d.ts +174 -0
- package/sidecar/dist/src/type-inferrer.js +602 -88
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* function's return type.
|
|
19
19
|
*/
|
|
20
20
|
import * as path from 'node:path';
|
|
21
|
-
import { Node, SyntaxKind, ts, } from 'ts-morph';
|
|
21
|
+
import { Node, SyntaxKind, VariableDeclarationKind, ts, } from 'ts-morph';
|
|
22
22
|
import { typeToStringAsDeclared } from './print-type.js';
|
|
23
23
|
import { validateInferRequestItem } from './validators.js';
|
|
24
24
|
import { notePrintedType, PrintedTypes } from './printed-names.js';
|
|
@@ -172,6 +172,15 @@ const RESPONSE_HELPER_MAX_DEPTH = 4;
|
|
|
172
172
|
* the branch an error path, whose shape is not the endpoint's contract.
|
|
173
173
|
*/
|
|
174
174
|
const STATUS_MEMBER_NAMES = ['status', 'statusCode'];
|
|
175
|
+
/** The provenance sentence for each reason a branch was not read. */
|
|
176
|
+
const SERIALISED_BODY_UNREAD_DETAIL = {
|
|
177
|
+
prebuilt: 'A return hands back a status and body built somewhere else, such as a variable or a field of another result, so the value in its body was not read.',
|
|
178
|
+
not_serialised: "A return's body string is not made by JSON.stringify, so the value it holds was not read.",
|
|
179
|
+
builder: 'A return calls a response helper that could not be followed to the one value it serialises, so what it sends was not read.',
|
|
180
|
+
top_type: 'A return serialises a value typed any or unknown, so the shape of its body is not known.',
|
|
181
|
+
};
|
|
182
|
+
/** `any` or `unknown` as a type in printed type text, not as a member name or a string. */
|
|
183
|
+
const TOP_TYPE_TOKEN = /(?<![\w$'"`])(?:any|unknown)(?![\w$'"`?:])/;
|
|
175
184
|
/**
|
|
176
185
|
* The items whose whitespace-collapsed texts differ, first of each kept, in
|
|
177
186
|
* order: how a joined union drops a member it already has.
|
|
@@ -789,6 +798,13 @@ export class TypeInferrer {
|
|
|
789
798
|
// whose contract lives in the yield position. Unwrap it to the yield type so
|
|
790
799
|
// both forms reduce to `Order` before structural expansion / rule matching.
|
|
791
800
|
const awaitedType = this.unwrapAsyncIterableType(this.unwrapPromiseType(returnType));
|
|
801
|
+
// carrick#2200: a `{ statusCode, body: string }` return is transport the
|
|
802
|
+
// handler built itself; its contract is the value it serialised into
|
|
803
|
+
// `body`. Decided before the extraction config, whose rule for the
|
|
804
|
+
// envelope would read `body` and publish `string`.
|
|
805
|
+
if (this.serialisedBodyEnvelopeEngages(awaitedType, func)) {
|
|
806
|
+
return this.inferSerialisedBodyEnvelope(request, func);
|
|
807
|
+
}
|
|
792
808
|
const unwrapResult = this.unwrapTypeWithConfig(awaitedType, func, extractionConfig);
|
|
793
809
|
if (unwrapResult.wasUnwrapped && !unwrapResult.verifiedMachinery) {
|
|
794
810
|
typeString = unwrapResult.typeString;
|
|
@@ -1525,17 +1541,13 @@ export class TypeInferrer {
|
|
|
1525
1541
|
}
|
|
1526
1542
|
this.log(`Response locator at ${request.file_path}:${request.line_number} names an error ` +
|
|
1527
1543
|
'or redirect send and its handler sends no success body; abstaining');
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
'or carries no body a JSON contract can describe, so it publishes no success body',
|
|
1536
|
-
},
|
|
1537
|
-
];
|
|
1538
|
-
return abstain;
|
|
1544
|
+
return this.decidedAbstain(request, this.getNodeLocation(site), {
|
|
1545
|
+
path: '',
|
|
1546
|
+
kind: 'unknown',
|
|
1547
|
+
reason: 'no_success_payload',
|
|
1548
|
+
detail: "every response this route's handler sends states an error or redirect status, " +
|
|
1549
|
+
'or carries no body a JSON contract can describe, so it publishes no success body',
|
|
1550
|
+
});
|
|
1539
1551
|
}
|
|
1540
1552
|
if (recovered && recovered.nodes.some((node) => contains(site, node))) {
|
|
1541
1553
|
return this.inferredFromRecoveredPayload(request, recovered);
|
|
@@ -1763,18 +1775,14 @@ export class TypeInferrer {
|
|
|
1763
1775
|
this.log(`Call result at ${request.file_path}:${request.line_number} is only ever read as ` +
|
|
1764
1776
|
`members of ${typeText(callExpr.getType(), callExpr)}; a part of a payload is not ` +
|
|
1765
1777
|
'a payload, so this site states no response contract');
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
'payload a caller receives',
|
|
1775
|
-
},
|
|
1776
|
-
];
|
|
1777
|
-
return abstain;
|
|
1778
|
+
return this.decidedAbstain(request, this.getNodeLocation(callExpr), {
|
|
1779
|
+
path: '',
|
|
1780
|
+
kind: 'unknown',
|
|
1781
|
+
reason: 'projected_value_only',
|
|
1782
|
+
detail: "every read of this call's result takes a member out of it and none reads the " +
|
|
1783
|
+
'value itself, so what this site states is a part of a payload rather than the ' +
|
|
1784
|
+
'payload a caller receives',
|
|
1785
|
+
});
|
|
1778
1786
|
}
|
|
1779
1787
|
typeString = this.unwrapPromise(typeString, returnType);
|
|
1780
1788
|
// #336: consumer analogue of the #306 producer anchor. Anchor on the
|
|
@@ -2315,17 +2323,13 @@ export class TypeInferrer {
|
|
|
2315
2323
|
else if (inCall.kind === 'no_body') {
|
|
2316
2324
|
this.log(`Request locator at ${request.file_path}:${request.line_number} is a request config ` +
|
|
2317
2325
|
'that carries no body member; the call sends no request body');
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
'takes its body, so the call sends no request body',
|
|
2326
|
-
},
|
|
2327
|
-
];
|
|
2328
|
-
return abstain;
|
|
2326
|
+
return this.decidedAbstain(request, this.getNodeLocation(unwrapped), {
|
|
2327
|
+
path: '',
|
|
2328
|
+
kind: 'unknown',
|
|
2329
|
+
reason: 'no_request_body',
|
|
2330
|
+
detail: `the call's request config sets no '${inCall.member}' member, which is where the call ` +
|
|
2331
|
+
'takes its body, so the call sends no request body',
|
|
2332
|
+
});
|
|
2329
2333
|
}
|
|
2330
2334
|
// A `fetch` body is almost always `JSON.stringify(payload)`, whose own type
|
|
2331
2335
|
// is the useless `string`. Drill to the serialized argument so the consumer
|
|
@@ -2380,17 +2384,13 @@ export class TypeInferrer {
|
|
|
2380
2384
|
if (nonBodyPart !== undefined) {
|
|
2381
2385
|
this.log(`Request locator at ${request.file_path}:${request.line_number} reads the validated ` +
|
|
2382
2386
|
`'${nonBodyPart}' part, which is not a body; the route declares no request body here`);
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
'so the route states no body contract here',
|
|
2391
|
-
},
|
|
2392
|
-
];
|
|
2393
|
-
return abstain;
|
|
2387
|
+
return this.decidedAbstain(request, this.getNodeLocation(node), {
|
|
2388
|
+
path: '',
|
|
2389
|
+
kind: 'unknown',
|
|
2390
|
+
reason: 'no_request_body',
|
|
2391
|
+
detail: `the located read is the '${nonBodyPart}' part a validator binds, not a request body, ` +
|
|
2392
|
+
'so the route states no body contract here',
|
|
2393
|
+
});
|
|
2394
2394
|
}
|
|
2395
2395
|
}
|
|
2396
2396
|
const payloadType = node.getType();
|
|
@@ -2882,16 +2882,12 @@ export class TypeInferrer {
|
|
|
2882
2882
|
transportAbstain(request, callExpr, detail = "what this call's result carries is transport that the service's wrapper " +
|
|
2883
2883
|
'rules verify and read no payload out of (a library response object around the body), ' +
|
|
2884
2884
|
'so this site states no response contract') {
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
detail,
|
|
2892
|
-
},
|
|
2893
|
-
];
|
|
2894
|
-
return abstain;
|
|
2885
|
+
return this.decidedAbstain(request, this.getNodeLocation(callExpr), {
|
|
2886
|
+
path: '',
|
|
2887
|
+
kind: 'unknown',
|
|
2888
|
+
reason: 'machinery_envelope',
|
|
2889
|
+
detail,
|
|
2890
|
+
});
|
|
2895
2891
|
}
|
|
2896
2892
|
/**
|
|
2897
2893
|
* `Future<T>` -> `T` for a thenable, read off the await protocol rather
|
|
@@ -4215,29 +4211,44 @@ export class TypeInferrer {
|
|
|
4215
4211
|
const payloadNode = this.responseHelperPayloadNode(expression, 0, statedOnly, serialisers);
|
|
4216
4212
|
if (!payloadNode)
|
|
4217
4213
|
continue;
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
continue;
|
|
4232
|
-
}
|
|
4233
|
-
const payloadType = this.unwrapPromiseType(payloadNode.getType());
|
|
4234
|
-
candidates.push({
|
|
4235
|
-
typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode), wire),
|
|
4236
|
-
isExplicit: false,
|
|
4237
|
-
node: payloadNode,
|
|
4238
|
-
anchorType: payloadType,
|
|
4239
|
-
});
|
|
4214
|
+
candidates.push(this.payloadCandidateOf(payloadNode, wire));
|
|
4215
|
+
}
|
|
4216
|
+
return this.joinPayloadCandidates(candidates);
|
|
4217
|
+
}
|
|
4218
|
+
/** How one payload node reads: its stated annotation, else its resolved type. */
|
|
4219
|
+
payloadCandidateOf(payloadNode, wire) {
|
|
4220
|
+
// A `satisfies X` / `as X` on the argument states the contract in source:
|
|
4221
|
+
// read the ANNOTATION, not the literal the compiler widened it from.
|
|
4222
|
+
// `expandAnnotationTypeNode` keeps the bare name when the declaration is
|
|
4223
|
+
// not resolvable, which is the honest answer on a bare checkout.
|
|
4224
|
+
const stated = this.statedTypeNodeOf(payloadNode);
|
|
4225
|
+
if (stated) {
|
|
4226
|
+
return this.statedPayloadCandidate(payloadNode, stated, wire);
|
|
4240
4227
|
}
|
|
4228
|
+
const payloadType = this.unwrapPromiseType(payloadNode.getType());
|
|
4229
|
+
return {
|
|
4230
|
+
typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode), wire),
|
|
4231
|
+
isExplicit: false,
|
|
4232
|
+
node: payloadNode,
|
|
4233
|
+
anchorType: payloadType,
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
/** A payload whose contract a type node in the source states. */
|
|
4237
|
+
statedPayloadCandidate(payloadNode, stated, wire) {
|
|
4238
|
+
return {
|
|
4239
|
+
typeString: this.expandAnnotationTypeNode(stated, wire),
|
|
4240
|
+
isExplicit: true,
|
|
4241
|
+
node: payloadNode,
|
|
4242
|
+
anchorType: this.unwrapPromiseType(stated.getType()),
|
|
4243
|
+
statedTypeNode: stated,
|
|
4244
|
+
};
|
|
4245
|
+
}
|
|
4246
|
+
/**
|
|
4247
|
+
* Join the payloads of several response branches into one contract, as
|
|
4248
|
+
* several return statements are joined: an empty object drops beside a
|
|
4249
|
+
* branch with members, equal texts dedupe, and the rest is a union.
|
|
4250
|
+
*/
|
|
4251
|
+
joinPayloadCandidates(candidates) {
|
|
4241
4252
|
if (candidates.length === 0) {
|
|
4242
4253
|
return null;
|
|
4243
4254
|
}
|
|
@@ -4268,6 +4279,516 @@ export class TypeInferrer {
|
|
|
4268
4279
|
statedTypeNode: distinct.length === 1 ? distinct[0].statedTypeNode : undefined,
|
|
4269
4280
|
};
|
|
4270
4281
|
}
|
|
4282
|
+
// ===========================================================================
|
|
4283
|
+
// Serialised-body envelopes (carrick#2200)
|
|
4284
|
+
// ===========================================================================
|
|
4285
|
+
/**
|
|
4286
|
+
* True when a handler's awaited return, or a member of its union, is a
|
|
4287
|
+
* serialised-body envelope: an object with a numeric `statusCode` or
|
|
4288
|
+
* `status` member and a `body` member typed `string`. Either member may be
|
|
4289
|
+
* optional, as a platform's result type declares them.
|
|
4290
|
+
*
|
|
4291
|
+
* HTTP vocabulary only, the class `RESPONSE_INIT_MEMBER_NAMES` is. A `body`
|
|
4292
|
+
* typed as an object is not an envelope: a runtime that serialises the
|
|
4293
|
+
* handler's return value sends that object as the payload, so the shape is
|
|
4294
|
+
* ambiguous and keeps the ordinary reading.
|
|
4295
|
+
*/
|
|
4296
|
+
typeIsSerialisedBodyEnvelope(type, at) {
|
|
4297
|
+
const members = type.isUnion() ? type.getUnionTypes() : [type];
|
|
4298
|
+
return members.some((member) => {
|
|
4299
|
+
if (!member.isObject() || member.isArray() || member.getCallSignatures().length > 0) {
|
|
4300
|
+
return false;
|
|
4301
|
+
}
|
|
4302
|
+
const body = this.envelopeMemberType(member, 'body', at);
|
|
4303
|
+
if (!body || !(body.isString() || body.isStringLiteral()))
|
|
4304
|
+
return false;
|
|
4305
|
+
return STATUS_MEMBER_NAMES.some((name) => {
|
|
4306
|
+
const status = this.envelopeMemberType(member, name, at);
|
|
4307
|
+
return status !== undefined && (status.isNumber() || statusCodesOfType(status) !== undefined);
|
|
4308
|
+
});
|
|
4309
|
+
});
|
|
4310
|
+
}
|
|
4311
|
+
/** A member's type with `undefined` and `null` removed, or `undefined` if absent. */
|
|
4312
|
+
envelopeMemberType(type, name, at) {
|
|
4313
|
+
const property = type.getProperty(name);
|
|
4314
|
+
if (!property)
|
|
4315
|
+
return undefined;
|
|
4316
|
+
return property.getTypeAtLocation(at).getNonNullableType();
|
|
4317
|
+
}
|
|
4318
|
+
/**
|
|
4319
|
+
* True when a handler's return is a serialised-body envelope AND the source
|
|
4320
|
+
* shows the body is serialised, so the envelope reading applies:
|
|
4321
|
+
* - a returned branch builds an envelope whose `body` is `JSON.stringify`
|
|
4322
|
+
* of a value, inline, through a `const`, or in a builder the reading can
|
|
4323
|
+
* follow (`branchShowsSerialisation`); or
|
|
4324
|
+
* - the envelope type is declared outside the repository (a platform's
|
|
4325
|
+
* result type) with every member optional. Nothing else returns such a
|
|
4326
|
+
* type, so a value of it returned as-is is still transport. The repo's
|
|
4327
|
+
* own all-optional types (`Partial<Article>`) engage only on evidence.
|
|
4328
|
+
*
|
|
4329
|
+
* A domain object that merely has `status` and `body` members (an article,
|
|
4330
|
+
* a message) shows neither and keeps the ordinary reading.
|
|
4331
|
+
*/
|
|
4332
|
+
serialisedBodyEnvelopeEngages(type, func) {
|
|
4333
|
+
if (!this.typeIsSerialisedBodyEnvelope(type, func))
|
|
4334
|
+
return false;
|
|
4335
|
+
const members = type.isUnion() ? type.getUnionTypes() : [type];
|
|
4336
|
+
const platformShaped = members.some((member) => {
|
|
4337
|
+
if (!this.typeIsSerialisedBodyEnvelope(member, func))
|
|
4338
|
+
return false;
|
|
4339
|
+
const properties = member.getProperties();
|
|
4340
|
+
return properties.every((property) => property.hasFlags(ts.SymbolFlags.Optional) &&
|
|
4341
|
+
this.symbolIsLibOrExternalOrigin(property));
|
|
4342
|
+
});
|
|
4343
|
+
if (platformShaped)
|
|
4344
|
+
return true;
|
|
4345
|
+
return this.envelopeBranches(func).some((branch) => this.branchShowsSerialisation(branch, func, 0));
|
|
4346
|
+
}
|
|
4347
|
+
/**
|
|
4348
|
+
* True when a returned branch builds an envelope from a serialised value:
|
|
4349
|
+
* a literal whose `body` is `JSON.stringify(...)`, a call to a builder the
|
|
4350
|
+
* reading can follow, or a call handing on one such envelope.
|
|
4351
|
+
*/
|
|
4352
|
+
branchShowsSerialisation(expression, scope, depth) {
|
|
4353
|
+
if (depth > RESPONSE_HELPER_MAX_DEPTH)
|
|
4354
|
+
return false;
|
|
4355
|
+
const node = this.peelTransparentExpression(expression);
|
|
4356
|
+
if (Node.isObjectLiteralExpression(node)) {
|
|
4357
|
+
return this.serialisedBodyArgument(node, scope) !== undefined;
|
|
4358
|
+
}
|
|
4359
|
+
if (!Node.isCallExpression(node))
|
|
4360
|
+
return false;
|
|
4361
|
+
if (this.envelopeBuilderOf(node) !== undefined)
|
|
4362
|
+
return true;
|
|
4363
|
+
return this.handedOnArguments(node).some((arg) => this.branchShowsSerialisation(arg, scope, depth + 1));
|
|
4364
|
+
}
|
|
4365
|
+
/**
|
|
4366
|
+
* The arguments a call could hand back as its result: every argument but
|
|
4367
|
+
* an object literal, which is the call's input (`create({ body:
|
|
4368
|
+
* JSON.stringify(doc) })` stores a document, it does not respond with it),
|
|
4369
|
+
* and but a function, whose returns are read as the call's callbacks.
|
|
4370
|
+
*/
|
|
4371
|
+
handedOnArguments(call) {
|
|
4372
|
+
return call
|
|
4373
|
+
.getArguments()
|
|
4374
|
+
.map((arg) => this.peelTransparentExpression(arg))
|
|
4375
|
+
.filter((arg) => !Node.isObjectLiteralExpression(arg) &&
|
|
4376
|
+
!Node.isArrowFunction(arg) &&
|
|
4377
|
+
!Node.isFunctionExpression(arg));
|
|
4378
|
+
}
|
|
4379
|
+
/**
|
|
4380
|
+
* The returned branches of an envelope handler: each returned expression,
|
|
4381
|
+
* conditional sides split. A returned call whose callbacks return values
|
|
4382
|
+
* (`settle().then(ok, fail)`) is carried by those returns, plus every
|
|
4383
|
+
* envelope it is handed directly (`withFallback(() => a, b)` can answer
|
|
4384
|
+
* `b`); a call whose callbacks return nothing stays a branch of its own.
|
|
4385
|
+
*/
|
|
4386
|
+
envelopeBranches(func) {
|
|
4387
|
+
const carriedByCallbacks = new Set(this.returnedExpressions(func).filter((expression) => {
|
|
4388
|
+
const call = this.peelTransparentExpression(expression);
|
|
4389
|
+
if (!Node.isCallExpression(call))
|
|
4390
|
+
return false;
|
|
4391
|
+
return call.getArguments().some((arg) => {
|
|
4392
|
+
const callback = this.peelTransparentExpression(arg);
|
|
4393
|
+
return ((Node.isArrowFunction(callback) || Node.isFunctionExpression(callback)) &&
|
|
4394
|
+
this.returnedExpressions(callback).length > 0);
|
|
4395
|
+
});
|
|
4396
|
+
}));
|
|
4397
|
+
const handedEnvelopes = [...carriedByCallbacks].flatMap((expression) => {
|
|
4398
|
+
const call = this.peelTransparentExpression(expression);
|
|
4399
|
+
if (!Node.isCallExpression(call))
|
|
4400
|
+
return [];
|
|
4401
|
+
return call
|
|
4402
|
+
.getArguments()
|
|
4403
|
+
.map((arg) => this.peelTransparentExpression(arg))
|
|
4404
|
+
.filter((arg) => !Node.isArrowFunction(arg) &&
|
|
4405
|
+
!Node.isFunctionExpression(arg) &&
|
|
4406
|
+
this.typeIsSerialisedBodyEnvelope(arg.getType(), arg));
|
|
4407
|
+
});
|
|
4408
|
+
return [
|
|
4409
|
+
...this.responseReturnedExpressions(func).filter((expression) => !carriedByCallbacks.has(expression)),
|
|
4410
|
+
...handedEnvelopes,
|
|
4411
|
+
].flatMap((expression) => this.expandResponseBranches(expression, 0));
|
|
4412
|
+
}
|
|
4413
|
+
/**
|
|
4414
|
+
* The `function_return` answer for a handler that returns a serialised-body
|
|
4415
|
+
* envelope: the union of the values its success branches serialised.
|
|
4416
|
+
*
|
|
4417
|
+
* Each returned branch is read by `readEnvelopeBranch`. Error and redirect
|
|
4418
|
+
* branches contribute nothing (carrick#1161). If any branch cannot be read,
|
|
4419
|
+
* the answer is `unknown` with the decided reason `serialised_body_unread`
|
|
4420
|
+
* and one sentence per cause: the branches that did read are part of the
|
|
4421
|
+
* contract, and neither the envelope nor its `string` body is any of it.
|
|
4422
|
+
*/
|
|
4423
|
+
inferSerialisedBodyEnvelope(request, func) {
|
|
4424
|
+
const wire = this.wireFormatFor(request);
|
|
4425
|
+
const branches = this.envelopeBranches(func);
|
|
4426
|
+
const candidates = [];
|
|
4427
|
+
const causes = new Set();
|
|
4428
|
+
for (const branch of branches) {
|
|
4429
|
+
const read = this.readEnvelopeBranch(branch, func, wire, 0);
|
|
4430
|
+
if (read === 'dropped')
|
|
4431
|
+
continue;
|
|
4432
|
+
if ('unread' in read)
|
|
4433
|
+
causes.add(read.unread);
|
|
4434
|
+
else
|
|
4435
|
+
candidates.push(read);
|
|
4436
|
+
}
|
|
4437
|
+
const location = this.getNodeLocation(func);
|
|
4438
|
+
if (causes.size > 0) {
|
|
4439
|
+
this.log(`Return at ${request.file_path}:${request.line_number} is a serialised-body ` +
|
|
4440
|
+
`envelope and a returned branch was not read (${[...causes].join(', ')}); abstaining`);
|
|
4441
|
+
return this.decidedAbstain(request, location, {
|
|
4442
|
+
path: '',
|
|
4443
|
+
kind: 'unknown',
|
|
4444
|
+
reason: 'serialised_body_unread',
|
|
4445
|
+
detail: [...causes].map((cause) => SERIALISED_BODY_UNREAD_DETAIL[cause]).join(' '),
|
|
4446
|
+
});
|
|
4447
|
+
}
|
|
4448
|
+
const recovered = this.joinPayloadCandidates(candidates);
|
|
4449
|
+
if (!recovered) {
|
|
4450
|
+
return this.decidedAbstain(request, location, {
|
|
4451
|
+
path: '',
|
|
4452
|
+
kind: 'unknown',
|
|
4453
|
+
reason: 'no_success_payload',
|
|
4454
|
+
detail: 'every status and body this handler returns states an error or redirect status, so it publishes no success body',
|
|
4455
|
+
});
|
|
4456
|
+
}
|
|
4457
|
+
return this.inferredFromRecoveredPayload(request, recovered);
|
|
4458
|
+
}
|
|
4459
|
+
/** An `unknown` answer the inferrer decided, carrying its reason at the root. */
|
|
4460
|
+
decidedAbstain(request, location, reason) {
|
|
4461
|
+
const abstain = this.createInferredType(request, 'unknown', false, location);
|
|
4462
|
+
abstain.any_provenance = [reason];
|
|
4463
|
+
return abstain;
|
|
4464
|
+
}
|
|
4465
|
+
/**
|
|
4466
|
+
* What one returned branch of an envelope handler serialised:
|
|
4467
|
+
* - an envelope object literal: the argument of the `JSON.stringify` that
|
|
4468
|
+
* produced its `body`, inline or through a `const` in the same function;
|
|
4469
|
+
* its status member decides the branch;
|
|
4470
|
+
* - a call to a response builder this repository declares
|
|
4471
|
+
* (`envelopeBuilderOf`): the argument the builder serialises; the
|
|
4472
|
+
* argument or literal its status member takes decides the branch;
|
|
4473
|
+
* - any other call handing on exactly one envelope argument (never an
|
|
4474
|
+
* object literal, which is the call's input): that
|
|
4475
|
+
* envelope is read in its place. Its own stated status decides the
|
|
4476
|
+
* branch; only when it states none does a status the outer call's
|
|
4477
|
+
* arguments state decide (`log(ctx, 503, "code", prebuilt)`).
|
|
4478
|
+
* Anything else, and a payload holding `any` or `unknown`, is unread, with
|
|
4479
|
+
* its cause.
|
|
4480
|
+
*/
|
|
4481
|
+
readEnvelopeBranch(expression, scope, wire, depth) {
|
|
4482
|
+
if (depth > RESPONSE_HELPER_MAX_DEPTH)
|
|
4483
|
+
return { unread: 'builder' };
|
|
4484
|
+
const node = this.peelTransparentExpression(expression);
|
|
4485
|
+
if (Node.isObjectLiteralExpression(node)) {
|
|
4486
|
+
const status = this.envelopeLiteralStatus(node);
|
|
4487
|
+
if (status === 'error' || status === 'redirect')
|
|
4488
|
+
return 'dropped';
|
|
4489
|
+
const payload = this.serialisedBodyArgument(node, scope);
|
|
4490
|
+
return payload ? this.envelopePayloadCandidate(payload, wire) : { unread: 'not_serialised' };
|
|
4491
|
+
}
|
|
4492
|
+
if (!Node.isCallExpression(node))
|
|
4493
|
+
return { unread: 'prebuilt' };
|
|
4494
|
+
const builder = this.envelopeBuilderOf(node);
|
|
4495
|
+
if (builder === 'failure')
|
|
4496
|
+
return 'dropped';
|
|
4497
|
+
if (builder) {
|
|
4498
|
+
const status = this.builderCallStatus(node, builder);
|
|
4499
|
+
if (status === 'error' || status === 'redirect')
|
|
4500
|
+
return 'dropped';
|
|
4501
|
+
const argument = node.getArguments()[builder.payloadParameter];
|
|
4502
|
+
if (!argument)
|
|
4503
|
+
return { unread: 'builder' };
|
|
4504
|
+
return this.builderPayloadCandidate(node, builder, argument, wire);
|
|
4505
|
+
}
|
|
4506
|
+
const envelopes = this.handedOnArguments(node).filter((arg) => this.typeIsSerialisedBodyEnvelope(arg.getType(), arg));
|
|
4507
|
+
if (envelopes.length > 1)
|
|
4508
|
+
return { unread: 'builder' };
|
|
4509
|
+
const inner = envelopes[0];
|
|
4510
|
+
const innerStatus = inner ? this.envelopeOwnStatus(inner) : 'undecided';
|
|
4511
|
+
if (innerStatus === 'undecided' || innerStatus === 'variable') {
|
|
4512
|
+
const outer = this.responseSiteStatus(node);
|
|
4513
|
+
if (outer === 'error' || outer === 'redirect')
|
|
4514
|
+
return 'dropped';
|
|
4515
|
+
}
|
|
4516
|
+
if (!inner)
|
|
4517
|
+
return { unread: 'builder' };
|
|
4518
|
+
return this.readEnvelopeBranch(inner, scope, wire, depth + 1);
|
|
4519
|
+
}
|
|
4520
|
+
/** The status a builder call sends: its status argument, or the builder's literal. */
|
|
4521
|
+
builderCallStatus(call, builder) {
|
|
4522
|
+
if (builder.statusParameter !== undefined) {
|
|
4523
|
+
return this.statusOfValue(call.getArguments()[builder.statusParameter]);
|
|
4524
|
+
}
|
|
4525
|
+
return builder.fixedStatus ?? 'undecided';
|
|
4526
|
+
}
|
|
4527
|
+
/**
|
|
4528
|
+
* The status an envelope expression states itself: a literal's status
|
|
4529
|
+
* member, or what a followable builder call sends. `'undecided'` for
|
|
4530
|
+
* anything else (a variable holding an envelope states nothing here).
|
|
4531
|
+
*/
|
|
4532
|
+
envelopeOwnStatus(expression) {
|
|
4533
|
+
const node = this.peelTransparentExpression(expression);
|
|
4534
|
+
if (Node.isObjectLiteralExpression(node))
|
|
4535
|
+
return this.envelopeLiteralStatus(node);
|
|
4536
|
+
if (!Node.isCallExpression(node))
|
|
4537
|
+
return 'undecided';
|
|
4538
|
+
const builder = this.envelopeBuilderOf(node);
|
|
4539
|
+
if (builder === 'failure')
|
|
4540
|
+
return 'error';
|
|
4541
|
+
return builder ? this.builderCallStatus(node, builder) : 'undecided';
|
|
4542
|
+
}
|
|
4543
|
+
/** The status an envelope literal's `statusCode` or `status` member states. */
|
|
4544
|
+
envelopeLiteralStatus(literal) {
|
|
4545
|
+
for (const name of STATUS_MEMBER_NAMES) {
|
|
4546
|
+
const value = this.envelopeMemberValue(literal, name);
|
|
4547
|
+
if (value)
|
|
4548
|
+
return this.statusOfValue(value);
|
|
4549
|
+
}
|
|
4550
|
+
return 'undecided';
|
|
4551
|
+
}
|
|
4552
|
+
/** The status a value states, classified as a send's status argument is. */
|
|
4553
|
+
statusOfValue(value) {
|
|
4554
|
+
if (!value)
|
|
4555
|
+
return 'undecided';
|
|
4556
|
+
const read = this.statedStatusCodes(this.peelTransparentExpression(value));
|
|
4557
|
+
if (read === 'variable')
|
|
4558
|
+
return 'variable';
|
|
4559
|
+
if (!read)
|
|
4560
|
+
return 'undecided';
|
|
4561
|
+
const kind = classifyStatusCodes(read);
|
|
4562
|
+
return kind === 'mixed' ? 'variable' : kind;
|
|
4563
|
+
}
|
|
4564
|
+
/**
|
|
4565
|
+
* The value node an object literal gives a member: a property assignment's
|
|
4566
|
+
* initializer, or a shorthand's own name.
|
|
4567
|
+
*/
|
|
4568
|
+
envelopeMemberValue(literal, name) {
|
|
4569
|
+
const property = literal.getProperty(name);
|
|
4570
|
+
if (property && Node.isPropertyAssignment(property))
|
|
4571
|
+
return property.getInitializer();
|
|
4572
|
+
if (property && Node.isShorthandPropertyAssignment(property))
|
|
4573
|
+
return property.getNameNode();
|
|
4574
|
+
return undefined;
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* The value an envelope literal's `body` serialises: the argument of a
|
|
4578
|
+
* `JSON.stringify` written there, or written as the initializer of a
|
|
4579
|
+
* `const` that `scope` declares and `body` names. `undefined` when `body`
|
|
4580
|
+
* is built any other way.
|
|
4581
|
+
*/
|
|
4582
|
+
serialisedBodyArgument(literal, scope) {
|
|
4583
|
+
const raw = this.envelopeMemberValue(literal, 'body');
|
|
4584
|
+
if (!raw)
|
|
4585
|
+
return undefined;
|
|
4586
|
+
const value = this.peelTransparentExpression(raw);
|
|
4587
|
+
const direct = this.unwrapJsonStringifyArg(value);
|
|
4588
|
+
if (direct !== value)
|
|
4589
|
+
return direct;
|
|
4590
|
+
const initializer = this.constInitializerInScope(value, scope);
|
|
4591
|
+
if (!initializer)
|
|
4592
|
+
return undefined;
|
|
4593
|
+
const bound = this.peelTransparentExpression(initializer);
|
|
4594
|
+
const viaConst = this.unwrapJsonStringifyArg(bound);
|
|
4595
|
+
return viaConst !== bound ? viaConst : undefined;
|
|
4596
|
+
}
|
|
4597
|
+
/**
|
|
4598
|
+
* The initializer of the `const` an identifier (or a shorthand member's
|
|
4599
|
+
* name) refers to, when `scope` itself declares it.
|
|
4600
|
+
*/
|
|
4601
|
+
constInitializerInScope(node, scope) {
|
|
4602
|
+
for (const declaration of this.valueSymbolOf(node)?.getDeclarations() ?? []) {
|
|
4603
|
+
if (!Node.isVariableDeclaration(declaration))
|
|
4604
|
+
continue;
|
|
4605
|
+
if (declaration.getVariableStatement()?.getDeclarationKind() !==
|
|
4606
|
+
VariableDeclarationKind.Const) {
|
|
4607
|
+
continue;
|
|
4608
|
+
}
|
|
4609
|
+
if (this.findContainingFunctionForNode(declaration) !== scope)
|
|
4610
|
+
continue;
|
|
4611
|
+
return declaration.getInitializer();
|
|
4612
|
+
}
|
|
4613
|
+
return undefined;
|
|
4614
|
+
}
|
|
4615
|
+
/**
|
|
4616
|
+
* The symbol of the value a node names: a shorthand member's value symbol
|
|
4617
|
+
* (its own symbol is the property), else the identifier's symbol.
|
|
4618
|
+
*/
|
|
4619
|
+
valueSymbolOf(node) {
|
|
4620
|
+
const parent = node.getParent();
|
|
4621
|
+
if (parent && Node.isShorthandPropertyAssignment(parent) && parent.getNameNode() === node) {
|
|
4622
|
+
return parent.getValueSymbol();
|
|
4623
|
+
}
|
|
4624
|
+
return Node.isIdentifier(node) ? node.getSymbol() : undefined;
|
|
4625
|
+
}
|
|
4626
|
+
/** The index of the `params` entry a node names, or `undefined`. */
|
|
4627
|
+
parameterIndexOf(node, params) {
|
|
4628
|
+
for (const declaration of this.valueSymbolOf(node)?.getDeclarations() ?? []) {
|
|
4629
|
+
const index = params.findIndex((param) => param === declaration);
|
|
4630
|
+
if (index >= 0)
|
|
4631
|
+
return index;
|
|
4632
|
+
}
|
|
4633
|
+
return undefined;
|
|
4634
|
+
}
|
|
4635
|
+
/**
|
|
4636
|
+
* How a call's callee builds an envelope, when the callee is a function this
|
|
4637
|
+
* repository declares (not lib, not an installed package) and every return
|
|
4638
|
+
* it makes is an envelope literal:
|
|
4639
|
+
* - returns whose status member is a literal 4xx or 5xx are its failure
|
|
4640
|
+
* path and are ignored; a builder with nothing else is `'failure'`, so a
|
|
4641
|
+
* call to it is an error branch;
|
|
4642
|
+
* - every other return must serialise the same parameter into `body` and
|
|
4643
|
+
* take its status from the same parameter, or state the same literal
|
|
4644
|
+
* success or redirect status.
|
|
4645
|
+
* `undefined` when the callee is anything else.
|
|
4646
|
+
*/
|
|
4647
|
+
envelopeBuilderOf(call) {
|
|
4648
|
+
let symbol = call.getExpression().getSymbol();
|
|
4649
|
+
if (symbol?.isAlias())
|
|
4650
|
+
symbol = symbol.getAliasedSymbol() ?? symbol;
|
|
4651
|
+
if (!symbol || this.symbolIsLibOrExternalOrigin(symbol))
|
|
4652
|
+
return undefined;
|
|
4653
|
+
const builder = this.builderFunctionOf(symbol);
|
|
4654
|
+
if (!builder)
|
|
4655
|
+
return undefined;
|
|
4656
|
+
const params = builder.getParameters();
|
|
4657
|
+
const returned = this.returnedExpressions(builder).flatMap((expression) => this.expandResponseBranches(expression, 0));
|
|
4658
|
+
let mapping;
|
|
4659
|
+
let sawFailure = false;
|
|
4660
|
+
for (const expression of returned) {
|
|
4661
|
+
const literal = this.peelTransparentExpression(expression);
|
|
4662
|
+
if (!Node.isObjectLiteralExpression(literal))
|
|
4663
|
+
return undefined;
|
|
4664
|
+
const statusValue = STATUS_MEMBER_NAMES.map((name) => this.envelopeMemberValue(literal, name)).find((value) => value !== undefined);
|
|
4665
|
+
if (!statusValue)
|
|
4666
|
+
return undefined;
|
|
4667
|
+
const statusParameter = this.parameterIndexOf(statusValue, params);
|
|
4668
|
+
let fixedStatus;
|
|
4669
|
+
if (statusParameter === undefined) {
|
|
4670
|
+
const codes = this.statedStatusCodes(this.peelTransparentExpression(statusValue));
|
|
4671
|
+
if (!Array.isArray(codes))
|
|
4672
|
+
return undefined;
|
|
4673
|
+
const kind = classifyStatusCodes(codes);
|
|
4674
|
+
if (kind === 'error') {
|
|
4675
|
+
sawFailure = true;
|
|
4676
|
+
continue;
|
|
4677
|
+
}
|
|
4678
|
+
if (kind === 'mixed')
|
|
4679
|
+
return undefined;
|
|
4680
|
+
fixedStatus = kind;
|
|
4681
|
+
}
|
|
4682
|
+
const serialised = this.serialisedBodyArgument(literal, builder);
|
|
4683
|
+
const payloadParameter = serialised ? this.parameterIndexOf(serialised, params) : undefined;
|
|
4684
|
+
if (payloadParameter === undefined)
|
|
4685
|
+
return undefined;
|
|
4686
|
+
const here = { builder, payloadParameter, statusParameter, fixedStatus };
|
|
4687
|
+
if (mapping &&
|
|
4688
|
+
(mapping.payloadParameter !== here.payloadParameter ||
|
|
4689
|
+
mapping.statusParameter !== here.statusParameter ||
|
|
4690
|
+
mapping.fixedStatus !== here.fixedStatus)) {
|
|
4691
|
+
return undefined;
|
|
4692
|
+
}
|
|
4693
|
+
mapping = here;
|
|
4694
|
+
}
|
|
4695
|
+
if (mapping)
|
|
4696
|
+
return mapping;
|
|
4697
|
+
return sawFailure ? 'failure' : undefined;
|
|
4698
|
+
}
|
|
4699
|
+
/**
|
|
4700
|
+
* The single function a callee symbol declares with a body: a function
|
|
4701
|
+
* declaration, or a variable initialised with an arrow or function
|
|
4702
|
+
* expression.
|
|
4703
|
+
*/
|
|
4704
|
+
builderFunctionOf(symbol) {
|
|
4705
|
+
const bodies = [];
|
|
4706
|
+
for (const declaration of symbol.getDeclarations()) {
|
|
4707
|
+
if (Node.isFunctionDeclaration(declaration) && declaration.getBody()) {
|
|
4708
|
+
bodies.push(declaration);
|
|
4709
|
+
}
|
|
4710
|
+
else if (Node.isVariableDeclaration(declaration)) {
|
|
4711
|
+
const initializer = declaration.getInitializer();
|
|
4712
|
+
const fn = initializer ? this.peelTransparentExpression(initializer) : undefined;
|
|
4713
|
+
if (fn && (Node.isArrowFunction(fn) || Node.isFunctionExpression(fn)))
|
|
4714
|
+
bodies.push(fn);
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
return bodies.length === 1 ? bodies[0] : undefined;
|
|
4718
|
+
}
|
|
4719
|
+
/**
|
|
4720
|
+
* The payload a builder call serialises. The source states it, and the
|
|
4721
|
+
* answer is explicit, when the argument carries `satisfies`/`as`, when the
|
|
4722
|
+
* builder's parameter is one of its type parameters and the call passes
|
|
4723
|
+
* type arguments (`reply<AddNoteResult>(201, x)`), or when the parameter is
|
|
4724
|
+
* declared with a type that says something. Otherwise the argument's own
|
|
4725
|
+
* type is read.
|
|
4726
|
+
*/
|
|
4727
|
+
builderPayloadCandidate(call, builder, argument, wire) {
|
|
4728
|
+
const payloadNode = this.peelTransparentExpression(argument);
|
|
4729
|
+
if (this.statedTypeNodeOf(payloadNode)) {
|
|
4730
|
+
return this.envelopePayloadCandidate(payloadNode, wire);
|
|
4731
|
+
}
|
|
4732
|
+
const declared = builder.builder.getParameters()[builder.payloadParameter]?.getTypeNode();
|
|
4733
|
+
if (declared) {
|
|
4734
|
+
const typeParameterIndex = builder.builder
|
|
4735
|
+
.getTypeParameters()
|
|
4736
|
+
.findIndex((tp) => tp.getName() === declared.getText().trim());
|
|
4737
|
+
if (typeParameterIndex >= 0) {
|
|
4738
|
+
const typeArgument = call.getTypeArguments()[typeParameterIndex];
|
|
4739
|
+
if (typeArgument) {
|
|
4740
|
+
return this.checkedEnvelopeCandidate(this.statedPayloadCandidate(payloadNode, typeArgument, wire));
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
else if (!this.typeNodeStatesNothing(declared)) {
|
|
4744
|
+
return this.checkedEnvelopeCandidate(this.statedPayloadCandidate(payloadNode, declared, wire));
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
return this.envelopePayloadCandidate(payloadNode, wire);
|
|
4748
|
+
}
|
|
4749
|
+
/**
|
|
4750
|
+
* True when a declared parameter type does not say what is sent: a top
|
|
4751
|
+
* type, the `object` keyword, an object type that names no member (`{}`,
|
|
4752
|
+
* `Record<string, unknown>`), a union that admits both primitives and
|
|
4753
|
+
* objects (a JSON-value type such as `string | number | Json[] | { [k:
|
|
4754
|
+
* string]: Json }`), or a type that mentions a type parameter. A builder
|
|
4755
|
+
* that takes "any value" states nothing about the payload a call hands it,
|
|
4756
|
+
* so the argument's own type is read instead.
|
|
4757
|
+
*/
|
|
4758
|
+
typeNodeStatesNothing(typeNode) {
|
|
4759
|
+
const type = typeNode.getType();
|
|
4760
|
+
if (type.isAny() || type.isUnknown() || type.isTypeParameter())
|
|
4761
|
+
return true;
|
|
4762
|
+
if (type.getFlags() & ts.TypeFlags.NonPrimitive)
|
|
4763
|
+
return true;
|
|
4764
|
+
if (type.isObject() && !type.isArray() && type.getProperties().length === 0) {
|
|
4765
|
+
return true;
|
|
4766
|
+
}
|
|
4767
|
+
if (type.isUnion()) {
|
|
4768
|
+
const members = type.getUnionTypes();
|
|
4769
|
+
const primitive = members.some((member) => member.isString() ||
|
|
4770
|
+
member.isNumber() ||
|
|
4771
|
+
member.isBoolean() ||
|
|
4772
|
+
member.isBooleanLiteral());
|
|
4773
|
+
if (primitive && members.some((member) => member.isObject()))
|
|
4774
|
+
return true;
|
|
4775
|
+
}
|
|
4776
|
+
return [typeNode, ...typeNode.getDescendants()].some((node) => Node.isTypeReference(node) && node.getType().isTypeParameter());
|
|
4777
|
+
}
|
|
4778
|
+
/** A serialised value's candidate, unread when it holds a top type. */
|
|
4779
|
+
envelopePayloadCandidate(payloadNode, wire) {
|
|
4780
|
+
return this.checkedEnvelopeCandidate(this.payloadCandidateOf(payloadNode, wire));
|
|
4781
|
+
}
|
|
4782
|
+
/**
|
|
4783
|
+
* A payload candidate, or unread when its printed type is or holds `any`
|
|
4784
|
+
* or `unknown` anywhere (`{ [x: string]: any }`, `{ data: any }`). A
|
|
4785
|
+
* contract with a hole in it is not published on this path: the capture
|
|
4786
|
+
* would refuse the text and re-read the line, which can publish the
|
|
4787
|
+
* envelope.
|
|
4788
|
+
*/
|
|
4789
|
+
checkedEnvelopeCandidate(candidate) {
|
|
4790
|
+
return TOP_TYPE_TOKEN.test(candidate.typeString) ? { unread: 'top_type' } : candidate;
|
|
4791
|
+
}
|
|
4271
4792
|
/**
|
|
4272
4793
|
* The request field each member of a joined union answers
|
|
4273
4794
|
* (`response-modes.ts`). Case texts are joined by the published union's
|
|
@@ -4666,16 +5187,9 @@ export class TypeInferrer {
|
|
|
4666
5187
|
const receiver = this.peelTransparentExpression(callee.getExpression());
|
|
4667
5188
|
if (!Node.isCallExpression(receiver))
|
|
4668
5189
|
return undefined;
|
|
4669
|
-
const
|
|
4670
|
-
if (
|
|
4671
|
-
|
|
4672
|
-
if (read === 'variable')
|
|
4673
|
-
return 'variable';
|
|
4674
|
-
if (read) {
|
|
4675
|
-
const kind = classifyStatusCodes(read);
|
|
4676
|
-
return kind === 'mixed' ? 'variable' : kind;
|
|
4677
|
-
}
|
|
4678
|
-
}
|
|
5190
|
+
const status = this.statusOfValue(receiver.getArguments()[0]);
|
|
5191
|
+
if (status !== 'undecided')
|
|
5192
|
+
return status;
|
|
4679
5193
|
callee = receiver.getExpression();
|
|
4680
5194
|
}
|
|
4681
5195
|
return undefined;
|