@jarenjs/contract 0.43.1

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 (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
package/src/diff.js ADDED
@@ -0,0 +1,610 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `diffContracts(a, b)`: what changed from contract `a` to contract
4
+ * `b`, classified by the published rule table (docs/CONTRACT-FORMAT.md
5
+ * §13, rows R1–R15) into `breaking`, `additive`, `neutral` and `unknown`
6
+ * — where `unknown` is the honest fourth class: a schema construct the
7
+ * checker does not model (`anyOf`, `if`, a changed `pattern`, an external
8
+ * `$ref` that moved) is REPORTED, never silently classed.
9
+ *
10
+ * The schema comparison walks the two sides in parallel over the resolved
11
+ * same-document structure — a bare `{ "$ref": "#/$defs/X" }` hop is
12
+ * followed with the same rules the compiler used — and models exactly the
13
+ * R6 keyword set (`type`, `const`, `enum`, `maximum`, `minimum`,
14
+ * `maxLength`, `minLength`, `pattern`) plus object structure
15
+ * (`properties`, `required`, `additionalProperties`) and `items`. Pure
16
+ * annotations (`title`, `description`, `examples`, `$comment`,
17
+ * `deprecated`) never move a wire byte and are ignored; every other
18
+ * keyword that differs between the two sides lands in `unknown` (R15).
19
+ *
20
+ * A `Change`'s `docPath` points into the document that carries it — a
21
+ * removal into `a`, everything else into `b` — composed over the RESOLVED
22
+ * structure, so a constraint reached through a `$ref` reports the path a
23
+ * validator error would name, not the `$defs` entry's.
24
+ */
25
+
26
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
27
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
28
+ import { collectSameDocumentAnchors, resolveSameDocumentRef } from '@jarenjs/validate/normalize';
29
+
30
+ import { compileContract } from './compile.js';
31
+ import { isCompiledContract } from './public.js';
32
+ import { pathShape } from './path.js';
33
+
34
+ export { isCompatible, compatReason } from './compat.js';
35
+
36
+ /**
37
+ * @typedef {import('./compile.js').Contract} Contract
38
+ * @typedef {import('./compile.js').CompiledOperation} CompiledOperation
39
+ */
40
+
41
+ /**
42
+ * One classified change.
43
+ * @typedef {Object} Change
44
+ * @property {string} kind - a stable slug naming what changed (`'operation-removed'`, `'input-narrowed'`, …)
45
+ * @property {string} op - the operation id
46
+ * @property {string} docPath - RFC 6901 pointer to the change (into `a` for a removal, into `b` otherwise)
47
+ * @property {unknown} [from] - the old value, where one exists
48
+ * @property {unknown} [to] - the new value, where one exists
49
+ * @property {string} rule - the §13 row: `'R1'`–`'R15'`
50
+ * @property {string} [note] - the honesty rider some rows carry (R5's "now ignored, not validated")
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} ContractDiff
55
+ * @property {Change[]} breaking
56
+ * @property {Change[]} additive
57
+ * @property {Change[]} neutral
58
+ * @property {Change[]} unknown
59
+ */
60
+
61
+ /** Keywords compared as constraints (the R6 set). */
62
+ const CONSTRAINTS = ['type', 'const', 'enum', 'maximum', 'minimum', 'maxLength', 'minLength', 'pattern'];
63
+ const CONSTRAINT_SET = new Set(CONSTRAINTS);
64
+
65
+ /** Keywords the object-structure pass owns. */
66
+ const STRUCTURE = new Set(['properties', 'required', 'additionalProperties', 'items']);
67
+
68
+ /** Pure annotations — no wire behavior, never reported. */
69
+ const ANNOTATIONS = new Set(['title', 'description', 'examples', '$comment', 'deprecated']);
70
+
71
+ /** Resolution artifacts the parallel walk consults, never compares. */
72
+ const RESOLUTION = new Set(['$ref', '$defs', 'definitions', '$anchor', '$id', '$schema']);
73
+
74
+ /** The stable canonical text of any JSON value (for deep equality). */
75
+ const canon = (/** @type {unknown} */ v) => (v === undefined ? 'undefined' : canonicalizeJson(v));
76
+
77
+ /**
78
+ * @param {unknown} value
79
+ * @returns {value is Record<string, any>}
80
+ */
81
+ function isObject(value) {
82
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
83
+ }
84
+
85
+ /**
86
+ * One side of the parallel walk: the schema resolution scope.
87
+ * @typedef {{ doc: any, anchors: Map<string, object> }} Side
88
+ */
89
+
90
+ /**
91
+ * Resolve bare `{ $ref }` hops (same-document only). Returns the resolved
92
+ * node, or a marker for the shapes the walk cannot model.
93
+ * @param {any} node
94
+ * @param {Side} side
95
+ * @returns {{ node: any } | { external: string } | { opaque: true }}
96
+ */
97
+ function resolveHops(node, side) {
98
+ const seen = new Set();
99
+ while (isObject(node) && typeof node.$ref === 'string') {
100
+ if (Object.keys(node).length > 1) return { opaque: true }; // $ref with siblings — not modeled
101
+ if (!node.$ref.startsWith('#')) return { external: node.$ref };
102
+ if (seen.has(node)) return { opaque: true };
103
+ seen.add(node);
104
+ const target = resolveSameDocumentRef(node.$ref, side.doc, side.anchors);
105
+ if (target === undefined) return { opaque: true };
106
+ node = target;
107
+ }
108
+ return { node };
109
+ }
110
+
111
+ /**
112
+ * The event sink of one schema comparison.
113
+ * @typedef {Object} Sink
114
+ * @property {(direction: 'narrowed' | 'widened', keyword: string, path: string, from: unknown, to: unknown) => void} constraint
115
+ * @property {(event: 'removed' | 'added-required' | 'added-optional' | 'made-required' | 'made-optional',
116
+ * direction: 'narrowed' | 'widened' | null, path: string, member: string) => void} member
117
+ * `direction` is the AP-aware narrowing/widening reading of the member
118
+ * event (`null` when it is a no-op, e.g. an unconstrained optional
119
+ * member added to an open object)
120
+ * @property {(keyword: string, path: string, from: unknown, to: unknown) => void} unknown
121
+ */
122
+
123
+ /**
124
+ * Compare two schema nodes in parallel over the modeled structure.
125
+ * @param {any} aNode
126
+ * @param {any} bNode
127
+ * @param {string} path - pointer relative to the schema roots
128
+ * @param {Side} a
129
+ * @param {Side} b
130
+ * @param {Sink} sink
131
+ * @param {Map<object, Set<object>>} visited - pair memo (recursive schemas terminate)
132
+ */
133
+ function compareSchema(aNode, bNode, path, a, b, sink, visited) {
134
+ const ra = resolveHops(aNode, a);
135
+ const rb = resolveHops(bNode, b);
136
+ if ('external' in ra || 'external' in rb) {
137
+ const from = 'external' in ra ? ra.external : undefined;
138
+ const to = 'external' in rb ? rb.external : undefined;
139
+ if (from !== to) sink.unknown('$ref', path, aNode, bNode);
140
+ return;
141
+ }
142
+ if ('opaque' in ra || 'opaque' in rb) {
143
+ if (canon(aNode) !== canon(bNode)) sink.unknown('$ref', path, aNode, bNode);
144
+ return;
145
+ }
146
+ let A = ra.node;
147
+ let B = rb.node;
148
+ // booleans: `true` is the empty schema, `false` accepts nothing
149
+ if (A === false || B === false) {
150
+ if (A === false && B === false) return;
151
+ if (B === false) sink.constraint('narrowed', 'schema', path, A, false);
152
+ else sink.constraint('widened', 'schema', path, false, B);
153
+ return;
154
+ }
155
+ if (A === true) A = {};
156
+ if (B === true) B = {};
157
+ if (!isObject(A) || !isObject(B)) {
158
+ if (canon(A) !== canon(B)) sink.unknown('schema', path, A, B);
159
+ return;
160
+ }
161
+ if (A === B) return;
162
+ let pairs = visited.get(A);
163
+ if (pairs !== undefined && pairs.has(B)) return;
164
+ if (pairs === undefined) visited.set(A, (pairs = new Set()));
165
+ pairs.add(B);
166
+
167
+ const keys = new Set([...Object.keys(A), ...Object.keys(B)]);
168
+ let structure = false;
169
+ for (const key of keys) {
170
+ if (ANNOTATIONS.has(key) || RESOLUTION.has(key)) continue;
171
+ if (CONSTRAINT_SET.has(key)) continue; // the constraint pass below
172
+ if (STRUCTURE.has(key)) {
173
+ structure = true;
174
+ continue;
175
+ }
176
+ if (canon(A[key]) !== canon(B[key])) sink.unknown(key, path + '/' + key, A[key], B[key]);
177
+ }
178
+ compareConstraints(A, B, path, sink);
179
+ if (structure) {
180
+ compareObject(A, B, path, a, b, sink, visited);
181
+ if (A.items !== undefined || B.items !== undefined) {
182
+ if (A.items === undefined) sink.constraint('narrowed', 'items', path + '/items', undefined, B.items);
183
+ else if (B.items === undefined) sink.constraint('widened', 'items', path + '/items', A.items, undefined);
184
+ else compareSchema(A.items, B.items, path + '/items', a, b, sink, visited);
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The R6 keyword set, leaf by leaf.
191
+ * @param {Record<string, any>} A
192
+ * @param {Record<string, any>} B
193
+ * @param {string} path
194
+ * @param {Sink} sink
195
+ */
196
+ function compareConstraints(A, B, path, sink) {
197
+ const emit = (/** @type {'narrowed' | 'widened'} */ d, /** @type {string} */ k) =>
198
+ sink.constraint(d, k, path + '/' + k, A[k], B[k]);
199
+
200
+ // type and enum/const compare as value sets: what was removed narrows,
201
+ // what was added widens — an incomparable change (string → integer)
202
+ // honestly reports BOTH
203
+ const sets = [
204
+ ['type', (/** @type {any} */ v) => (v === undefined ? null : Array.isArray(v) ? v : [v])],
205
+ ['enum', (/** @type {any} */ v) => (v === undefined ? null : v)],
206
+ ['const', (/** @type {any} */ v) => (v === undefined ? null : [v])],
207
+ ];
208
+ for (const [key, toSet] of /** @type {[string, (v: any) => any[] | null][]} */ (sets)) {
209
+ if (canon(A[key]) === canon(B[key])) continue;
210
+ const before = toSet(A[key]);
211
+ const after = toSet(B[key]);
212
+ if (before === null) emit('narrowed', key); // unconstrained → constrained
213
+ else if (after === null) emit('widened', key);
214
+ else {
215
+ const beforeTexts = new Set(before.map(canon));
216
+ const afterTexts = new Set(after.map(canon));
217
+ if ([...beforeTexts].some((t) => !afterTexts.has(t))) emit('narrowed', key);
218
+ if ([...afterTexts].some((t) => !beforeTexts.has(t))) emit('widened', key);
219
+ }
220
+ }
221
+
222
+ // numeric/length bounds: which way did the accepted range move?
223
+ for (const [key, tighterWhen] of /** @type {[string, 'lower' | 'higher'][]} */ ([
224
+ ['maximum', 'lower'], ['maxLength', 'lower'], ['minimum', 'higher'], ['minLength', 'higher'],
225
+ ])) {
226
+ const from = A[key];
227
+ const to = B[key];
228
+ if (from === to) continue;
229
+ if (typeof from !== 'number' && from !== undefined) { sink.unknown(key, path + '/' + key, from, to); continue; }
230
+ if (typeof to !== 'number' && to !== undefined) { sink.unknown(key, path + '/' + key, from, to); continue; }
231
+ if (from === undefined) emit('narrowed', key);
232
+ else if (to === undefined) emit('widened', key);
233
+ else if (tighterWhen === 'lower' ? to < from : to > from) emit('narrowed', key);
234
+ else emit('widened', key);
235
+ }
236
+
237
+ if (A.pattern !== B.pattern) {
238
+ if (A.pattern === undefined) emit('narrowed', 'pattern');
239
+ else if (B.pattern === undefined) emit('widened', 'pattern');
240
+ else sink.unknown('pattern', path + '/pattern', A.pattern, B.pattern); // two regex languages are not comparable
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Object structure: members, `required`, `additionalProperties`.
246
+ * @param {Record<string, any>} A
247
+ * @param {Record<string, any>} B
248
+ * @param {string} path
249
+ * @param {Side} a
250
+ * @param {Side} b
251
+ * @param {Sink} sink
252
+ * @param {Map<object, Set<object>>} visited
253
+ */
254
+ function compareObject(A, B, path, a, b, sink, visited) {
255
+ const aProps = isObject(A.properties) ? A.properties : {};
256
+ const bProps = isObject(B.properties) ? B.properties : {};
257
+ const aReq = new Set(Array.isArray(A.required) ? A.required : []);
258
+ const bReq = new Set(Array.isArray(B.required) ? B.required : []);
259
+ const aClosed = A.additionalProperties === false;
260
+ const bClosed = B.additionalProperties === false;
261
+
262
+ for (const m of new Set([...Object.keys(aProps), ...Object.keys(bProps)])) {
263
+ const at = path + '/properties/' + encodeJSONPointerSegment(m);
264
+ const inA = Object.hasOwn(aProps, m);
265
+ const inB = Object.hasOwn(bProps, m);
266
+ if (inA && !inB) {
267
+ // removed: forbidden under a closed b, unconstrained under an open one
268
+ sink.member('removed', bClosed ? 'narrowed' : 'widened', at, m);
269
+ continue;
270
+ }
271
+ if (!inA && inB) {
272
+ const unconstrained = bProps[m] === true || (isObject(bProps[m]) && Object.keys(bProps[m]).length === 0);
273
+ if (bReq.has(m)) sink.member('added-required', 'narrowed', at, m);
274
+ else if (aClosed) sink.member('added-optional', 'widened', at, m); // was forbidden, now allowed
275
+ else sink.member('added-optional', unconstrained ? null : 'narrowed', at, m); // was unconstrained
276
+ continue;
277
+ }
278
+ if (!aReq.has(m) && bReq.has(m)) sink.member('made-required', 'narrowed', at, m);
279
+ else if (aReq.has(m) && !bReq.has(m)) sink.member('made-optional', 'widened', at, m);
280
+ compareSchema(aProps[m], bProps[m], at, a, b, sink, visited);
281
+ }
282
+
283
+ // a required name without a properties entry is still a requirement
284
+ for (const m of bReq) {
285
+ if (!aReq.has(m) && !Object.hasOwn(bProps, m) && !Object.hasOwn(aProps, m)) {
286
+ sink.member('added-required', 'narrowed', path + '/required', m);
287
+ }
288
+ }
289
+ for (const m of aReq) {
290
+ if (!bReq.has(m) && !Object.hasOwn(aProps, m) && !Object.hasOwn(bProps, m)) {
291
+ sink.member('made-optional', 'widened', path + '/required', m);
292
+ }
293
+ }
294
+
295
+ const aAp = A.additionalProperties === undefined ? true : A.additionalProperties;
296
+ const bAp = B.additionalProperties === undefined ? true : B.additionalProperties;
297
+ if (canon(aAp) !== canon(bAp)) {
298
+ compareSchema(aAp, bAp, path + '/additionalProperties', a, b, sink, visited);
299
+ }
300
+ }
301
+
302
+ //#region the operation walk
303
+
304
+ /** @param {string} id */
305
+ const opPath = (/** @type {string} */ id) => '/operations/' + encodeJSONPointerSegment(id);
306
+
307
+ /**
308
+ * @param {Change[]} into
309
+ * @param {Change} change
310
+ */
311
+ const push = (into, change) => { into.push(change); };
312
+
313
+ /**
314
+ * The rule table's classification of one rule id.
315
+ * @param {ContractDiff} diff
316
+ * @param {string} rule
317
+ * @returns {Change[]}
318
+ */
319
+ function classOf(diff, rule) {
320
+ switch (rule) {
321
+ case 'R2': case 'R7': case 'R9': case 'R11': return diff.additive;
322
+ case 'R13': return diff.neutral;
323
+ case 'R15': return diff.unknown;
324
+ default: return diff.breaking;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * A sink whose events land as classified changes of one operation.
330
+ * @param {ContractDiff} diff
331
+ * @param {string} op
332
+ * @param {string} root - `/operations/<id>/input` or `…/output`
333
+ * @param {'input' | 'output'} what - input maps narrow/widen to R6/R7,
334
+ * output maps member events to R8/R9 and narrow/widen likewise
335
+ * @returns {Sink}
336
+ */
337
+ function schemaSink(diff, op, root, what) {
338
+ /** @type {(direction: 'narrowed' | 'widened') => [string, string]} */
339
+ const directionRule = (direction) => (what === 'input'
340
+ ? (direction === 'narrowed' ? ['R6', 'input-narrowed'] : ['R7', 'input-widened'])
341
+ : (direction === 'narrowed' ? ['R8', 'output-narrowed'] : ['R9', 'output-widened']));
342
+ return {
343
+ constraint(direction, keyword, path, from, to) {
344
+ const [rule, kind] = directionRule(direction);
345
+ push(classOf(diff, rule), { kind, op, docPath: root + path, from, to, rule, note: keyword });
346
+ },
347
+ member(event, direction, path, member) {
348
+ if (what === 'output') {
349
+ const breaking = event === 'removed' || event === 'made-optional';
350
+ const rule = breaking ? 'R8' : 'R9';
351
+ push(classOf(diff, rule), {
352
+ kind: breaking
353
+ ? (event === 'removed' ? 'output-member-removed' : 'output-member-optional')
354
+ : (event === 'made-required' ? 'output-member-guaranteed' : 'output-member-added'),
355
+ op, docPath: root + path, from: member, to: member, rule,
356
+ });
357
+ return;
358
+ }
359
+ if (direction === null) return; // a no-op member event (unconstrained optional member on an open object)
360
+ const [rule, kind] = directionRule(direction);
361
+ push(classOf(diff, rule), { kind, op, docPath: root + path, from: member, to: member, rule, note: event });
362
+ },
363
+ unknown(keyword, path, from, to) {
364
+ push(diff.unknown, { kind: 'schema-unknown', op, docPath: root + path, from, to, rule: 'R15', note: keyword });
365
+ },
366
+ };
367
+ }
368
+
369
+ /**
370
+ * The top-level input members of both sides, by the R4/R5/R7 rows, then
371
+ * the member schemas recursively.
372
+ * @param {ContractDiff} diff
373
+ * @param {CompiledOperation} aOp
374
+ * @param {CompiledOperation} bOp
375
+ * @param {Side} a
376
+ * @param {Side} b
377
+ */
378
+ function compareInput(diff, aOp, bOp, a, b) {
379
+ const op = aOp.id;
380
+ const root = opPath(op) + '/input';
381
+ const aEff = aOp.input === null ? null : aOp.input.effective;
382
+ const bEff = bOp.input === null ? null : bOp.input.effective;
383
+ if (aEff === null && bEff === null) return;
384
+
385
+ const aProps = aEff !== null && isObject(aEff.properties) ? aEff.properties : {};
386
+ const bProps = bEff !== null && isObject(bEff.properties) ? bEff.properties : {};
387
+ const aReq = new Set(aEff !== null && Array.isArray(aEff.required) ? aEff.required : []);
388
+ const bReq = new Set(bEff !== null && Array.isArray(bEff.required) ? bEff.required : []);
389
+ const bClosed = bEff === null || bEff.additionalProperties === false;
390
+
391
+ if (aEff !== null && bEff === null) {
392
+ push(diff.breaking, {
393
+ kind: 'input-removed', op, docPath: root, from: aOp.input?.schema, rule: 'R5',
394
+ note: 'the operation no longer takes input — anything an older client sends is refused',
395
+ });
396
+ return;
397
+ }
398
+
399
+ const sink = schemaSink(diff, op, root, 'input');
400
+ const visited = new Map();
401
+ for (const m of new Set([...Object.keys(aProps), ...Object.keys(bProps), ...aReq, ...bReq])) {
402
+ const at = root + '/properties/' + encodeJSONPointerSegment(m);
403
+ const inA = Object.hasOwn(aProps, m) || aReq.has(m);
404
+ const inB = Object.hasOwn(bProps, m) || bReq.has(m);
405
+ if (inA && !inB) {
406
+ if (bClosed) {
407
+ push(diff.breaking, { kind: 'input-member-removed', op, docPath: at, from: m, rule: 'R5' });
408
+ }
409
+ else {
410
+ push(diff.neutral, {
411
+ kind: 'input-member-removed', op, docPath: at, from: m, rule: 'R5',
412
+ note: 'the input schema stays open, so the member is now ignored, not validated',
413
+ });
414
+ }
415
+ continue;
416
+ }
417
+ if (!inA && inB) {
418
+ if (bReq.has(m)) push(diff.breaking, { kind: 'input-required-added', op, docPath: at, to: m, rule: 'R4' });
419
+ else push(diff.additive, { kind: 'input-member-added', op, docPath: at, to: m, rule: 'R7' });
420
+ continue;
421
+ }
422
+ if (!aReq.has(m) && bReq.has(m)) {
423
+ push(diff.breaking, { kind: 'input-required-added', op, docPath: at, from: m, to: m, rule: 'R4' });
424
+ }
425
+ else if (aReq.has(m) && !bReq.has(m)) {
426
+ push(diff.additive, { kind: 'input-member-optional', op, docPath: at, from: m, to: m, rule: 'R7' });
427
+ }
428
+ if (Object.hasOwn(aProps, m) && Object.hasOwn(bProps, m)) {
429
+ compareSchema(aProps[m], bProps[m], '/properties/' + encodeJSONPointerSegment(m), a, b, sink, visited);
430
+ }
431
+ }
432
+ const aAp = aEff === null || aEff.additionalProperties === undefined ? true : aEff.additionalProperties;
433
+ const bAp = bEff.additionalProperties === undefined ? true : bEff.additionalProperties;
434
+ if (canon(aAp) !== canon(bAp)) compareSchema(aAp, bAp, '/additionalProperties', a, b, sink, visited);
435
+ }
436
+
437
+ /**
438
+ * The binding members of R3, plus member locations and the whole-body
439
+ * member — a member that moves (query → header, say) rewrites the wire
440
+ * exactly like a moved path, so it classifies with the binding row.
441
+ * @param {ContractDiff} diff
442
+ * @param {CompiledOperation} aOp
443
+ * @param {CompiledOperation} bOp
444
+ */
445
+ function compareBinding(diff, aOp, bOp) {
446
+ const op = aOp.id;
447
+ const emit = (/** @type {string} */ member, /** @type {unknown} */ from, /** @type {unknown} */ to) =>
448
+ push(diff.breaking, { kind: 'binding-changed', op, docPath: opPath(op) + '/http/' + member, from, to, rule: 'R3', note: member });
449
+ if (aOp.kind !== bOp.kind) {
450
+ push(diff.breaking, { kind: 'binding-changed', op, docPath: opPath(op) + '/kind', from: aOp.kind, to: bOp.kind, rule: 'R3', note: 'kind' });
451
+ }
452
+ if (aOp.http.method !== bOp.http.method) emit('method', aOp.http.method, bOp.http.method);
453
+ const aShape = pathShape(aOp.http.template);
454
+ const bShape = pathShape(bOp.http.template);
455
+ if (aShape !== bShape) emit('path', aOp.http.path, bOp.http.path);
456
+ if (aOp.http.status !== bOp.http.status) emit('status', aOp.http.status, bOp.http.status);
457
+ if (aOp.http.media !== bOp.http.media) emit('media', aOp.http.media, bOp.http.media);
458
+ if (aOp.http.opaque !== bOp.http.opaque) emit('media', aOp.http.opaque, bOp.http.opaque);
459
+ if (aOp.http.body !== bOp.http.body) emit('body', aOp.http.body, bOp.http.body);
460
+ for (const m of new Set([...Object.keys(aOp.http.in), ...Object.keys(bOp.http.in)])) {
461
+ const from = aOp.http.in[m];
462
+ const to = bOp.http.in[m];
463
+ if (from !== undefined && to !== undefined && from !== to) {
464
+ push(diff.breaking, {
465
+ kind: 'binding-changed', op, docPath: opPath(op) + '/http/in/' + encodeJSONPointerSegment(m),
466
+ from, to, rule: 'R3', note: `member '${m}' moved`,
467
+ });
468
+ }
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Declared errors: codes and statuses by R10/R11; a changed `details`
474
+ * schema is a construct the table does not model, so it reports (R15).
475
+ * @param {ContractDiff} diff
476
+ * @param {CompiledOperation} aOp
477
+ * @param {CompiledOperation} bOp
478
+ */
479
+ function compareErrors(diff, aOp, bOp) {
480
+ const op = aOp.id;
481
+ for (const code of new Set([...Object.keys(aOp.errors), ...Object.keys(bOp.errors)])) {
482
+ const at = opPath(op) + '/errors/' + encodeJSONPointerSegment(code);
483
+ const from = aOp.errors[code];
484
+ const to = bOp.errors[code];
485
+ if (from !== undefined && to === undefined) {
486
+ push(diff.breaking, { kind: 'error-removed', op, docPath: at, from: code, rule: 'R10' });
487
+ continue;
488
+ }
489
+ if (from === undefined && to !== undefined) {
490
+ push(diff.additive, { kind: 'error-added', op, docPath: at, to: code, rule: 'R11' });
491
+ continue;
492
+ }
493
+ if (from === undefined || to === undefined) continue;
494
+ if (from.status !== to.status) {
495
+ push(diff.breaking, { kind: 'error-status-changed', op, docPath: at + '/status', from: from.status, to: to.status, rule: 'R10' });
496
+ }
497
+ if (canon(from.schema) !== canon(to.schema)) {
498
+ push(diff.unknown, {
499
+ kind: 'error-schema-changed', op, docPath: at + '/schema', from: from.schema, to: to.schema,
500
+ rule: 'R15', note: 'the rule table does not model error-schema evolution',
501
+ });
502
+ }
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Policy: `idempotency` by R12, the client-observable rest by R13.
508
+ * @param {ContractDiff} diff
509
+ * @param {CompiledOperation} aOp
510
+ * @param {CompiledOperation} bOp
511
+ */
512
+ function comparePolicy(diff, aOp, bOp) {
513
+ const op = aOp.id;
514
+ const at = (/** @type {string} */ m) => opPath(op) + '/policy/' + m;
515
+ const from = aOp.policy;
516
+ const to = bOp.policy;
517
+ if (from.idempotency !== to.idempotency) {
518
+ const tightened = to.idempotency === 'required';
519
+ push(tightened ? diff.breaking : diff.additive, {
520
+ kind: tightened ? 'idempotency-required' : 'idempotency-relaxed',
521
+ op, docPath: at('idempotency'), from: from.idempotency, to: to.idempotency, rule: 'R12',
522
+ });
523
+ }
524
+ const neutral = /** @type {[string, unknown, unknown][]} */ ([
525
+ ['task', from.task, to.task],
526
+ ['retry', from.retry, to.retry],
527
+ ['cache', from.cache, to.cache],
528
+ ['revision', from.revision, to.revision],
529
+ ]);
530
+ for (const [member, before, after] of neutral) {
531
+ if (canon(before) !== canon(after)) {
532
+ push(diff.neutral, { kind: 'policy-changed', op, docPath: at(member), from: before, to: after, rule: 'R13' });
533
+ }
534
+ }
535
+ if ((aOp.doc ?? null) !== (bOp.doc ?? null)) {
536
+ push(diff.neutral, { kind: 'doc-changed', op, docPath: opPath(op) + '/doc', from: aOp.doc, to: bOp.doc, rule: 'R13' });
537
+ }
538
+ }
539
+
540
+ //#endregion
541
+
542
+ /**
543
+ * @param {unknown} value
544
+ * @returns {Contract}
545
+ */
546
+ function asCompiled(value) {
547
+ if (isCompiledContract(value)) return /** @type {Contract} */ (value);
548
+ return compileContract(value); // a document; a bad one refuses with its own JC00xx
549
+ }
550
+
551
+ /**
552
+ * Classify every change from contract `a` to contract `b` by the §13
553
+ * rule table. Takes compiled contracts or raw documents (documents are
554
+ * compiled, so a malformed one refuses with its compile error before any
555
+ * comparison). Operations whose `policy.audience` is `server` on BOTH
556
+ * sides are outside the compatibility surface and are skipped; an
557
+ * audience flip is R14 and subsumes the operation's other changes.
558
+ * @param {Contract | Record<string, unknown>} a - the contract consumers hold today
559
+ * @param {Contract | Record<string, unknown>} b - the contract they would meet
560
+ * @returns {ContractDiff}
561
+ * @example
562
+ * const { breaking } = diffContracts(v1Doc, v2Doc);
563
+ * if (breaking.length > 0) throw new Error(breaking.map((c) => `${c.rule} ${c.op}: ${c.kind}`).join('\n'));
564
+ */
565
+ export function diffContracts(a, b) {
566
+ const A = asCompiled(a);
567
+ const B = asCompiled(b);
568
+ /** @type {ContractDiff} */
569
+ const diff = { breaking: [], additive: [], neutral: [], unknown: [] };
570
+ /** @type {Side} */
571
+ const sideA = { doc: A.doc, anchors: collectSameDocumentAnchors(A.doc) };
572
+ /** @type {Side} */
573
+ const sideB = { doc: B.doc, anchors: collectSameDocumentAnchors(B.doc) };
574
+
575
+ for (const id of new Set([...A.ids, ...B.ids])) {
576
+ const aOp = Object.hasOwn(A.operations, id) ? A.operations[id] : null;
577
+ const bOp = Object.hasOwn(B.operations, id) ? B.operations[id] : null;
578
+ if (aOp !== null && bOp === null) {
579
+ if (aOp.policy.audience !== 'server') {
580
+ push(diff.breaking, { kind: 'operation-removed', op: id, docPath: opPath(id), rule: 'R1' });
581
+ }
582
+ continue;
583
+ }
584
+ if (aOp === null && bOp !== null) {
585
+ if (bOp.policy.audience !== 'server') {
586
+ push(diff.additive, { kind: 'operation-added', op: id, docPath: opPath(id), rule: 'R2' });
587
+ }
588
+ continue;
589
+ }
590
+ if (aOp === null || bOp === null) continue;
591
+ if (aOp.policy.audience !== bOp.policy.audience) {
592
+ const narrowed = bOp.policy.audience === 'server';
593
+ push(narrowed ? diff.breaking : diff.additive, {
594
+ kind: narrowed ? 'audience-narrowed' : 'audience-widened',
595
+ op: id, docPath: opPath(id) + '/policy/audience',
596
+ from: aOp.policy.audience, to: bOp.policy.audience, rule: 'R14',
597
+ });
598
+ continue; // the flip subsumes the operation's other changes
599
+ }
600
+ if (aOp.policy.audience === 'server') continue; // invisible on both sides
601
+
602
+ compareBinding(diff, aOp, bOp);
603
+ compareInput(diff, aOp, bOp, sideA, sideB);
604
+ const outSink = schemaSink(diff, id, opPath(id) + '/output', 'output');
605
+ compareSchema(aOp.output.schema, bOp.output.schema, '', sideA, sideB, outSink, new Map());
606
+ compareErrors(diff, aOp, bOp);
607
+ comparePolicy(diff, aOp, bOp);
608
+ }
609
+ return diff;
610
+ }