@lovrozagar/oat 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +71 -22
  2. package/dist/cli.js +7 -2
  3. package/dist/config/define-config.d.ts +41 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/reference/defects.d.ts +14 -0
  8. package/dist/reference/defects.d.ts.map +1 -1
  9. package/dist/reference/defects.js +14 -0
  10. package/dist/reference/defects.js.map +1 -1
  11. package/dist/reference/http.d.ts.map +1 -1
  12. package/dist/reference/http.js +4 -1
  13. package/dist/reference/http.js.map +1 -1
  14. package/dist/report/matrix.d.ts.map +1 -1
  15. package/dist/report/matrix.js +2 -0
  16. package/dist/report/matrix.js.map +1 -1
  17. package/dist/report/render.d.ts.map +1 -1
  18. package/dist/report/render.js +28 -5
  19. package/dist/report/render.js.map +1 -1
  20. package/dist/runtime/auth.d.ts.map +1 -1
  21. package/dist/runtime/auth.js +30 -3
  22. package/dist/runtime/auth.js.map +1 -1
  23. package/dist/runtime/body.d.ts +33 -0
  24. package/dist/runtime/body.d.ts.map +1 -0
  25. package/dist/runtime/body.js +218 -0
  26. package/dist/runtime/body.js.map +1 -0
  27. package/dist/runtime/checks.d.ts +3 -0
  28. package/dist/runtime/checks.d.ts.map +1 -1
  29. package/dist/runtime/checks.js +329 -18
  30. package/dist/runtime/checks.js.map +1 -1
  31. package/dist/runtime/client.d.ts +20 -1
  32. package/dist/runtime/client.d.ts.map +1 -1
  33. package/dist/runtime/client.js +207 -8
  34. package/dist/runtime/client.js.map +1 -1
  35. package/dist/runtime/dummy.d.ts +23 -0
  36. package/dist/runtime/dummy.d.ts.map +1 -0
  37. package/dist/runtime/dummy.js +214 -0
  38. package/dist/runtime/dummy.js.map +1 -0
  39. package/dist/runtime/fixture.d.ts.map +1 -1
  40. package/dist/runtime/fixture.js +4 -3
  41. package/dist/runtime/fixture.js.map +1 -1
  42. package/dist/runtime/payloads.d.ts +20 -0
  43. package/dist/runtime/payloads.d.ts.map +1 -0
  44. package/dist/runtime/payloads.js +250 -0
  45. package/dist/runtime/payloads.js.map +1 -0
  46. package/dist/runtime/progress.d.ts +4 -0
  47. package/dist/runtime/progress.d.ts.map +1 -1
  48. package/dist/runtime/progress.js +23 -0
  49. package/dist/runtime/progress.js.map +1 -1
  50. package/dist/runtime/run.d.ts +4 -1
  51. package/dist/runtime/run.d.ts.map +1 -1
  52. package/dist/runtime/run.js +15 -1
  53. package/dist/runtime/run.js.map +1 -1
  54. package/dist/runtime/upload.d.ts +21 -0
  55. package/dist/runtime/upload.d.ts.map +1 -0
  56. package/dist/runtime/upload.js +247 -0
  57. package/dist/runtime/upload.js.map +1 -0
  58. package/dist/runtime/world.d.ts +2 -0
  59. package/dist/runtime/world.d.ts.map +1 -1
  60. package/dist/runtime/world.js +16 -11
  61. package/dist/runtime/world.js.map +1 -1
  62. package/dist/spec/collection.d.ts +13 -0
  63. package/dist/spec/collection.d.ts.map +1 -1
  64. package/dist/spec/collection.js +36 -6
  65. package/dist/spec/collection.js.map +1 -1
  66. package/dist/spec/graph.js +2 -2
  67. package/dist/spec/graph.js.map +1 -1
  68. package/dist/spec/load.d.ts +2 -0
  69. package/dist/spec/load.d.ts.map +1 -1
  70. package/dist/spec/load.js +12 -0
  71. package/dist/spec/load.js.map +1 -1
  72. package/dist/spec/types.d.ts +3 -0
  73. package/dist/spec/types.d.ts.map +1 -1
  74. package/dist/spec/types.js.map +1 -1
  75. package/package.json +1 -1
