@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.
- package/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- 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
|
+
}
|