carrick 0.3.70 → 0.3.72

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.
@@ -861,7 +861,7 @@ export class TypeInferrer {
861
861
  if (atLine) {
862
862
  const requestType = this.requestContractFromRegistration(atLine.registration, atLine.handler);
863
863
  if (requestType) {
864
- return this.createInferredType(request, requestType, true, this.getNodeLocation(atLine.handler), undefined, undefined);
864
+ return this.declaredRequestInferredType(request, requestType, this.getNodeLocation(atLine.handler));
865
865
  }
866
866
  }
867
867
  return null;
@@ -879,7 +879,7 @@ export class TypeInferrer {
879
879
  if (registrationHandler) {
880
880
  const requestType = this.requestContractFromRegistration(this.unwrapExpressionNode(located), registrationHandler);
881
881
  if (requestType) {
882
- return this.createInferredType(request, requestType, true, this.getNodeLocation(registrationHandler), undefined, undefined);
882
+ return this.declaredRequestInferredType(request, requestType, this.getNodeLocation(registrationHandler));
883
883
  }
884
884
  // A genuinely payload-less handler (no typed request read): do NOT fall
885
885
  // through to read the registration call's return type, which would emit a
@@ -912,7 +912,7 @@ export class TypeInferrer {
912
912
  if (declared) {
913
913
  this.log(`Route registration at ${request.file_path}:${request.line_number} declares its ` +
914
914
  'request contract; using the declaration over the located expression');
915
- return this.createInferredType(request, declared, true, this.getNodeLocation(declaredAt.handler), undefined, undefined);
915
+ return this.declaredRequestInferredType(request, declared, this.getNodeLocation(declaredAt.handler));
916
916
  }
917
917
  }
918
918
  // A text locator (Gemini `expression_text` + `expression_line`, as opposed
@@ -966,6 +966,21 @@ export class TypeInferrer {
966
966
  }
967
967
  }
968
968
  }
969
+ // carrick#1101: the locator landed on a validated read — a call inside a
970
+ // registered handler whose type IS the parsed output of a body schema that
971
+ // the enclosing registration declares. That value is what the handler
972
+ // receives, not what a caller sends, so publish the schema's input. Only a
973
+ // direct call is read this way: a value that went through a serialiser or
974
+ // a variable hop is left to the path below, so a consumer body forwarded
975
+ // from inside a handler keeps its own type.
976
+ if (node === unwrapped && Node.isCallExpression(node)) {
977
+ const validatedRead = this.validatedReadRequestContract(node);
978
+ if (validatedRead) {
979
+ this.log(`Request locator at ${request.file_path}:${request.line_number} is a validated read ` +
980
+ "of its registration's body schema; publishing the schema's input");
981
+ return this.declaredRequestInferredType(request, validatedRead, this.getNodeLocation(node));
982
+ }
983
+ }
969
984
  const payloadType = node.getType();
970
985
  let typeString = typeText(payloadType, node);
971
986
  let isExplicit = false;
@@ -3126,12 +3141,13 @@ export class TypeInferrer {
3126
3141
  // of that parameter's type;
3127
3142
  // (b) a validation-schema object passed alongside the handler — `{ schema:
3128
3143
  // { body: ref('CreateWidget'), response: { 200: ref('Widget') } } }` —
3129
- // whose entries reference schema VALUES that carry their parsed output
3130
- // type.
3144
+ // whose entries reference schema VALUES that declare their input and
3145
+ // output types.
3131
3146
  //
3132
3147
  // Both are read structurally: (a) is "the `body` member of a parameter's
3133
3148
  // type", (b) is "a `schema` property on a registration argument, whose
3134
- // entries resolve to a value whose `parse` returns the contract". No
3149
+ // entries resolve to a value that declares its types" (the body reads the
3150
+ // input, the response the output; carrick#1101). No
3135
3151
  // framework, library, or method-name list is involved — a framework whose
3136
3152
  // request object exposes `body` and whose route options carry `schema` is
3137
3153
  // read the same way regardless of which one it is.
@@ -3160,13 +3176,13 @@ export class TypeInferrer {
3160
3176
  * back to following the handler's return.
3161
3177
  */
3162
3178
  declaredResponseInferredType(request, registration) {
3163
- const declared = this.routeSchemaContractText(registration, 'response');
3179
+ const declared = this.routeSchemaContract(registration, 'response');
3164
3180
  if (!declared) {
3165
3181
  return null;
3166
3182
  }
3167
3183
  this.log(`Route registration at ${request.file_path}:${request.line_number} declares its ` +
3168
3184
  'response schema; using the declared contract');
3169
- return this.createInferredType(request, declared, true, this.getNodeLocation(registration), undefined, undefined);
3185
+ return this.createInferredType(request, declared.text, true, this.getNodeLocation(registration), undefined, undefined);
3170
3186
  }
3171
3187
  /**
3172
3188
  * The request contract of a route registration, in anchor order: the
@@ -3176,8 +3192,23 @@ export class TypeInferrer {
3176
3192
  * the route declares its request nowhere we can read.
3177
3193
  */
3178
3194
  requestContractFromRegistration(registration, handler) {
3179
- return (this.declaredRequestContract(registration, handler) ??
3180
- this.inferRequestReadFromHandler(handler));
3195
+ const declared = this.declaredRequestContract(registration, handler);
3196
+ if (declared) {
3197
+ return declared;
3198
+ }
3199
+ const read = this.inferRequestReadFromHandler(handler);
3200
+ return read ? { text: read } : null;
3201
+ }
3202
+ /**
3203
+ * An explicit request `InferredType` for a contract the route declares,
3204
+ * carrying the provenance the reading recorded.
3205
+ */
3206
+ declaredRequestInferredType(request, contract, location) {
3207
+ const inferred = this.createInferredType(request, contract.text, true, location, undefined, undefined);
3208
+ if (contract.provenance && contract.provenance.length > 0) {
3209
+ inferred.any_provenance = contract.provenance;
3210
+ }
3211
+ return inferred;
3181
3212
  }
3182
3213
  /**
3183
3214
  * The request contract a route DECLARES, in anchor order: the handler's
@@ -3191,9 +3222,12 @@ export class TypeInferrer {
3191
3222
  * own locator authoritative when a route declares nothing.
3192
3223
  */
3193
3224
  declaredRequestContract(registration, handler) {
3194
- return (this.requestBodyFromHandlerParams(handler) ??
3195
- this.routeSchemaContractText(registration, 'body') ??
3196
- this.validatedBodyContractText(registration));
3225
+ const annotated = this.requestBodyFromHandlerParams(handler);
3226
+ if (annotated) {
3227
+ return { text: annotated };
3228
+ }
3229
+ return (this.routeSchemaContract(registration, 'body') ??
3230
+ this.validatedBodyContract(registration));
3197
3231
  }
3198
3232
  /**
3199
3233
  * Anchor (a): the request contract declared on the handler's own signature.
@@ -3246,8 +3280,9 @@ export class TypeInferrer {
3246
3280
  * The shape is a CALL among the registration's arguments whose own arguments
3247
3281
  * are a request part and a schema value. Neither the middleware's name nor
3248
3282
  * the schema library is checked: the part is read off the string literal, and
3249
- * the schema is whatever exposes a parsed output (`schemaOutputTypeText`) —
3250
- * the same test anchor (b) already applies to a `schema: { body: … }` object.
3283
+ * the schema is whatever declares its types (`schemaContract`) — the same
3284
+ * test anchor (b) already applies to a `schema: { body: … }` object. The
3285
+ * contract is the schema's INPUT, what a caller sends (carrick#1101).
3251
3286
  *
3252
3287
  * `REQUEST_BODY_PARTS` is HTTP vocabulary for how a body is carried, not a
3253
3288
  * framework list. A middleware bound to any other part (`query`, `param`,
@@ -3255,10 +3290,26 @@ export class TypeInferrer {
3255
3290
  * at all is not read: publishing a query schema as the request contract would
3256
3291
  * be the same confident-and-wrong answer this anchor exists to remove.
3257
3292
  */
3258
- validatedBodyContractText(registration) {
3293
+ validatedBodyContract(registration) {
3294
+ for (const candidate of this.middlewareBodySchemaValues(registration)) {
3295
+ const declared = this.schemaContract(candidate, 'input');
3296
+ if (declared) {
3297
+ return declared;
3298
+ }
3299
+ }
3300
+ return null;
3301
+ }
3302
+ /**
3303
+ * The values a registration's validator middleware binds to a body part, in
3304
+ * argument order: every non-literal argument of a middleware call that names
3305
+ * a `REQUEST_BODY_PARTS` part. Whether each one IS a schema is the reader's
3306
+ * question (`schemaContract`), not this walk's.
3307
+ */
3308
+ middlewareBodySchemaValues(registration) {
3259
3309
  if (!Node.isCallExpression(registration)) {
3260
- return null;
3310
+ return [];
3261
3311
  }
3312
+ const values = [];
3262
3313
  for (const argument of registration.getArguments()) {
3263
3314
  const middleware = this.unwrapExpressionNode(argument);
3264
3315
  if (!Node.isCallExpression(middleware)) {
@@ -3267,21 +3318,58 @@ export class TypeInferrer {
3267
3318
  const middlewareArgs = middleware
3268
3319
  .getArguments()
3269
3320
  .map((arg) => this.unwrapExpressionNode(arg));
3270
- const parts = middlewareArgs.filter((arg) => Node.isStringLiteral(arg));
3271
- if (parts.length === 0) {
3321
+ const bindsBody = middlewareArgs.some((arg) => Node.isStringLiteral(arg) &&
3322
+ REQUEST_BODY_PARTS.has(arg.getLiteralValue().toLowerCase()));
3323
+ if (!bindsBody) {
3324
+ continue;
3325
+ }
3326
+ values.push(...middlewareArgs.filter((arg) => !Node.isStringLiteral(arg)));
3327
+ }
3328
+ return values;
3329
+ }
3330
+ /**
3331
+ * The request contract behind a VALIDATED READ: a call inside a registered
3332
+ * handler whose type is exactly the parsed output of a body schema the
3333
+ * enclosing registration declares (carrick#1101).
3334
+ *
3335
+ * The read's own type is what validation hands the handler, the schema's
3336
+ * output, so a locator that lands on it would publish the output as the
3337
+ * request. The registration is found by walking the read's ancestors to each
3338
+ * call or registry object whose handler contains the read and which declares
3339
+ * a body schema (anchor (b) or (b2)). The match is on the printed type: only
3340
+ * a read whose structural text equals that schema's output is substituted, so
3341
+ * any other expression in the handler (a query read, a payload of a different
3342
+ * shape) keeps its own type.
3343
+ */
3344
+ validatedReadRequestContract(read) {
3345
+ let readText;
3346
+ for (const ancestor of read.getAncestors()) {
3347
+ if (!Node.isCallExpression(ancestor) &&
3348
+ !Node.isObjectLiteralExpression(ancestor)) {
3272
3349
  continue;
3273
3350
  }
3274
- if (!parts.some((part) => REQUEST_BODY_PARTS.has(part.getLiteralValue().toLowerCase()))) {
3351
+ const handler = this.registrationHandlerAt(ancestor);
3352
+ if (!handler ||
3353
+ read.getStart() < handler.getStart() ||
3354
+ read.getEnd() > handler.getEnd()) {
3275
3355
  continue;
3276
3356
  }
3277
- for (const candidate of middlewareArgs) {
3278
- if (Node.isStringLiteral(candidate)) {
3357
+ const schemaValues = [
3358
+ ...this.routeSchemaEntryValues(ancestor, 'body'),
3359
+ ...this.middlewareBodySchemaValues(ancestor),
3360
+ ];
3361
+ for (const value of schemaValues) {
3362
+ const output = this.schemaContract(value, 'output');
3363
+ if (!output) {
3279
3364
  continue;
3280
3365
  }
3281
- const declared = this.schemaOutputTypeText(candidate);
3282
- if (declared) {
3283
- return declared;
3366
+ if (readText === undefined) {
3367
+ readText = this.structuralTextFromType(read.getType(), read);
3368
+ }
3369
+ if (readText !== output.text) {
3370
+ continue;
3284
3371
  }
3372
+ return this.schemaContract(value, 'input');
3285
3373
  }
3286
3374
  }
3287
3375
  return null;
@@ -3291,29 +3379,43 @@ export class TypeInferrer {
3291
3379
  *
3292
3380
  * `part` is `'body'` for the request contract and `'response'` for the
3293
3381
  * response contract; a `response` entry keyed by status code resolves to its
3294
- * success entry. Returns null when the registration carries no schema, when
3295
- * the entry references something whose parsed output cannot be resolved, or
3296
- * when the schema is a plain JSON-schema literal (whose own object type is
3297
- * the JSON-Schema document, not the payload — emitting that would be worse
3298
- * than abstaining).
3382
+ * success entry. The body reads the schema's input and the response its
3383
+ * output (carrick#1101). Returns null when the registration carries no
3384
+ * schema, when the entry references something whose declared types cannot be
3385
+ * resolved, or when the schema is a plain JSON-schema literal (whose own
3386
+ * object type is the JSON-Schema document, not the payload — emitting that
3387
+ * would be worse than abstaining).
3388
+ */
3389
+ routeSchemaContract(registration, part) {
3390
+ const [value] = this.routeSchemaEntryValues(registration, part);
3391
+ if (!value) {
3392
+ return null;
3393
+ }
3394
+ return this.schemaContract(value, part === 'body' ? 'input' : 'output');
3395
+ }
3396
+ /**
3397
+ * The value a route `schema` object declares for `part`, as a zero- or
3398
+ * one-element list: the `body` entry itself, or the success entry of a
3399
+ * status-keyed `response` map.
3299
3400
  */
3300
- routeSchemaContractText(registration, part) {
3401
+ routeSchemaEntryValues(registration, part) {
3301
3402
  const schemaObject = this.routeSchemaObject(registration);
3302
3403
  if (!schemaObject) {
3303
- return null;
3404
+ return [];
3304
3405
  }
3305
3406
  const entry = schemaObject.getProperty(part);
3306
3407
  if (!entry || !Node.isPropertyAssignment(entry)) {
3307
- return null;
3408
+ return [];
3308
3409
  }
3309
3410
  const initializer = entry.getInitializer();
3310
3411
  if (!initializer) {
3311
- return null;
3412
+ return [];
3312
3413
  }
3313
- const value = part === 'response'
3314
- ? (this.successStatusEntry(initializer) ?? initializer)
3315
- : initializer;
3316
- return this.schemaOutputTypeText(value);
3414
+ return [
3415
+ part === 'response'
3416
+ ? (this.successStatusEntry(initializer) ?? initializer)
3417
+ : initializer,
3418
+ ];
3317
3419
  }
3318
3420
  /**
3319
3421
  * The `schema` object literal carried by a route registration: scan the
@@ -3379,17 +3481,36 @@ export class TypeInferrer {
3379
3481
  return best?.node;
3380
3482
  }
3381
3483
  /**
3382
- * The payload type a schema entry declares.
3484
+ * The payload type a schema entry declares, read in `direction`.
3383
3485
  *
3384
3486
  * The entry is either a REFERENCE call — `ref('CreateWidget')`, a
3385
3487
  * name-to-schema indirection whose registry is the argument the ref function
3386
- * was built from — or the schema value itself. Either way the payload is the
3387
- * schema's parsed output: the return type of its `parse` method (the shape
3388
- * every schema value exposes as its public validate-and-return API), falling
3389
- * back to a declared `_output` member. A value with neither is not a schema
3390
- * and yields null.
3488
+ * was built from — or the schema value itself. A value that declares no type
3489
+ * in either direction is not a schema and yields null.
3490
+ *
3491
+ * `output` is the parsed value (`schemaOutputType`). `input` is what a caller
3492
+ * sends (`schemaInputType`), and a schema that declares no input member reads
3493
+ * its output instead, which is the whole of what is knowable about it.
3494
+ *
3495
+ * The input is decided member by member (carrick#1105). A member whose input
3496
+ * is `any`/`unknown` where the output is concrete is what a coercion declares
3497
+ * (it accepts any value and parses it into, say, a number). A top type in a
3498
+ * published contract disqualifies the whole row downstream, so THAT member
3499
+ * prints its output type, keeps the input key's optionality, and is recorded
3500
+ * as `coerced_input` provenance. Every other member keeps its input, so a
3501
+ * defaulted key beside a coerced one stays optional to send.
3502
+ *
3503
+ * Limits, each logged where it bites:
3504
+ * - the position walk is bounded (`walkTypePositions`), so a top type below
3505
+ * the bound is not compared and prints as the input declares it;
3506
+ * - a coerced position that cannot be substituted (its output is absent or
3507
+ * differs across union branches, or the printer cannot reach it inside a
3508
+ * tuple, behind a cycle or past the expansion depth) makes the row fall
3509
+ * back to the whole parsed output with every coerced member labelled. That
3510
+ * was the answer before the per-member reading, and it never publishes an
3511
+ * `unknown`.
3391
3512
  */
3392
- schemaOutputTypeText(value) {
3513
+ schemaContract(value, direction) {
3393
3514
  const node = this.unwrapExpressionNode(value);
3394
3515
  let schemaType;
3395
3516
  if (Node.isCallExpression(node)) {
@@ -3403,13 +3524,201 @@ export class TypeInferrer {
3403
3524
  return null;
3404
3525
  }
3405
3526
  }
3527
+ const location = this.getNodeLocation(node);
3528
+ const where = `${location.file_path}:${location.start_line}`;
3406
3529
  const output = this.schemaOutputType(schemaType, node);
3407
- if (!output) {
3408
- this.log(`Route schema entry at ${this.getNodeLocation(node).file_path}:${this.getNodeLocation(node).start_line} declares no resolvable parsed output (schema library types unavailable, ` +
3409
- 'or a plain JSON-Schema literal); leaving unresolved');
3530
+ const input = direction === 'input' ? this.schemaInputType(schemaType, node) : undefined;
3531
+ if (!output && !input) {
3532
+ this.log(`Route schema entry at ${where} declares no resolvable type (schema library ` +
3533
+ 'types unavailable, or a plain JSON-Schema literal); leaving unresolved');
3534
+ return null;
3535
+ }
3536
+ if (direction === 'output' || !input) {
3537
+ if (direction === 'input') {
3538
+ this.log(`Route schema entry at ${where} declares no input type; publishing its parsed output`);
3539
+ }
3540
+ const text = output ? this.structuralTextFromType(output, node) : null;
3541
+ return text ? { text } : null;
3542
+ }
3543
+ const inputTop = this.topTypePositions(input, node, where);
3544
+ const outputTop = output && inputTop.size > 0 ? this.topTypePositions(output, node, where) : undefined;
3545
+ const coerced = outputTop
3546
+ ? [...inputTop.entries()]
3547
+ .filter(([position]) => !outputTop.has(position))
3548
+ .sort(([a], [b]) => a.localeCompare(b))
3549
+ : [];
3550
+ if (!output || coerced.length === 0) {
3551
+ const text = this.structuralTextFromType(input, node);
3552
+ return text ? { text } : null;
3553
+ }
3554
+ const positions = coerced.map(([position]) => position || '<root>').join(', ');
3555
+ const provenance = coerced.map(([position, kind]) => ({
3556
+ path: position,
3557
+ kind,
3558
+ reason: 'coerced_input',
3559
+ detail: `the schema accepts any value here and coerces it, so the published type ` +
3560
+ `is what parsing produces, not a limit on what a caller may send`,
3561
+ }));
3562
+ const text = this.inputWithCoercedMembers(input, output, coerced.map(([position]) => position), node, where);
3563
+ if (text) {
3564
+ this.log(`Route schema entry at ${where} accepts any value at ${positions} on input; ` +
3565
+ 'publishing the parsed type there and the input everywhere else');
3566
+ return { text, provenance };
3567
+ }
3568
+ const whole = this.structuralTextFromType(output, node);
3569
+ if (!whole) {
3410
3570
  return null;
3411
3571
  }
3412
- return this.structuralTextFromType(output, node);
3572
+ this.log(`Route schema entry at ${where} accepts any value at ${positions} on input, and not ` +
3573
+ 'every one could be substituted member by member; publishing its whole parsed output');
3574
+ return { text: whole, provenance };
3575
+ }
3576
+ /**
3577
+ * Print a schema's input with each coerced position replaced by the output's
3578
+ * type at that position (carrick#1105). Null when any position cannot be
3579
+ * substituted: its output is missing or differs across union branches, or
3580
+ * the printer never reached it. A partial substitution would publish an
3581
+ * `unknown`, so it is not an answer.
3582
+ */
3583
+ inputWithCoercedMembers(input, output, coerced, at, where) {
3584
+ const wanted = new Set(coerced);
3585
+ const types = new Map();
3586
+ const ambiguous = new Set();
3587
+ this.walkTypePositions(output, at, where, (type, position, unionPart) => {
3588
+ // A union's parts share its position; the union itself is the type there.
3589
+ if (wanted.has(position) && !unionPart) {
3590
+ const seen = types.get(position);
3591
+ if (!seen) {
3592
+ types.set(position, type);
3593
+ }
3594
+ else if (seen.compilerType !== type.compilerType) {
3595
+ ambiguous.add(position);
3596
+ }
3597
+ }
3598
+ return true;
3599
+ });
3600
+ for (const position of ambiguous) {
3601
+ types.delete(position);
3602
+ }
3603
+ const missing = coerced.filter((position) => !types.has(position));
3604
+ if (missing.length > 0) {
3605
+ this.log(`Route schema entry at ${where}: no single parsed type at ` +
3606
+ `${missing.map((position) => position || '<root>').join(', ')} to substitute ` +
3607
+ 'for the coerced input');
3608
+ return null;
3609
+ }
3610
+ const root = types.get('');
3611
+ if (root) {
3612
+ return this.structuralTextFromType(root, at);
3613
+ }
3614
+ const overrides = { types, applied: new Set(), at };
3615
+ let text;
3616
+ try {
3617
+ text = expandTypeStructural(this.unwrapPromiseType(input), new Set(), 0, overrides);
3618
+ }
3619
+ catch {
3620
+ return null;
3621
+ }
3622
+ const unreached = coerced.filter((position) => !overrides.applied.has(position));
3623
+ if (unreached.length > 0) {
3624
+ this.log(`Route schema entry at ${where}: the printer did not reach coerced ` +
3625
+ `${unreached.join(', ')} (tuple, cycle or depth bound)`);
3626
+ return null;
3627
+ }
3628
+ return this.isUselessType(text) ? null : text;
3629
+ }
3630
+ /**
3631
+ * Member positions inside `root` that are `any` or `unknown`, keyed by the
3632
+ * member-path notation the provenance entries use (`''` for the root,
3633
+ * `sub.field`, `items<0>` for an array element). Union and intersection
3634
+ * members share their parent's position, so `unknown | undefined` on an
3635
+ * optional key reads as `unknown` there.
3636
+ */
3637
+ topTypePositions(root, at, where) {
3638
+ const found = new Map();
3639
+ this.walkTypePositions(root, at, where, (type, position) => {
3640
+ if (type.isAny() || type.isUnknown()) {
3641
+ found.set(position, type.isAny() ? 'any' : 'unknown');
3642
+ return false;
3643
+ }
3644
+ return true;
3645
+ });
3646
+ return found;
3647
+ }
3648
+ /**
3649
+ * Visit every member position of `root`, in the notation `topTypePositions`
3650
+ * documents. `visit` returns false to stop descending below a position;
3651
+ * `unionPart` is true when the type is a union or intersection part visited
3652
+ * at its parent's position. Callables are not descended into.
3653
+ *
3654
+ * Bounded and cycle-safe. It only has to tell a schema's input apart from its
3655
+ * output, so a subtree past the bound is not compared, and that is logged:
3656
+ * a coercion below the bound prints as its input declares it.
3657
+ */
3658
+ walkTypePositions(root, at, where, visit) {
3659
+ const MAX_DEPTH = 8;
3660
+ const MAX_VISITED = 512;
3661
+ const onPath = new Set();
3662
+ let visited = 0;
3663
+ let bounded = false;
3664
+ const walk = (type, position, depth, unionPart) => {
3665
+ if (depth > MAX_DEPTH || visited > MAX_VISITED) {
3666
+ bounded = true;
3667
+ return;
3668
+ }
3669
+ if (!visit(type, position, unionPart)) {
3670
+ return;
3671
+ }
3672
+ const compilerType = type.compilerType;
3673
+ if (onPath.has(compilerType)) {
3674
+ return;
3675
+ }
3676
+ onPath.add(compilerType);
3677
+ visited += 1;
3678
+ try {
3679
+ const parts = type.isUnion()
3680
+ ? type.getUnionTypes()
3681
+ : type.isIntersection()
3682
+ ? type.getIntersectionTypes()
3683
+ : undefined;
3684
+ if (parts) {
3685
+ for (const part of parts) {
3686
+ walk(part, position, depth + 1, true);
3687
+ }
3688
+ return;
3689
+ }
3690
+ if (type.isArray()) {
3691
+ const element = type.getArrayElementType();
3692
+ if (element) {
3693
+ walk(element, `${position}<0>`, depth + 1, false);
3694
+ }
3695
+ return;
3696
+ }
3697
+ if (!type.isObject() || this.isCallableType(type)) {
3698
+ return;
3699
+ }
3700
+ for (const property of type.getProperties()) {
3701
+ let propertyType;
3702
+ try {
3703
+ propertyType = property.getTypeAtLocation(at);
3704
+ }
3705
+ catch {
3706
+ continue;
3707
+ }
3708
+ const name = property.getName();
3709
+ walk(propertyType, position === '' ? name : `${position}.${name}`, depth + 1, false);
3710
+ }
3711
+ }
3712
+ finally {
3713
+ onPath.delete(compilerType);
3714
+ }
3715
+ };
3716
+ walk(root, '', 0, false);
3717
+ if (bounded) {
3718
+ this.log(`Schema type at ${where} is deeper or wider than the position walk bound ` +
3719
+ `(depth ${MAX_DEPTH}, ${MAX_VISITED} members); members past it are not ` +
3720
+ 'compared for coercion');
3721
+ }
3413
3722
  }
3414
3723
  /**
3415
3724
  * Follow a schema REFERENCE call (`ref('CreateWidget')`) to the schema value
@@ -3479,8 +3788,9 @@ export class TypeInferrer {
3479
3788
  }
3480
3789
  /**
3481
3790
  * The parsed output type of a schema value: the return type of its `parse`
3482
- * method, else a declared `_output` member. Returns undefined when neither
3483
- * carries a usable type — including when the schema library's own types are
3791
+ * method, else a declared `_output` member, else the Standard Schema member
3792
+ * `~standard.types.output` (a library that exposes nothing else). Returns
3793
+ * undefined when none carries a usable type — including when the schema library's own types are
3484
3794
  * unavailable (an uninstalled dependency resolves the schema to `any`), which
3485
3795
  * must abstain rather than publish `any` as a contract.
3486
3796
  */
@@ -3507,13 +3817,62 @@ export class TypeInferrer {
3507
3817
  const outputSymbol = schemaType.getProperty('_output');
3508
3818
  if (outputSymbol) {
3509
3819
  try {
3510
- return usable(outputSymbol.getTypeAtLocation(at));
3820
+ const declared = usable(outputSymbol.getTypeAtLocation(at));
3821
+ if (declared) {
3822
+ return declared;
3823
+ }
3511
3824
  }
3512
3825
  catch {
3513
- return undefined;
3826
+ // fall through to the Standard Schema member
3514
3827
  }
3515
3828
  }
3516
- return undefined;
3829
+ return usable(this.standardSchemaType(schemaType, at, 'output'));
3830
+ }
3831
+ /**
3832
+ * The input type of a schema value, what a caller may send (carrick#1101):
3833
+ * the Standard Schema member `~standard.types.input`, else a declared
3834
+ * `_input` member. Undefined when the schema declares neither.
3835
+ *
3836
+ * Unlike the output, an input of `unknown` is kept: it is a declared fact
3837
+ * (a coercion accepts any value), and `schemaContract` decides what to
3838
+ * publish for it. An `any` that is really an unresolved library still reads
3839
+ * as no input, because an unresolved schema type has no members to read.
3840
+ */
3841
+ schemaInputType(schemaType, at) {
3842
+ const standard = this.standardSchemaType(schemaType, at, 'input');
3843
+ if (standard) {
3844
+ return standard;
3845
+ }
3846
+ const inputSymbol = schemaType.getProperty('_input');
3847
+ if (!inputSymbol) {
3848
+ return undefined;
3849
+ }
3850
+ try {
3851
+ return inputSymbol.getTypeAtLocation(at);
3852
+ }
3853
+ catch {
3854
+ return undefined;
3855
+ }
3856
+ }
3857
+ /**
3858
+ * `~standard.types.<side>` on a schema value: the Standard Schema
3859
+ * specification's library-neutral statement of a schema's input and output
3860
+ * types. `types` is declared optional, so its `undefined` is stripped before
3861
+ * the side is read.
3862
+ */
3863
+ standardSchemaType(schemaType, at, side) {
3864
+ try {
3865
+ const standard = schemaType.getProperty('~standard');
3866
+ const types = standard?.getTypeAtLocation(at).getProperty('types');
3867
+ const member = types
3868
+ ?.getTypeAtLocation(at)
3869
+ .getNonNullableType()
3870
+ .getProperty(side);
3871
+ return member?.getTypeAtLocation(at);
3872
+ }
3873
+ catch {
3874
+ return undefined;
3875
+ }
3517
3876
  }
3518
3877
  /**
3519
3878
  * Find a node by matching expression text near a target line.
@@ -28,22 +28,51 @@
28
28
  * `type-inferrer.ts` (consumer-side inference), so both paths emit the same
29
29
  * structural form rather than a dangling name.
30
30
  */
31
- import { type Type } from 'ts-morph';
31
+ import { type Node, type Type } from 'ts-morph';
32
32
  /**
33
33
  * Bound on the structural-expansion recursion. Deep enough for every realistic
34
34
  * request/response shape; a backstop against pathological/recursive types the
35
35
  * cycle set somehow misses.
36
36
  */
37
37
  export declare const MAX_EXPANSION_DEPTH = 12;
38
+ /**
39
+ * Types to print at named member positions instead of the walked type
40
+ * (carrick#1105).
41
+ *
42
+ * A validation schema's request contract is its INPUT, except at a member
43
+ * whose input is `unknown` and whose output is concrete (a coercion): that
44
+ * member prints the OUTPUT type. The substitution is per member, so it has to
45
+ * happen inside the walk, where the member is printed.
46
+ *
47
+ * Positions use the member-path notation of the provenance entries: `''` for
48
+ * the root, `sub.field` for a property (keyed by symbol name), `items<0>` for
49
+ * an array element; union and intersection members share their parent's
50
+ * position. The walk records every position it printed an override at in
51
+ * `applied`, so a caller can tell a substitution the walk could not reach (a
52
+ * tuple, a cycle, the depth backstop) from one it made.
53
+ *
54
+ * `at` is the node the member types are read at while walking toward an
55
+ * override. A library's inferred object type is a mapped type whose member
56
+ * declaration sits inside a generic, where the member reads as `any`; read at
57
+ * the schema's own node it is the instantiated type, the same read the
58
+ * caller's position walk makes.
59
+ */
60
+ export interface MemberOverrides {
61
+ readonly types: ReadonlyMap<string, Type>;
62
+ readonly applied: Set<string>;
63
+ readonly at: Node;
64
+ }
38
65
  /**
39
66
  * Recursively render a `Type` as fully-inlined structural text.
40
67
  *
41
68
  * Named object/interface types are expanded to their member structure;
42
69
  * primitives, literals, library types (`Date`, `Promise`, tuples, …) and
43
70
  * functions stay by name. The `seen` set (object type ids on the current
44
- * branch) breaks reference cycles; `depth` is a hard backstop.
71
+ * branch) breaks reference cycles; `depth` is a hard backstop. `overrides`
72
+ * substitutes a type at named member positions (`MemberOverrides`); without
73
+ * it the print is unchanged.
45
74
  */
46
- export declare function expandTypeStructural(type: Type, seen?: Set<number>, depth?: number): string;
75
+ export declare function expandTypeStructural(type: Type, seen?: Set<number>, depth?: number, overrides?: MemberOverrides): string;
47
76
  /**
48
77
  * Non-expanded text for a type. Passes `undefined` as the enclosing node so
49
78
  * the compiler can't throw on an invalid node context (tuples and some