@crewhaus/tool-schema 0.7.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.
- package/README.md +127 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +678 -0
- package/dist/lib/assert.d.ts +62 -0
- package/dist/lib/assert.js +343 -0
- package/dist/lib/compare.d.ts +67 -0
- package/dist/lib/compare.js +298 -0
- package/dist/lib/formats.d.ts +115 -0
- package/dist/lib/formats.js +395 -0
- package/dist/lib/infer.d.ts +51 -0
- package/dist/lib/infer.js +201 -0
- package/dist/lib/jsonschema.d.ts +50 -0
- package/dist/lib/jsonschema.js +605 -0
- package/dist/lib/records.d.ts +114 -0
- package/dist/lib/records.js +234 -0
- package/dist/lib/schemadiff.d.ts +26 -0
- package/dist/lib/schemadiff.js +350 -0
- package/dist/lib/suggest.d.ts +25 -0
- package/dist/lib/suggest.js +65 -0
- package/dist/lib/summarize.d.ts +28 -0
- package/dist/lib/summarize.js +212 -0
- package/dist/lib/value.d.ts +84 -0
- package/dist/lib/value.js +241 -0
- package/package.json +45 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What changed between two JSON Schemas, and whether a reader survives it.
|
|
3
|
+
*
|
|
4
|
+
* ## The compatibility question this answers
|
|
5
|
+
*
|
|
6
|
+
* A change is **compatible** when every value that was valid under the old
|
|
7
|
+
* schema is still valid under the new one — the producer may have widened
|
|
8
|
+
* things, but nothing a reader already accepts has become invalid. It is
|
|
9
|
+
* **breaking** when some value valid under the old schema is now rejected.
|
|
10
|
+
* It is **unknown** when deciding that would require reasoning this package
|
|
11
|
+
* does not do: whether one regex accepts everything another does, or how two
|
|
12
|
+
* different `anyOf` unions relate.
|
|
13
|
+
*
|
|
14
|
+
* `unknown` is reported, never rounded to compatible. A schema gate that
|
|
15
|
+
* quietly passes an undecided change is worse than one that asks. That holds
|
|
16
|
+
* for keywords this file does not model at all — `patternProperties`,
|
|
17
|
+
* `contains`, `propertyNames`, `dependencies` and anything newer: a change to
|
|
18
|
+
* one of them comes back `unknown`, not as silence.
|
|
19
|
+
*
|
|
20
|
+
* Note the direction: this is compatibility for a *reader* of the data. A
|
|
21
|
+
* writer sees the mirror image — a widened type is a breaking change for
|
|
22
|
+
* whoever must now produce it.
|
|
23
|
+
*/
|
|
24
|
+
import { deepEqual, isPlainObject, joinPointer, preview } from "./value";
|
|
25
|
+
function note(ctx, path, keyword, compat, detail) {
|
|
26
|
+
ctx.changes.push({ path, keyword, detail, compat });
|
|
27
|
+
}
|
|
28
|
+
function asObject(schema) {
|
|
29
|
+
return isPlainObject(schema) ? schema : null;
|
|
30
|
+
}
|
|
31
|
+
function typeSet(schema) {
|
|
32
|
+
const declared = schema["type"];
|
|
33
|
+
if (typeof declared === "string")
|
|
34
|
+
return new Set([declared]);
|
|
35
|
+
if (Array.isArray(declared)) {
|
|
36
|
+
return new Set(declared.filter((t) => typeof t === "string"));
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function acceptsType(types, type) {
|
|
41
|
+
return types.has(type) || (type === "integer" && types.has("number"));
|
|
42
|
+
}
|
|
43
|
+
/** A bound that may only loosen: `minimum`, `minLength`, `minItems`, ... */
|
|
44
|
+
function diffLowerBound(ctx, path, keyword, before, after) {
|
|
45
|
+
if (deepEqual(before, after))
|
|
46
|
+
return;
|
|
47
|
+
if (typeof after !== "number") {
|
|
48
|
+
note(ctx, path, keyword, "compatible", `${keyword} removed — the constraint is gone`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (typeof before !== "number") {
|
|
52
|
+
note(ctx, path, keyword, "breaking", `${keyword} added (${after}) where there was no lower bound`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (after > before)
|
|
56
|
+
note(ctx, path, keyword, "breaking", `${keyword} raised from ${before} to ${after}`);
|
|
57
|
+
else
|
|
58
|
+
note(ctx, path, keyword, "compatible", `${keyword} lowered from ${before} to ${after}`);
|
|
59
|
+
}
|
|
60
|
+
/** A bound that may only loosen upwards: `maximum`, `maxLength`, `maxItems`, ... */
|
|
61
|
+
function diffUpperBound(ctx, path, keyword, before, after) {
|
|
62
|
+
if (deepEqual(before, after))
|
|
63
|
+
return;
|
|
64
|
+
if (typeof after !== "number") {
|
|
65
|
+
note(ctx, path, keyword, "compatible", `${keyword} removed — the constraint is gone`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (typeof before !== "number") {
|
|
69
|
+
note(ctx, path, keyword, "breaking", `${keyword} added (${after}) where there was no upper bound`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (after < before)
|
|
73
|
+
note(ctx, path, keyword, "breaking", `${keyword} lowered from ${before} to ${after}`);
|
|
74
|
+
else
|
|
75
|
+
note(ctx, path, keyword, "compatible", `${keyword} raised from ${before} to ${after}`);
|
|
76
|
+
}
|
|
77
|
+
const COMPOSITIONAL = ["allOf", "anyOf", "oneOf", "not", "if", "then", "else", "$ref"];
|
|
78
|
+
/**
|
|
79
|
+
* Keywords {@link diffNode} reasons about itself, either by classifying the
|
|
80
|
+
* change or by declaring it undecidable. Anything outside this set and
|
|
81
|
+
* {@link ANNOTATIONS} is swept up at the end of the node as `unknown`, so a
|
|
82
|
+
* keyword this package has never heard of cannot make a changed schema come
|
|
83
|
+
* back "compatible".
|
|
84
|
+
*/
|
|
85
|
+
const MODELLED = new Set([
|
|
86
|
+
...COMPOSITIONAL,
|
|
87
|
+
"type",
|
|
88
|
+
"enum",
|
|
89
|
+
"const",
|
|
90
|
+
"required",
|
|
91
|
+
"minimum",
|
|
92
|
+
"exclusiveMinimum",
|
|
93
|
+
"minLength",
|
|
94
|
+
"minItems",
|
|
95
|
+
"minProperties",
|
|
96
|
+
"maximum",
|
|
97
|
+
"exclusiveMaximum",
|
|
98
|
+
"maxLength",
|
|
99
|
+
"maxItems",
|
|
100
|
+
"maxProperties",
|
|
101
|
+
"pattern",
|
|
102
|
+
"format",
|
|
103
|
+
"multipleOf",
|
|
104
|
+
"uniqueItems",
|
|
105
|
+
"properties",
|
|
106
|
+
"additionalProperties",
|
|
107
|
+
"items",
|
|
108
|
+
]);
|
|
109
|
+
/**
|
|
110
|
+
* Keywords that carry no constraint, so changing one cannot change which
|
|
111
|
+
* values validate. Mirrors the annotation list in `./jsonschema`.
|
|
112
|
+
*/
|
|
113
|
+
const ANNOTATIONS = new Set([
|
|
114
|
+
"$id",
|
|
115
|
+
"$schema",
|
|
116
|
+
"$comment",
|
|
117
|
+
"title",
|
|
118
|
+
"description",
|
|
119
|
+
"default",
|
|
120
|
+
"examples",
|
|
121
|
+
"readOnly",
|
|
122
|
+
"writeOnly",
|
|
123
|
+
"deprecated",
|
|
124
|
+
"contentMediaType",
|
|
125
|
+
"contentEncoding",
|
|
126
|
+
"$defs",
|
|
127
|
+
"definitions",
|
|
128
|
+
]);
|
|
129
|
+
function diffNode(before, after, path, ctx) {
|
|
130
|
+
if (deepEqual(before, after))
|
|
131
|
+
return;
|
|
132
|
+
// Boolean schemas: `true` accepts everything, `false` accepts nothing.
|
|
133
|
+
if (typeof before === "boolean" || typeof after === "boolean") {
|
|
134
|
+
if (after === false)
|
|
135
|
+
note(ctx, path, "schema", "breaking", "now rejects every value (false schema)");
|
|
136
|
+
else if (before === false)
|
|
137
|
+
note(ctx, path, "schema", "compatible", "no longer rejects every value");
|
|
138
|
+
else if (after === true)
|
|
139
|
+
note(ctx, path, "schema", "compatible", "now accepts every value (true schema)");
|
|
140
|
+
else
|
|
141
|
+
note(ctx, path, "schema", "breaking", "replaced an unconstrained schema with a constrained one");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const a = asObject(before);
|
|
145
|
+
const b = asObject(after);
|
|
146
|
+
if (a === null || b === null) {
|
|
147
|
+
note(ctx, path, "schema", "unknown", "one side is not a schema object");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// --- type
|
|
151
|
+
const aTypes = typeSet(a);
|
|
152
|
+
const bTypes = typeSet(b);
|
|
153
|
+
if (aTypes === null && bTypes !== null) {
|
|
154
|
+
note(ctx, path, "type", "breaking", `type ${[...bTypes].join("|")} added where any type was allowed`);
|
|
155
|
+
}
|
|
156
|
+
else if (aTypes !== null && bTypes === null) {
|
|
157
|
+
note(ctx, path, "type", "compatible", `type ${[...aTypes].join("|")} removed — any type is now allowed`);
|
|
158
|
+
}
|
|
159
|
+
else if (aTypes !== null && bTypes !== null) {
|
|
160
|
+
const lost = [...aTypes].filter((t) => !acceptsType(bTypes, t));
|
|
161
|
+
const gained = [...bTypes].filter((t) => !acceptsType(aTypes, t));
|
|
162
|
+
if (lost.length > 0) {
|
|
163
|
+
note(ctx, path, "type", "breaking", `no longer accepts ${lost.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
else if (gained.length > 0) {
|
|
166
|
+
note(ctx, path, "type", "compatible", `also accepts ${gained.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// --- enum and const
|
|
170
|
+
const aEnum = Array.isArray(a["enum"]) ? a["enum"] : null;
|
|
171
|
+
const bEnum = Array.isArray(b["enum"]) ? b["enum"] : null;
|
|
172
|
+
if (aEnum === null && bEnum !== null) {
|
|
173
|
+
note(ctx, path, "enum", "breaking", `enum added, limiting values to ${preview(bEnum, 80)}`);
|
|
174
|
+
}
|
|
175
|
+
else if (aEnum !== null && bEnum === null) {
|
|
176
|
+
note(ctx, path, "enum", "compatible", "enum removed — values are no longer limited");
|
|
177
|
+
}
|
|
178
|
+
else if (aEnum !== null && bEnum !== null) {
|
|
179
|
+
const removed = aEnum.filter((v) => !bEnum.some((w) => deepEqual(v, w)));
|
|
180
|
+
const added = bEnum.filter((v) => !aEnum.some((w) => deepEqual(v, w)));
|
|
181
|
+
if (removed.length > 0) {
|
|
182
|
+
note(ctx, path, "enum", "breaking", `enum values removed: ${preview(removed, 80)}`);
|
|
183
|
+
}
|
|
184
|
+
if (added.length > 0) {
|
|
185
|
+
note(ctx, path, "enum", "compatible", `enum values added: ${preview(added, 80)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const aConst = Object.hasOwn(a, "const");
|
|
189
|
+
const bConst = Object.hasOwn(b, "const");
|
|
190
|
+
if (!deepEqual(a["const"], b["const"]) || aConst !== bConst) {
|
|
191
|
+
if (bConst && !aConst) {
|
|
192
|
+
note(ctx, path, "const", "breaking", `const added (${preview(b["const"], 60)})`);
|
|
193
|
+
}
|
|
194
|
+
else if (aConst && !bConst) {
|
|
195
|
+
note(ctx, path, "const", "compatible", "const removed");
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
note(ctx, path, "const", "breaking", `const changed from ${preview(a["const"], 40)} to ${preview(b["const"], 40)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// --- required
|
|
202
|
+
const aRequired = Array.isArray(a["required"])
|
|
203
|
+
? a["required"].filter((k) => typeof k === "string")
|
|
204
|
+
: [];
|
|
205
|
+
const bRequired = Array.isArray(b["required"])
|
|
206
|
+
? b["required"].filter((k) => typeof k === "string")
|
|
207
|
+
: [];
|
|
208
|
+
const nowRequired = bRequired.filter((k) => !aRequired.includes(k));
|
|
209
|
+
const noLongerRequired = aRequired.filter((k) => !bRequired.includes(k));
|
|
210
|
+
if (nowRequired.length > 0) {
|
|
211
|
+
note(ctx, path, "required", "breaking", `now required: ${nowRequired.join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
if (noLongerRequired.length > 0) {
|
|
214
|
+
note(ctx, path, "required", "compatible", `no longer required: ${noLongerRequired.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
// --- bounds
|
|
217
|
+
diffLowerBound(ctx, path, "minimum", a["minimum"], b["minimum"]);
|
|
218
|
+
diffLowerBound(ctx, path, "exclusiveMinimum", a["exclusiveMinimum"], b["exclusiveMinimum"]);
|
|
219
|
+
diffLowerBound(ctx, path, "minLength", a["minLength"], b["minLength"]);
|
|
220
|
+
diffLowerBound(ctx, path, "minItems", a["minItems"], b["minItems"]);
|
|
221
|
+
diffLowerBound(ctx, path, "minProperties", a["minProperties"], b["minProperties"]);
|
|
222
|
+
diffUpperBound(ctx, path, "maximum", a["maximum"], b["maximum"]);
|
|
223
|
+
diffUpperBound(ctx, path, "exclusiveMaximum", a["exclusiveMaximum"], b["exclusiveMaximum"]);
|
|
224
|
+
diffUpperBound(ctx, path, "maxLength", a["maxLength"], b["maxLength"]);
|
|
225
|
+
diffUpperBound(ctx, path, "maxItems", a["maxItems"], b["maxItems"]);
|
|
226
|
+
diffUpperBound(ctx, path, "maxProperties", a["maxProperties"], b["maxProperties"]);
|
|
227
|
+
// --- constraints whose containment this package will not guess at
|
|
228
|
+
for (const keyword of ["pattern", "format", "multipleOf"]) {
|
|
229
|
+
const beforeValue = a[keyword];
|
|
230
|
+
const afterValue = b[keyword];
|
|
231
|
+
if (deepEqual(beforeValue, afterValue))
|
|
232
|
+
continue;
|
|
233
|
+
if (afterValue === undefined) {
|
|
234
|
+
note(ctx, path, keyword, "compatible", `${keyword} removed`);
|
|
235
|
+
}
|
|
236
|
+
else if (beforeValue === undefined) {
|
|
237
|
+
note(ctx, path, keyword, "breaking", `${keyword} added (${preview(afterValue, 40)})`);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
note(ctx, path, keyword, "unknown", `${keyword} changed from ${preview(beforeValue, 40)} to ${preview(afterValue, 40)} — containment is not analyzed`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (a["uniqueItems"] !== b["uniqueItems"]) {
|
|
244
|
+
if (b["uniqueItems"] === true) {
|
|
245
|
+
note(ctx, path, "uniqueItems", "breaking", "items must now be unique");
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
note(ctx, path, "uniqueItems", "compatible", "items need no longer be unique");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// --- properties
|
|
252
|
+
const aProps = asObject(a["properties"]) ?? {};
|
|
253
|
+
const bProps = asObject(b["properties"]) ?? {};
|
|
254
|
+
const propsPath = joinPointer(path, "properties");
|
|
255
|
+
for (const key of Object.keys(aProps)) {
|
|
256
|
+
if (Object.hasOwn(bProps, key)) {
|
|
257
|
+
diffNode(aProps[key], bProps[key], joinPointer(propsPath, key), ctx);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (b["additionalProperties"] === false) {
|
|
261
|
+
note(ctx, joinPointer(propsPath, key), "properties", "breaking", `property "${key}" removed while additionalProperties is false, so values carrying it are now rejected`);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
note(ctx, joinPointer(propsPath, key), "properties", "compatible", `property "${key}" is no longer described, but is still allowed`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const key of Object.keys(bProps)) {
|
|
268
|
+
if (Object.hasOwn(aProps, key))
|
|
269
|
+
continue;
|
|
270
|
+
const required = bRequired.includes(key);
|
|
271
|
+
note(ctx, joinPointer(propsPath, key), "properties", required ? "breaking" : "compatible", required ? `property "${key}" added and required` : `optional property "${key}" added`);
|
|
272
|
+
}
|
|
273
|
+
// --- additionalProperties
|
|
274
|
+
if (!deepEqual(a["additionalProperties"], b["additionalProperties"])) {
|
|
275
|
+
const apPath = joinPointer(path, "additionalProperties");
|
|
276
|
+
if (b["additionalProperties"] === false) {
|
|
277
|
+
note(ctx, apPath, "additionalProperties", "breaking", "extra properties are now rejected");
|
|
278
|
+
}
|
|
279
|
+
else if (a["additionalProperties"] === false) {
|
|
280
|
+
note(ctx, apPath, "additionalProperties", "compatible", "extra properties are now allowed");
|
|
281
|
+
}
|
|
282
|
+
else if (isPlainObject(a["additionalProperties"]) &&
|
|
283
|
+
isPlainObject(b["additionalProperties"])) {
|
|
284
|
+
diffNode(a["additionalProperties"], b["additionalProperties"], apPath, ctx);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
note(ctx, apPath, "additionalProperties", "unknown", "the additionalProperties schema changed shape");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// --- items
|
|
291
|
+
const aItems = a["items"];
|
|
292
|
+
const bItems = b["items"];
|
|
293
|
+
if (!deepEqual(aItems, bItems)) {
|
|
294
|
+
const itemsPath = joinPointer(path, "items");
|
|
295
|
+
if (Array.isArray(aItems) || Array.isArray(bItems)) {
|
|
296
|
+
note(ctx, itemsPath, "items", "unknown", "tuple-form items changed — positions are not analyzed");
|
|
297
|
+
}
|
|
298
|
+
else if (aItems === undefined) {
|
|
299
|
+
note(ctx, itemsPath, "items", "breaking", "items added where any element was allowed");
|
|
300
|
+
}
|
|
301
|
+
else if (bItems === undefined) {
|
|
302
|
+
note(ctx, itemsPath, "items", "compatible", "items removed — any element is now allowed");
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
diffNode(aItems, bItems, itemsPath, ctx);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// --- everything compositional
|
|
309
|
+
for (const keyword of COMPOSITIONAL) {
|
|
310
|
+
if (deepEqual(a[keyword], b[keyword]))
|
|
311
|
+
continue;
|
|
312
|
+
note(ctx, joinPointer(path, keyword), keyword, "unknown", `${keyword} changed — this diff does not reason about composed schemas`);
|
|
313
|
+
}
|
|
314
|
+
// --- anything left. `patternProperties`, `contains`, `propertyNames`,
|
|
315
|
+
// `dependencies` and friends are not modelled here, and a change to one of
|
|
316
|
+
// them can absolutely reject a value that used to pass. Reporting them as
|
|
317
|
+
// undecidable is the only honest answer; staying silent would let the
|
|
318
|
+
// verdict read "compatible".
|
|
319
|
+
for (const keyword of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()) {
|
|
320
|
+
if (MODELLED.has(keyword) || ANNOTATIONS.has(keyword))
|
|
321
|
+
continue;
|
|
322
|
+
if (deepEqual(a[keyword], b[keyword]))
|
|
323
|
+
continue;
|
|
324
|
+
note(ctx, joinPointer(path, keyword), keyword, "unknown", `${keyword} changed — this diff does not model that keyword, so whether a reader survives it is undecided here`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Diff two schemas and classify each change. See the module comment for what
|
|
329
|
+
* "compatible" means here and for the cases deliberately left `unknown`.
|
|
330
|
+
*/
|
|
331
|
+
export function diffSchemas(before, after) {
|
|
332
|
+
const ctx = { changes: [] };
|
|
333
|
+
diffNode(before, after, "", ctx);
|
|
334
|
+
const counts = {
|
|
335
|
+
compatible: ctx.changes.filter((c) => c.compat === "compatible").length,
|
|
336
|
+
breaking: ctx.changes.filter((c) => c.compat === "breaking").length,
|
|
337
|
+
unknown: ctx.changes.filter((c) => c.compat === "unknown").length,
|
|
338
|
+
};
|
|
339
|
+
const verdict = counts.breaking > 0 ? "breaking" : counts.unknown > 0 ? "unknown" : "compatible";
|
|
340
|
+
return {
|
|
341
|
+
// Structural identity, not "nothing was found": two schemas differing
|
|
342
|
+
// only in a `description` produce no changes, and saying they are
|
|
343
|
+
// identical would be a different claim from the one made here.
|
|
344
|
+
identical: deepEqual(before, after),
|
|
345
|
+
verdict,
|
|
346
|
+
backwardCompatible: verdict === "compatible",
|
|
347
|
+
changes: ctx.changes,
|
|
348
|
+
counts,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean" for a failed enum check.
|
|
3
|
+
*
|
|
4
|
+
* A gate that says `"complet" is not allowed` costs someone a minute; one
|
|
5
|
+
* that says `did you mean "completed"?` costs them nothing. That is the whole
|
|
6
|
+
* purpose of this file — it never changes a verdict, only the message.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Optimal string alignment distance: Levenshtein plus a transposition of two
|
|
10
|
+
* adjacent characters as a single edit, so `opne` is one edit from `open`
|
|
11
|
+
* rather than two. That matters here because transposition is the most common
|
|
12
|
+
* typo and the one a plain Levenshtein threshold misses.
|
|
13
|
+
*
|
|
14
|
+
* Counted in UTF-16 units, so an astral character counts as two. It is the
|
|
15
|
+
* *restricted* variant: a substring is not edited twice, which is why this is
|
|
16
|
+
* called optimal string alignment rather than Damerau-Levenshtein.
|
|
17
|
+
*/
|
|
18
|
+
export declare function editDistance(a: string, b: string): number;
|
|
19
|
+
/**
|
|
20
|
+
* The closest candidate to `value`, or `null` when nothing is close enough.
|
|
21
|
+
* "Close enough" is an edit distance within a third of the longer string,
|
|
22
|
+
* which catches a typo or a case difference and rejects an unrelated word.
|
|
23
|
+
* Ties go to the earliest candidate, so the result is stable.
|
|
24
|
+
*/
|
|
25
|
+
export declare function closestMatch(value: string, candidates: string[]): string | null;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean" for a failed enum check.
|
|
3
|
+
*
|
|
4
|
+
* A gate that says `"complet" is not allowed` costs someone a minute; one
|
|
5
|
+
* that says `did you mean "completed"?` costs them nothing. That is the whole
|
|
6
|
+
* purpose of this file — it never changes a verdict, only the message.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Optimal string alignment distance: Levenshtein plus a transposition of two
|
|
10
|
+
* adjacent characters as a single edit, so `opne` is one edit from `open`
|
|
11
|
+
* rather than two. That matters here because transposition is the most common
|
|
12
|
+
* typo and the one a plain Levenshtein threshold misses.
|
|
13
|
+
*
|
|
14
|
+
* Counted in UTF-16 units, so an astral character counts as two. It is the
|
|
15
|
+
* *restricted* variant: a substring is not edited twice, which is why this is
|
|
16
|
+
* called optimal string alignment rather than Damerau-Levenshtein.
|
|
17
|
+
*/
|
|
18
|
+
export function editDistance(a, b) {
|
|
19
|
+
if (a === b)
|
|
20
|
+
return 0;
|
|
21
|
+
if (a.length === 0)
|
|
22
|
+
return b.length;
|
|
23
|
+
if (b.length === 0)
|
|
24
|
+
return a.length;
|
|
25
|
+
// Three rows: two back, one back, and the one being filled.
|
|
26
|
+
let twoBack = new Array(b.length + 1).fill(0);
|
|
27
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
28
|
+
let current = new Array(b.length + 1).fill(0);
|
|
29
|
+
for (let i = 1; i <= a.length; i++) {
|
|
30
|
+
current[0] = i;
|
|
31
|
+
for (let j = 1; j <= b.length; j++) {
|
|
32
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
33
|
+
let best = Math.min(previous[j - 1] + cost, previous[j] + 1, current[j - 1] + 1);
|
|
34
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
35
|
+
best = Math.min(best, twoBack[j - 2] + 1);
|
|
36
|
+
}
|
|
37
|
+
current[j] = best;
|
|
38
|
+
}
|
|
39
|
+
twoBack = previous;
|
|
40
|
+
previous = current;
|
|
41
|
+
current = new Array(b.length + 1).fill(0);
|
|
42
|
+
}
|
|
43
|
+
return previous[b.length];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The closest candidate to `value`, or `null` when nothing is close enough.
|
|
47
|
+
* "Close enough" is an edit distance within a third of the longer string,
|
|
48
|
+
* which catches a typo or a case difference and rejects an unrelated word.
|
|
49
|
+
* Ties go to the earliest candidate, so the result is stable.
|
|
50
|
+
*/
|
|
51
|
+
export function closestMatch(value, candidates) {
|
|
52
|
+
let best = null;
|
|
53
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
const distance = editDistance(value.toLowerCase(), candidate.toLowerCase());
|
|
56
|
+
if (distance < bestDistance) {
|
|
57
|
+
bestDistance = distance;
|
|
58
|
+
best = candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (best === null)
|
|
62
|
+
return null;
|
|
63
|
+
const tolerance = Math.max(1, Math.floor(Math.max(value.length, best.length) / 3));
|
|
64
|
+
return bestDistance <= tolerance ? best : null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type FieldRow = {
|
|
2
|
+
/** Dotted path, with `[]` marking an array level: `order.items[].sku`. */
|
|
3
|
+
path: string;
|
|
4
|
+
type: string;
|
|
5
|
+
required: boolean;
|
|
6
|
+
/** Everything that narrows the value, in one phrase; empty when unconstrained. */
|
|
7
|
+
constraints: string;
|
|
8
|
+
description: string;
|
|
9
|
+
};
|
|
10
|
+
export type SummarizeResult = {
|
|
11
|
+
title: string;
|
|
12
|
+
rootType: string;
|
|
13
|
+
rows: FieldRow[];
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Flatten `schema` into one row per field. The root itself gets no row; its
|
|
18
|
+
* type is reported separately, because "the document is an object" is not a
|
|
19
|
+
* field.
|
|
20
|
+
*/
|
|
21
|
+
export declare function summarizeSchema(schema: unknown, options?: {
|
|
22
|
+
maxRows?: number;
|
|
23
|
+
maxDepth?: number;
|
|
24
|
+
}): SummarizeResult;
|
|
25
|
+
/** Render the field list as a GitHub-flavoured markdown table. */
|
|
26
|
+
export declare function renderFieldTable(rows: FieldRow[]): string;
|
|
27
|
+
/** Every local definition a schema declares, for a caller listing its parts. */
|
|
28
|
+
export declare function listDefinitions(schema: unknown): string[];
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flattening a schema into the table a person actually wants: one row per
|
|
3
|
+
* field, with its path, its type, whether it is required, and its
|
|
4
|
+
* constraints in a single readable phrase.
|
|
5
|
+
*
|
|
6
|
+
* A schema is a tree of nested objects; a field list is flat. Turning one
|
|
7
|
+
* into the other is mechanical, and doing it deterministically here means a
|
|
8
|
+
* harness can put a schema in front of a model — or a reviewer — without
|
|
9
|
+
* spending a model call on the formatting.
|
|
10
|
+
*
|
|
11
|
+
* Local `$ref` is followed. A recursive reference is reported once and not
|
|
12
|
+
* expanded, since a recursive type has no finite field list.
|
|
13
|
+
*/
|
|
14
|
+
import { isPlainObject, joinPointer, parsePointer, preview, resolveSegments, typeOf, } from "./value";
|
|
15
|
+
const DEFAULTS = { maxRows: 300, maxDepth: 8 };
|
|
16
|
+
function describeType(schema) {
|
|
17
|
+
const declared = schema["type"];
|
|
18
|
+
if (typeof declared === "string")
|
|
19
|
+
return declared;
|
|
20
|
+
if (Array.isArray(declared))
|
|
21
|
+
return declared.filter((t) => typeof t === "string").join("|");
|
|
22
|
+
if (Array.isArray(schema["enum"]))
|
|
23
|
+
return "enum";
|
|
24
|
+
if (Object.hasOwn(schema, "const"))
|
|
25
|
+
return "const";
|
|
26
|
+
for (const keyword of ["anyOf", "oneOf", "allOf"]) {
|
|
27
|
+
if (Array.isArray(schema[keyword]))
|
|
28
|
+
return keyword;
|
|
29
|
+
}
|
|
30
|
+
return "any";
|
|
31
|
+
}
|
|
32
|
+
function describeConstraints(schema) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (Array.isArray(schema["enum"]))
|
|
35
|
+
parts.push(`one of ${preview(schema["enum"], 80)}`);
|
|
36
|
+
if (Object.hasOwn(schema, "const"))
|
|
37
|
+
parts.push(`const ${preview(schema["const"], 40)}`);
|
|
38
|
+
if (typeof schema["format"] === "string")
|
|
39
|
+
parts.push(`format ${schema["format"]}`);
|
|
40
|
+
if (typeof schema["pattern"] === "string")
|
|
41
|
+
parts.push(`matches /${schema["pattern"]}/`);
|
|
42
|
+
for (const [keyword, label] of [
|
|
43
|
+
["minimum", ">="],
|
|
44
|
+
["maximum", "<="],
|
|
45
|
+
["exclusiveMinimum", ">"],
|
|
46
|
+
["exclusiveMaximum", "<"],
|
|
47
|
+
]) {
|
|
48
|
+
if (typeof schema[keyword] === "number")
|
|
49
|
+
parts.push(`${label} ${String(schema[keyword])}`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof schema["multipleOf"] === "number")
|
|
52
|
+
parts.push(`multiple of ${schema["multipleOf"]}`);
|
|
53
|
+
for (const keyword of ["minLength", "maxLength", "minItems", "maxItems"]) {
|
|
54
|
+
if (typeof schema[keyword] === "number")
|
|
55
|
+
parts.push(`${keyword} ${String(schema[keyword])}`);
|
|
56
|
+
}
|
|
57
|
+
if (schema["uniqueItems"] === true)
|
|
58
|
+
parts.push("unique items");
|
|
59
|
+
if (schema["additionalProperties"] === false)
|
|
60
|
+
parts.push("no extra properties");
|
|
61
|
+
if (Object.hasOwn(schema, "default"))
|
|
62
|
+
parts.push(`default ${preview(schema["default"], 40)}`);
|
|
63
|
+
return parts.join(", ");
|
|
64
|
+
}
|
|
65
|
+
/** Follow a local `$ref` once. Returns null when it cannot be resolved here. */
|
|
66
|
+
function deref(schema, ctx) {
|
|
67
|
+
const ref = schema["$ref"];
|
|
68
|
+
if (typeof ref !== "string" || !ref.startsWith("#"))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
const found = resolveSegments(ctx.root, parsePointer(ref));
|
|
72
|
+
return found.found && isPlainObject(found.value) ? found.value : null;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function push(ctx, row) {
|
|
79
|
+
if (ctx.rows.length >= ctx.maxRows) {
|
|
80
|
+
ctx.truncated = true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
ctx.rows.push(row);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
function walk(schema, path, required, depth, seenRefs, ctx) {
|
|
87
|
+
if (ctx.truncated)
|
|
88
|
+
return;
|
|
89
|
+
if (typeof schema === "boolean") {
|
|
90
|
+
push(ctx, {
|
|
91
|
+
path,
|
|
92
|
+
type: schema ? "any" : "never",
|
|
93
|
+
required,
|
|
94
|
+
constraints: schema ? "" : "accepts no value",
|
|
95
|
+
description: "",
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!isPlainObject(schema)) {
|
|
100
|
+
push(ctx, {
|
|
101
|
+
path,
|
|
102
|
+
type: typeOf(schema),
|
|
103
|
+
required,
|
|
104
|
+
constraints: "not a schema",
|
|
105
|
+
description: "",
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const ref = typeof schema["$ref"] === "string" ? schema["$ref"] : null;
|
|
110
|
+
if (ref !== null) {
|
|
111
|
+
if (seenRefs.includes(ref)) {
|
|
112
|
+
push(ctx, { path, type: `${ref} (recursive)`, required, constraints: "", description: "" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const target = deref(schema, ctx);
|
|
116
|
+
if (target === null) {
|
|
117
|
+
push(ctx, { path, type: ref, required, constraints: "unresolved $ref", description: "" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
walk(target, path, required, depth, [...seenRefs, ref], ctx);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const description = typeof schema["description"] === "string" ? schema["description"] : "";
|
|
124
|
+
if (path !== "") {
|
|
125
|
+
if (!push(ctx, {
|
|
126
|
+
path,
|
|
127
|
+
type: describeType(schema),
|
|
128
|
+
required,
|
|
129
|
+
constraints: describeConstraints(schema),
|
|
130
|
+
description,
|
|
131
|
+
})) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (depth >= ctx.maxDepth)
|
|
136
|
+
return;
|
|
137
|
+
const properties = schema["properties"];
|
|
138
|
+
if (isPlainObject(properties)) {
|
|
139
|
+
const requiredNames = Array.isArray(schema["required"])
|
|
140
|
+
? schema["required"].filter((k) => typeof k === "string")
|
|
141
|
+
: [];
|
|
142
|
+
for (const key of Object.keys(properties)) {
|
|
143
|
+
const childPath = path === "" ? key : `${path}.${key}`;
|
|
144
|
+
walk(properties[key], childPath, requiredNames.includes(key), depth + 1, seenRefs, ctx);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const items = schema["items"];
|
|
148
|
+
if (Array.isArray(items)) {
|
|
149
|
+
items.forEach((item, index) => {
|
|
150
|
+
walk(item, `${path}[${index}]`, required, depth + 1, seenRefs, ctx);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (items !== undefined) {
|
|
154
|
+
walk(items, `${path}[]`, required, depth + 1, seenRefs, ctx);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Flatten `schema` into one row per field. The root itself gets no row; its
|
|
159
|
+
* type is reported separately, because "the document is an object" is not a
|
|
160
|
+
* field.
|
|
161
|
+
*/
|
|
162
|
+
export function summarizeSchema(schema, options = {}) {
|
|
163
|
+
const ctx = {
|
|
164
|
+
root: schema,
|
|
165
|
+
rows: [],
|
|
166
|
+
maxRows: options.maxRows ?? DEFAULTS.maxRows,
|
|
167
|
+
maxDepth: options.maxDepth ?? DEFAULTS.maxDepth,
|
|
168
|
+
truncated: false,
|
|
169
|
+
};
|
|
170
|
+
walk(schema, "", false, 0, [], ctx);
|
|
171
|
+
const rootObject = isPlainObject(schema) ? schema : {};
|
|
172
|
+
return {
|
|
173
|
+
title: typeof rootObject["title"] === "string" ? rootObject["title"] : "",
|
|
174
|
+
rootType: isPlainObject(schema)
|
|
175
|
+
? describeType(rootObject)
|
|
176
|
+
: typeof schema === "boolean"
|
|
177
|
+
? "any"
|
|
178
|
+
: "unknown",
|
|
179
|
+
rows: ctx.rows,
|
|
180
|
+
truncated: ctx.truncated,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Render the field list as a GitHub-flavoured markdown table. */
|
|
184
|
+
export function renderFieldTable(rows) {
|
|
185
|
+
const header = ["Field", "Type", "Required", "Constraints", "Description"];
|
|
186
|
+
const cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
187
|
+
const body = rows.map((row) => [
|
|
188
|
+
cell(row.path),
|
|
189
|
+
cell(row.type),
|
|
190
|
+
row.required ? "yes" : "",
|
|
191
|
+
cell(row.constraints),
|
|
192
|
+
cell(row.description),
|
|
193
|
+
]);
|
|
194
|
+
const widths = header.map((cell, i) => Math.max(cell.length, ...body.map((line) => (line[i] ?? "").length)));
|
|
195
|
+
const line = (cells) => `| ${cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" | ")} |`;
|
|
196
|
+
const divider = `| ${widths.map((w) => "-".repeat(Math.max(3, w))).join(" | ")} |`;
|
|
197
|
+
return [line(header), divider, ...body.map(line)].join("\n");
|
|
198
|
+
}
|
|
199
|
+
/** Every local definition a schema declares, for a caller listing its parts. */
|
|
200
|
+
export function listDefinitions(schema) {
|
|
201
|
+
if (!isPlainObject(schema))
|
|
202
|
+
return [];
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const container of ["$defs", "definitions"]) {
|
|
205
|
+
const defs = schema[container];
|
|
206
|
+
if (!isPlainObject(defs))
|
|
207
|
+
continue;
|
|
208
|
+
for (const name of Object.keys(defs))
|
|
209
|
+
out.push(joinPointer(joinPointer("", container), name));
|
|
210
|
+
}
|
|
211
|
+
return out.sort();
|
|
212
|
+
}
|