@@ -6,6 +6,10 @@
6
6
  * the oracle of what oat just wrote. Check ids are stable: the conformance suite asserts on them.
7
7
  */
8
8
  import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
9
+ import { requestContent } from "../spec/collection.js";
10
+ import { pathTemplateMatches } from "../spec/load.js";
11
+ import { encodeForOperation } from "./body.js";
12
+ import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
9
13
  import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
10
14
  import { driveAsync } from "./async.js";
11
15
  import { buildCohort } from "./fixture.js";
@@ -1312,19 +1316,20 @@ const idempotentReplay = {
1312
1316
  return;
1313
1317
  const schema = requestSchemaOf(ctx, createOp);
1314
1318
  if (schema === null) {
1315
- return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no JSON request body to replay");
1319
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no request body to replay");
1316
1320
  }
1317
1321
  const body = validBody(ctx, schema);
1318
1322
  const key = `oat-idem-${ctx.seed}-${ctx.entityName}`;
1319
1323
  const path = fillPath(createOp.path, ctx.scope);
1320
1324
  const headers = { ...ctx.auth(), [header]: key };
1321
- const first = await ctx.client.request("POST", path, { body, headers });
1325
+ const encoded = await encodeOpBody(ctx, createOp, body);
1326
+ const first = await ctx.client.request("POST", path, { ...encoded, headers });
1322
1327
  if (standDownForFeatureGate(ctx, createOp, first, this.id))
1323
1328
  return;
1324
1329
  if (first.status >= 300) {
1325
1330
  return ctx.findings.unresolved(this.id, ctx.entityName, `the first create returned ${first.status}, so there was nothing to replay`);
1326
1331
  }
1327
- const second = await ctx.client.request("POST", path, { body, headers });
1332
+ const second = await ctx.client.request("POST", path, { ...encoded, headers });
1328
1333
  if (standDownForFeatureGate(ctx, createOp, second, this.id))
1329
1334
  return;
1330
1335
  if (second.status >= 300) {
@@ -1370,7 +1375,7 @@ const declaredInvalidationHappens = {
1370
1375
  return;
1371
1376
  const schema = requestSchemaOf(ctx, createOp);
1372
1377
  if (schema === null) {
1373
- return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no JSON request body, so no write can be made");
1378
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the create operation declares no request body, so no write can be made");
1374
1379
  }
1375
1380
  /* Only routes whose path can be filled from this entity's scope are probed: a route
1376
1381
  * needing an identifier oat does not hold would 404 for a reason unrelated to staleness. */
@@ -1395,7 +1400,7 @@ const declaredInvalidationHappens = {
1395
1400
  return ctx.findings.unresolved(this.id, ctx.entityName, "none of the declared routes could be read before the write");
1396
1401
  }
1397
1402
  const created = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
1398
- body: validBody(ctx, schema),
1403
+ ...(await encodeOpBody(ctx, createOp, validBody(ctx, schema))),
1399
1404
  headers: ctx.auth(),
1400
1405
  });
1401
1406
  if (standDownForFeatureGate(ctx, createOp, created, this.id))
@@ -2775,7 +2780,9 @@ const createPersistsFields = {
2775
2780
  const sent = createExchange(ctx);
2776
2781
  if (sent === undefined || sent.requestBody === undefined)
2777
2782
  return;
2778
- const request = sent.requestBody;
2783
+ const request = submittedFields(sent.requestBody);
2784
+ if (Object.keys(request).length === 0)
2785
+ return;
2779
2786
  const response = (sent.responseBody ?? {});
2780
2787
  const dropped = Object.entries(request).filter(([key, value]) => {
2781
2788
  if (value === null || value === undefined)
@@ -2811,7 +2818,7 @@ const enumValidated = {
2811
2818
  return;
2812
2819
  const body = { ...validBody(ctx, schema), [target.name]: "oat-not-a-member" };
2813
2820
  const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
2814
- body,
2821
+ ...(await encodeOpBody(ctx, createOp, body)),
2815
2822
  headers: ctx.auth(),
2816
2823
  });
2817
2824
  if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
@@ -2842,7 +2849,7 @@ const maxLengthValidated = {
2842
2849
  const max = target.schema.maxLength;
2843
2850
  const body = { ...validBody(ctx, schema), [target.name]: "x".repeat(max + 25) };
2844
2851
  const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
2845
- body,
2852
+ ...(await encodeOpBody(ctx, createOp, body)),
2846
2853
  headers: ctx.auth(),
2847
2854
  });
2848
2855
  if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
@@ -2871,7 +2878,7 @@ const requiredValidated = {
2871
2878
  return;
2872
2879
  const { [field]: _omitted, ...body } = validBody(ctx, schema);
2873
2880
  const exchange = await ctx.client.request("POST", fillPath(createOp.path, ctx.scope), {
2874
- body,
2881
+ ...(await encodeOpBody(ctx, createOp, body)),
2875
2882
  headers: ctx.auth(),
2876
2883
  });
2877
2884
  if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
@@ -2971,6 +2978,8 @@ const successSchemaHonoured = {
2971
2978
  const exchange = createExchange(ctx);
2972
2979
  if (exchange === undefined)
2973
2980
  return;
2981
+ if (isEventStream(exchange))
2982
+ return;
2974
2983
  if (!validator.documents(raw, exchange.status)) {
2975
2984
  return ctx.findings.unresolved(this.id, ctx.entityName, `create returned ${exchange.status}, which the document declares no schema for — ` +
2976
2985
  "there is nothing to validate the body against");
@@ -3005,14 +3014,34 @@ function bodyForOp(ctx, op) {
3005
3014
  }
3006
3015
  function requestSchemaOf(ctx, op) {
3007
3016
  const raw = ctx.model.rawOperations.get(op.operationId);
3008
- const content = raw?.requestBody?.content;
3009
- if (content === undefined)
3010
- return null;
3011
- for (const [mediaType, media] of Object.entries(content)) {
3012
- if (mediaType.includes("json") && media.schema !== undefined)
3013
- return media.schema;
3017
+ const picked = raw === undefined ? null : requestContent(raw);
3018
+ return picked === null ? null : picked.schema;
3019
+ }
3020
+ async function encodeOpBody(ctx, op, fields, variant = "baseline", index = 0) {
3021
+ const encoded = await encodeForOperation(op, ctx.model, fields, ctx.uploads, variant, index);
3022
+ return encoded.contentType === undefined
3023
+ ? { body: encoded.body }
3024
+ : { body: encoded.body, contentType: encoded.contentType };
3025
+ }
3026
+ function submittedFields(body) {
3027
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
3028
+ const out = {};
3029
+ for (const [name, value] of body.entries()) {
3030
+ if (typeof value === "string")
3031
+ out[name] = value;
3032
+ }
3033
+ return out;
3014
3034
  }
3015
- return null;
3035
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
3036
+ return Object.fromEntries(body.entries());
3037
+ }
3038
+ if (body !== null && typeof body === "object" && !Array.isArray(body))
3039
+ return body;
3040
+ return {};
3041
+ }
3042
+ function isEventStream(exchange) {
3043
+ const type = exchange.responseHeaders["content-type"] ?? "";
3044
+ return type.includes("text/event-stream");
3016
3045
  }
3017
3046
  function findConstrained(schema, predicate) {
3018
3047
  const props = schema.properties;
@@ -3265,7 +3294,7 @@ const declaredEffectsOccur = {
3265
3294
  const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3266
3295
  const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3267
3296
  headers: ctx.auth(),
3268
- ...(body === undefined ? {} : { body }),
3297
+ ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
3269
3298
  });
3270
3299
  if (standDownForFeatureGate(ctx, op, invoked, this.id))
3271
3300
  continue;
@@ -3337,7 +3366,7 @@ const asyncReachesTerminalState = {
3337
3366
  const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3338
3367
  const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3339
3368
  headers: ctx.auth(),
3340
- ...(body === undefined ? {} : { body }),
3369
+ ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
3341
3370
  });
3342
3371
  if (standDownForFeatureGate(ctx, op, start, this.id))
3343
3372
  continue;
@@ -3405,6 +3434,284 @@ const asyncReceiptIsResolvable = {
3405
3434
  }
3406
3435
  },
3407
3436
  };
3437
+ const ASCII_PAYLOAD_PROBE = "oat-payload-probe";
3438
+ function pickPayloadField(ctx, op) {
3439
+ const schema = requestSchemaOf(ctx, op);
3440
+ const properties = (schema?.properties ?? {});
3441
+ const immutable = new Set([
3442
+ ...(ctx.updateOp?.immutable ?? []),
3443
+ ...(ctx.updateOp?.generated ?? []),
3444
+ ...(ctx.createOp?.generated ?? []),
3445
+ ctx.identity,
3446
+ ]);
3447
+ let best = null;
3448
+ for (const [name, declared] of Object.entries(properties)) {
3449
+ if (immutable.has(name) || /_at$|_id$/.test(name))
3450
+ continue;
3451
+ if (declared === null || typeof declared !== "object")
3452
+ continue;
3453
+ if (declared.readOnly === true)
3454
+ continue;
3455
+ const union = declared.oneOf ?? declared.anyOf;
3456
+ const branch = Array.isArray(union)
3457
+ ? union.find((candidate) => candidate !== null && typeof candidate === "object" && candidate.type === "string")
3458
+ : undefined;
3459
+ const stringSchema = branch ?? declared;
3460
+ const type = stringSchema.type;
3461
+ const isString = type === "string" || (Array.isArray(type) && type.includes("string"));
3462
+ if (!isString && type !== undefined)
3463
+ continue;
3464
+ if (Array.isArray(stringSchema.enum))
3465
+ continue;
3466
+ if (typeof stringSchema.pattern === "string")
3467
+ continue;
3468
+ if (typeof stringSchema.format === "string")
3469
+ continue;
3470
+ const maxLength = typeof stringSchema.maxLength === "number" ? stringSchema.maxLength : undefined;
3471
+ const minLength = typeof stringSchema.minLength === "number" ? stringSchema.minLength : undefined;
3472
+ if (maxLength !== undefined && maxLength < 8)
3473
+ continue;
3474
+ const nullable = declared.nullable === true ||
3475
+ (Array.isArray(declared.type) && declared.type.includes("null")) ||
3476
+ (Array.isArray(union) &&
3477
+ union.some((candidate) => candidate !== null && typeof candidate === "object" && candidate.type === "null"));
3478
+ const score = (nullable ? 1000 : 0) + (maxLength ?? 1024);
3479
+ if (best === null || score > best.score)
3480
+ best = { maxLength, minLength, name, score };
3481
+ }
3482
+ return best;
3483
+ }
3484
+ const stringPayloadSurvives = {
3485
+ applicable: (ctx) => ctx.readOp !== undefined &&
3486
+ ctx.records.length > 0 &&
3487
+ (ctx.updateOp !== undefined || (ctx.createOp !== undefined && ctx.deleteOp !== undefined)),
3488
+ dependsOn: ["list.read-after-write"],
3489
+ id: "payload.string-survives",
3490
+ mutates: true,
3491
+ needs: "an update or create+delete, an item route, and a writable unconstrained string",
3492
+ async run(ctx) {
3493
+ const writeOp = ctx.updateOp ?? ctx.createOp;
3494
+ const readOp = ctx.readOp;
3495
+ if (writeOp === undefined || readOp === undefined)
3496
+ return;
3497
+ const field = pickPayloadField(ctx, writeOp);
3498
+ if (field === null) {
3499
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no writable unconstrained string field is available to probe payloads against");
3500
+ }
3501
+ const viaPatch = ctx.updateOp !== undefined;
3502
+ const target = ctx.records.at(-1) ?? ctx.records[0];
3503
+ if (target === undefined)
3504
+ return;
3505
+ const id = String(target[ctx.identity]);
3506
+ const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
3507
+ const original = target[field.name];
3508
+ const writeField = async (value, recordId) => {
3509
+ if (viaPatch && ctx.updateOp !== undefined) {
3510
+ return ctx.client.request("PATCH", fillPath(ctx.updateOp.path, { ...ctx.scope, ...itemParamFor(ctx, recordId) }), {
3511
+ body: { [field.name]: value },
3512
+ headers: ctx.auth(),
3513
+ });
3514
+ }
3515
+ const createOp = ctx.createOp;
3516
+ if (createOp === undefined)
3517
+ throw new Error("payload probe has no write operation");
3518
+ const body = { ...validBody(ctx, requestSchemaOf(ctx, createOp) ?? {}), [field.name]: value };
3519
+ return ctx.client.request("POST", fillPath(createOp.path, ctx.scope), { body, headers: ctx.auth() });
3520
+ };
3521
+ const readField = async (recordId) => {
3522
+ const exchange = await ctx.client.get(fillPath(readOp.path, { ...ctx.scope, ...itemParamFor(ctx, recordId) }), {
3523
+ headers: ctx.auth(),
3524
+ });
3525
+ const body = (exchange.responseBody ?? {});
3526
+ return { exchange, value: body[field.name] };
3527
+ };
3528
+ const remove = async (recordId) => {
3529
+ if (viaPatch || ctx.deleteOp === undefined)
3530
+ return;
3531
+ await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, { ...ctx.scope, ...itemParamFor(ctx, recordId) }), {
3532
+ headers: ctx.auth(),
3533
+ });
3534
+ };
3535
+ const identityOf = (exchange) => {
3536
+ const body = exchange.responseBody;
3537
+ if (body === null || typeof body !== "object")
3538
+ return undefined;
3539
+ const value = body[ctx.identity];
3540
+ return value === undefined || value === null ? undefined : String(value);
3541
+ };
3542
+ try {
3543
+ const control = await writeField(ASCII_PAYLOAD_PROBE, id);
3544
+ if (standDownForFeatureGate(ctx, writeOp, control, this.id))
3545
+ return;
3546
+ if (control.status === 401 || control.status === 403) {
3547
+ return ctx.findings.unresolved(this.id, ctx.entityName, `the ASCII control write returned ${control.status}, so later refusals cannot be attributed to the payload`);
3548
+ }
3549
+ if (control.status >= 400 && control.status < 500) {
3550
+ return ctx.findings.unresolved(this.id, ctx.entityName, `the ASCII control write was rejected with ${control.status}, so the field refuses ordinary strings`);
3551
+ }
3552
+ if (control.status >= 500) {
3553
+ ctx.findings.backend(this.id, ctx.entityName, "an ordinary ASCII write returned 5xx", `PATCH/POST of "${ASCII_PAYLOAD_PROBE}" on "${field.name}" returned ${control.status}.`, [control]);
3554
+ return;
3555
+ }
3556
+ const controlId = viaPatch ? id : identityOf(control);
3557
+ if (controlId === undefined) {
3558
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the ASCII control write did not return an identity, so the value could not be read back");
3559
+ }
3560
+ const controlRead = await readField(controlId);
3561
+ if (!viaPatch)
3562
+ await remove(controlId);
3563
+ if (controlRead.value !== ASCII_PAYLOAD_PROBE) {
3564
+ ctx.findings.backend(this.id, ctx.entityName, "an ordinary ASCII write did not persist exactly", `"${field.name}" was sent ${JSON.stringify(ASCII_PAYLOAD_PROBE)} and read back ` +
3565
+ `${JSON.stringify(controlRead.value)}.`, [control, controlRead.exchange]);
3566
+ return;
3567
+ }
3568
+ const failed = [];
3569
+ const evidence = [];
3570
+ let lostAuth = false;
3571
+ for (const payload of STRING_PAYLOADS) {
3572
+ if (!payloadFits(payload.value, field.maxLength, field.minLength))
3573
+ continue;
3574
+ const written = await writeField(payload.value, id);
3575
+ if (standDownForFeatureGate(ctx, writeOp, written, this.id))
3576
+ return;
3577
+ if (written.status === 401) {
3578
+ const retry = await writeField(ASCII_PAYLOAD_PROBE, id);
3579
+ if (retry.status >= 400) {
3580
+ lostAuth = true;
3581
+ break;
3582
+ }
3583
+ failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got HTTP 401`);
3584
+ if (evidence.length < 4)
3585
+ evidence.push(written);
3586
+ continue;
3587
+ }
3588
+ if (written.status === 404 || written.status === 409 || written.status === 415 || written.status === 429) {
3589
+ continue;
3590
+ }
3591
+ if (written.status >= 400 && written.status < 500) {
3592
+ failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got HTTP ${written.status}`);
3593
+ if (evidence.length < 4)
3594
+ evidence.push(written);
3595
+ if (!viaPatch) {
3596
+ const extra = identityOf(written);
3597
+ if (extra !== undefined)
3598
+ await remove(extra);
3599
+ }
3600
+ continue;
3601
+ }
3602
+ if (written.status >= 500) {
3603
+ failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got HTTP ${written.status}`);
3604
+ if (evidence.length < 4)
3605
+ evidence.push(written);
3606
+ continue;
3607
+ }
3608
+ const writtenId = viaPatch ? id : identityOf(written);
3609
+ if (writtenId === undefined) {
3610
+ failed.push(`${payload.id} (${payload.why}): write succeeded but returned no identity`);
3611
+ continue;
3612
+ }
3613
+ const got = await readField(writtenId);
3614
+ if (!viaPatch)
3615
+ await remove(writtenId);
3616
+ if (got.value !== payload.value) {
3617
+ failed.push(`${payload.id} (${payload.why}): sent ${JSON.stringify(payload.value)}, got ${JSON.stringify(got.value)}`);
3618
+ if (evidence.length < 4)
3619
+ evidence.push(written, got.exchange);
3620
+ }
3621
+ }
3622
+ if (lostAuth && failed.length === 0) {
3623
+ return ctx.findings.unresolved(this.id, ctx.entityName, "authentication failed in the middle of the payload catalog, so remaining cases were not run");
3624
+ }
3625
+ if (failed.length === 0)
3626
+ return;
3627
+ ctx.findings.backend(this.id, ctx.entityName, "a documented-valid string did not survive a write", `"${field.name}" failed ${failed.length} payload case(s): ${failed.slice(0, 6).join("; ")}` +
3628
+ (failed.length > 6 ? `; and ${failed.length - 6} more` : "") +
3629
+ ". The document accepts these as strings; refusing or mutating them is silent corruption " +
3630
+ "or an undocumented constraint.", evidence);
3631
+ }
3632
+ finally {
3633
+ if (viaPatch && ctx.updateOp !== undefined) {
3634
+ await ctx.client.request("PATCH", fillPath(ctx.updateOp.path, params), {
3635
+ body: { [field.name]: original ?? null },
3636
+ headers: ctx.auth(),
3637
+ });
3638
+ }
3639
+ }
3640
+ },
3641
+ };
3642
+ function resolveEntityOperation(ctx, method, pathname) {
3643
+ let best = null;
3644
+ for (const op of ctx.model.operations) {
3645
+ if (op.method !== method)
3646
+ continue;
3647
+ if (op.entity !== ctx.entityName)
3648
+ continue;
3649
+ if (!pathTemplateMatches(op.path, pathname))
3650
+ continue;
3651
+ if (best === null || op.path.length > best.path.length)
3652
+ best = op;
3653
+ }
3654
+ return best;
3655
+ }
3656
+ function statusIsDeclared(raw, status) {
3657
+ const keys = Object.keys(raw?.responses ?? {});
3658
+ if (keys.includes(String(status)))
3659
+ return true;
3660
+ return keys.includes(`${Math.floor(status / 100)}XX`);
3661
+ }
3662
+ function declaresConcreteStatuses(raw) {
3663
+ return Object.keys(raw?.responses ?? {}).some((key) => /^\d{3}$/.test(key) || /^[1-5]XX$/i.test(key));
3664
+ }
3665
+ const documentedStatusHonoured = {
3666
+ applicable: (ctx) => ctx.model.operations.some((op) => op.entity === ctx.entityName && op.action !== "create"),
3667
+ id: "response.status-is-documented",
3668
+ needs: "a modeled non-create operation on this entity",
3669
+ async run(ctx) {
3670
+ const createId = ctx.createOp?.operationId;
3671
+ const byOp = new Map();
3672
+ for (const exchange of ctx.client.transcript) {
3673
+ let pathname;
3674
+ try {
3675
+ pathname = new URL(exchange.url).pathname;
3676
+ }
3677
+ catch {
3678
+ continue;
3679
+ }
3680
+ const op = resolveEntityOperation(ctx, exchange.method, pathname);
3681
+ if (op === null)
3682
+ continue;
3683
+ if (op.operationId === createId || op.action === "create")
3684
+ continue;
3685
+ if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
3686
+ continue;
3687
+ const raw = ctx.model.rawOperations.get(op.operationId);
3688
+ if (!declaresConcreteStatuses(raw))
3689
+ continue;
3690
+ if (statusIsDeclared(raw, exchange.status))
3691
+ continue;
3692
+ const seen = byOp.get(op.operationId) ?? [];
3693
+ seen.push(exchange);
3694
+ byOp.set(op.operationId, seen);
3695
+ }
3696
+ if (byOp.size === 0)
3697
+ return;
3698
+ const lines = [];
3699
+ const evidence = [];
3700
+ for (const [operationId, exchanges] of byOp) {
3701
+ const raw = ctx.model.rawOperations.get(operationId);
3702
+ const declared = Object.keys(raw?.responses ?? {})
3703
+ .filter((key) => key !== "default")
3704
+ .sort()
3705
+ .join(", ");
3706
+ const seen = [...new Set(exchanges.map((item) => item.status))].sort((a, b) => a - b);
3707
+ lines.push(`${operationId} returned ${seen.join(", ")}; the document declares ${declared || "no concrete status"}`);
3708
+ const first = exchanges[0];
3709
+ if (first !== undefined && evidence.length < 6)
3710
+ evidence.push(first);
3711
+ }
3712
+ ctx.findings.spec(this.id, ctx.entityName, "an operation returned a status the document does not declare", `${lines.join(". ")}. Clients generated from this document will not recognise the response.`, evidence);
3713
+ },
3714
+ };
3408
3715
  export const CHECKS = [
3409
3716
  /* foundations — did the write land, is it visible, and do the paging primitives work at all.
3410
3717
  * Everything below assumes these hold, so they must be evaluated first for cascade
@@ -3442,6 +3749,7 @@ export const CHECKS = [
3442
3749
  /* write semantics */
3443
3750
  patchMinimality,
3444
3751
  immutableRejected,
3752
+ stringPayloadSurvives,
3445
3753
  idempotentReplay,
3446
3754
  declaredInvalidationHappens,
3447
3755
  projectionsAgree,
@@ -3474,5 +3782,8 @@ export const CHECKS = [
3474
3782
  declaredEffectsOccur,
3475
3783
  asyncReachesTerminalState,
3476
3784
  asyncReceiptIsResolvable,
3785
+ /* After every other check has written to the transcript — create is owned by
3786
+ * create.status-matches-document. */
3787
+ documentedStatusHonoured,
3477
3788
  ];
3478
3789
  //# sourceMappingURL=checks.js.map