@crewhaus/tool-schema 0.7.0 → 0.8.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +50 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +92 -13
- package/dist/lib/assert.d.ts +36 -1
- package/dist/lib/assert.js +95 -14
- package/dist/lib/compare.js +30 -9
- package/dist/lib/formats.d.ts +13 -0
- package/dist/lib/formats.js +14 -1
- package/dist/lib/jsonschema.d.ts +170 -0
- package/dist/lib/jsonschema.js +530 -68
- package/dist/lib/records.d.ts +16 -0
- package/dist/lib/records.js +17 -3
- package/dist/lib/regex-answers.d.ts +142 -0
- package/dist/lib/regex-answers.js +289 -0
- package/dist/lib/schemadiff.d.ts +19 -0
- package/dist/lib/schemadiff.js +98 -17
- package/dist/lib/shape-index.d.ts +22 -0
- package/dist/lib/shape-index.js +157 -0
- package/dist/lib/summarize.js +28 -9
- package/dist/lib/value.d.ts +16 -2
- package/dist/lib/value.js +143 -13
- package/package.json +11 -6
package/dist/lib/schemadiff.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* writer sees the mirror image — a widened type is a breaking change for
|
|
22
22
|
* whoever must now produce it.
|
|
23
23
|
*/
|
|
24
|
-
import {
|
|
24
|
+
import { indexShapes } from "./shape-index";
|
|
25
|
+
import { isPlainObject, joinPointer, preview } from "./value";
|
|
25
26
|
function note(ctx, path, keyword, compat, detail) {
|
|
26
27
|
ctx.changes.push({ path, keyword, detail, compat });
|
|
27
28
|
}
|
|
@@ -42,7 +43,7 @@ function acceptsType(types, type) {
|
|
|
42
43
|
}
|
|
43
44
|
/** A bound that may only loosen: `minimum`, `minLength`, `minItems`, ... */
|
|
44
45
|
function diffLowerBound(ctx, path, keyword, before, after) {
|
|
45
|
-
if (
|
|
46
|
+
if (ctx.same.equal(before, after))
|
|
46
47
|
return;
|
|
47
48
|
if (typeof after !== "number") {
|
|
48
49
|
note(ctx, path, keyword, "compatible", `${keyword} removed — the constraint is gone`);
|
|
@@ -59,7 +60,7 @@ function diffLowerBound(ctx, path, keyword, before, after) {
|
|
|
59
60
|
}
|
|
60
61
|
/** A bound that may only loosen upwards: `maximum`, `maxLength`, `maxItems`, ... */
|
|
61
62
|
function diffUpperBound(ctx, path, keyword, before, after) {
|
|
62
|
-
if (
|
|
63
|
+
if (ctx.same.equal(before, after))
|
|
63
64
|
return;
|
|
64
65
|
if (typeof after !== "number") {
|
|
65
66
|
note(ctx, path, keyword, "compatible", `${keyword} removed — the constraint is gone`);
|
|
@@ -126,8 +127,32 @@ const ANNOTATIONS = new Set([
|
|
|
126
127
|
"$defs",
|
|
127
128
|
"definitions",
|
|
128
129
|
]);
|
|
130
|
+
/**
|
|
131
|
+
* The values of `values` equal to no value of `others`, in order. By key
|
|
132
|
+
* when the index covers every value, so two enums of n values cost n and not
|
|
133
|
+
* n²; otherwise by comparing every pair, as `deepEqual` would.
|
|
134
|
+
*/
|
|
135
|
+
function missingFrom(values, others, same) {
|
|
136
|
+
const pairwise = () => values.filter((v) => !others.some((w) => same.equal(v, w)));
|
|
137
|
+
const present = new Set();
|
|
138
|
+
for (const other of others) {
|
|
139
|
+
const key = same.key(other);
|
|
140
|
+
if (key === undefined)
|
|
141
|
+
return pairwise();
|
|
142
|
+
present.add(key);
|
|
143
|
+
}
|
|
144
|
+
const missing = [];
|
|
145
|
+
for (const value of values) {
|
|
146
|
+
const key = same.key(value);
|
|
147
|
+
if (key === undefined)
|
|
148
|
+
return pairwise();
|
|
149
|
+
if (!present.has(key))
|
|
150
|
+
missing.push(value);
|
|
151
|
+
}
|
|
152
|
+
return missing;
|
|
153
|
+
}
|
|
129
154
|
function diffNode(before, after, path, ctx) {
|
|
130
|
-
if (
|
|
155
|
+
if (ctx.same.equal(before, after))
|
|
131
156
|
return;
|
|
132
157
|
// Boolean schemas: `true` accepts everything, `false` accepts nothing.
|
|
133
158
|
if (typeof before === "boolean" || typeof after === "boolean") {
|
|
@@ -176,8 +201,8 @@ function diffNode(before, after, path, ctx) {
|
|
|
176
201
|
note(ctx, path, "enum", "compatible", "enum removed — values are no longer limited");
|
|
177
202
|
}
|
|
178
203
|
else if (aEnum !== null && bEnum !== null) {
|
|
179
|
-
const removed = aEnum
|
|
180
|
-
const added = bEnum
|
|
204
|
+
const removed = missingFrom(aEnum, bEnum, ctx.same);
|
|
205
|
+
const added = missingFrom(bEnum, aEnum, ctx.same);
|
|
181
206
|
if (removed.length > 0) {
|
|
182
207
|
note(ctx, path, "enum", "breaking", `enum values removed: ${preview(removed, 80)}`);
|
|
183
208
|
}
|
|
@@ -187,7 +212,7 @@ function diffNode(before, after, path, ctx) {
|
|
|
187
212
|
}
|
|
188
213
|
const aConst = Object.hasOwn(a, "const");
|
|
189
214
|
const bConst = Object.hasOwn(b, "const");
|
|
190
|
-
if (!
|
|
215
|
+
if (!ctx.same.equal(a["const"], b["const"]) || aConst !== bConst) {
|
|
191
216
|
if (bConst && !aConst) {
|
|
192
217
|
note(ctx, path, "const", "breaking", `const added (${preview(b["const"], 60)})`);
|
|
193
218
|
}
|
|
@@ -205,8 +230,12 @@ function diffNode(before, after, path, ctx) {
|
|
|
205
230
|
const bRequired = Array.isArray(b["required"])
|
|
206
231
|
? b["required"].filter((k) => typeof k === "string")
|
|
207
232
|
: [];
|
|
208
|
-
|
|
209
|
-
|
|
233
|
+
// Sets, not a search of the other list per name: two lists of n names, and
|
|
234
|
+
// n properties added against a list of n, each cost n².
|
|
235
|
+
const aRequiredSet = new Set(aRequired);
|
|
236
|
+
const bRequiredSet = new Set(bRequired);
|
|
237
|
+
const nowRequired = bRequired.filter((k) => !aRequiredSet.has(k));
|
|
238
|
+
const noLongerRequired = aRequired.filter((k) => !bRequiredSet.has(k));
|
|
210
239
|
if (nowRequired.length > 0) {
|
|
211
240
|
note(ctx, path, "required", "breaking", `now required: ${nowRequired.join(", ")}`);
|
|
212
241
|
}
|
|
@@ -228,7 +257,7 @@ function diffNode(before, after, path, ctx) {
|
|
|
228
257
|
for (const keyword of ["pattern", "format", "multipleOf"]) {
|
|
229
258
|
const beforeValue = a[keyword];
|
|
230
259
|
const afterValue = b[keyword];
|
|
231
|
-
if (
|
|
260
|
+
if (ctx.same.equal(beforeValue, afterValue))
|
|
232
261
|
continue;
|
|
233
262
|
if (afterValue === undefined) {
|
|
234
263
|
note(ctx, path, keyword, "compatible", `${keyword} removed`);
|
|
@@ -267,11 +296,11 @@ function diffNode(before, after, path, ctx) {
|
|
|
267
296
|
for (const key of Object.keys(bProps)) {
|
|
268
297
|
if (Object.hasOwn(aProps, key))
|
|
269
298
|
continue;
|
|
270
|
-
const required =
|
|
299
|
+
const required = bRequiredSet.has(key);
|
|
271
300
|
note(ctx, joinPointer(propsPath, key), "properties", required ? "breaking" : "compatible", required ? `property "${key}" added and required` : `optional property "${key}" added`);
|
|
272
301
|
}
|
|
273
302
|
// --- additionalProperties
|
|
274
|
-
if (!
|
|
303
|
+
if (!ctx.same.equal(a["additionalProperties"], b["additionalProperties"])) {
|
|
275
304
|
const apPath = joinPointer(path, "additionalProperties");
|
|
276
305
|
if (b["additionalProperties"] === false) {
|
|
277
306
|
note(ctx, apPath, "additionalProperties", "breaking", "extra properties are now rejected");
|
|
@@ -290,7 +319,7 @@ function diffNode(before, after, path, ctx) {
|
|
|
290
319
|
// --- items
|
|
291
320
|
const aItems = a["items"];
|
|
292
321
|
const bItems = b["items"];
|
|
293
|
-
if (!
|
|
322
|
+
if (!ctx.same.equal(aItems, bItems)) {
|
|
294
323
|
const itemsPath = joinPointer(path, "items");
|
|
295
324
|
if (Array.isArray(aItems) || Array.isArray(bItems)) {
|
|
296
325
|
note(ctx, itemsPath, "items", "unknown", "tuple-form items changed — positions are not analyzed");
|
|
@@ -307,7 +336,7 @@ function diffNode(before, after, path, ctx) {
|
|
|
307
336
|
}
|
|
308
337
|
// --- everything compositional
|
|
309
338
|
for (const keyword of COMPOSITIONAL) {
|
|
310
|
-
if (
|
|
339
|
+
if (ctx.same.equal(a[keyword], b[keyword]))
|
|
311
340
|
continue;
|
|
312
341
|
note(ctx, joinPointer(path, keyword), keyword, "unknown", `${keyword} changed — this diff does not reason about composed schemas`);
|
|
313
342
|
}
|
|
@@ -319,17 +348,69 @@ function diffNode(before, after, path, ctx) {
|
|
|
319
348
|
for (const keyword of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()) {
|
|
320
349
|
if (MODELLED.has(keyword) || ANNOTATIONS.has(keyword))
|
|
321
350
|
continue;
|
|
322
|
-
if (
|
|
351
|
+
if (ctx.same.equal(a[keyword], b[keyword]))
|
|
323
352
|
continue;
|
|
324
353
|
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
354
|
}
|
|
326
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* The deepest a schema may nest, counting every object and array, for it to
|
|
358
|
+
* be diffed. Each level is a level of recursion here, and the stack's own
|
|
359
|
+
* limit moves with the platform and with how warm the code is: a schema
|
|
360
|
+
* near it came back answered in one process and as a crash in another. A
|
|
361
|
+
* fixed limit answers the same everywhere. It also bounds the pointers: every
|
|
362
|
+
* change carries its full path, so a schema differing at every level made a
|
|
363
|
+
* result the square of its depth. The same limit tool-data puts on a
|
|
364
|
+
* document.
|
|
365
|
+
*/
|
|
366
|
+
export const MAX_SCHEMA_DEPTH = 256;
|
|
367
|
+
/**
|
|
368
|
+
* Whether `value` nests deeper than `limit` objects and arrays, read with an
|
|
369
|
+
* explicit stack and abandoned as soon as it does. An object reached again
|
|
370
|
+
* (only a caller's own value can share one) is walked again only when it is
|
|
371
|
+
* reached deeper, so a cycle is too deep rather than endless.
|
|
372
|
+
*/
|
|
373
|
+
export function nestsDeeperThan(value, limit) {
|
|
374
|
+
const deepest = new Map();
|
|
375
|
+
const stack = [
|
|
376
|
+
{ node: value, depth: 1 },
|
|
377
|
+
];
|
|
378
|
+
for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
|
|
379
|
+
const { node, depth } = top;
|
|
380
|
+
if (typeof node !== "object" || node === null)
|
|
381
|
+
continue;
|
|
382
|
+
if (depth > limit)
|
|
383
|
+
return true;
|
|
384
|
+
const before = deepest.get(node);
|
|
385
|
+
if (before !== undefined && before >= depth)
|
|
386
|
+
continue;
|
|
387
|
+
deepest.set(node, depth);
|
|
388
|
+
const children = Array.isArray(node) ? node : Object.values(node);
|
|
389
|
+
for (let i = 0; i < children.length; i++) {
|
|
390
|
+
const child = children[i];
|
|
391
|
+
if (typeof child === "object" && child !== null)
|
|
392
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
327
397
|
/**
|
|
328
398
|
* Diff two schemas and classify each change. See the module comment for what
|
|
329
399
|
* "compatible" means here and for the cases deliberately left `unknown`.
|
|
400
|
+
* Refuses a schema nested past {@link MAX_SCHEMA_DEPTH}, before reading it.
|
|
330
401
|
*/
|
|
331
402
|
export function diffSchemas(before, after) {
|
|
332
|
-
const
|
|
403
|
+
for (const [side, schema] of [
|
|
404
|
+
["before", before],
|
|
405
|
+
["after", after],
|
|
406
|
+
]) {
|
|
407
|
+
if (nestsDeeperThan(schema, MAX_SCHEMA_DEPTH)) {
|
|
408
|
+
throw new Error(`\`${side}\` nests deeper than ${MAX_SCHEMA_DEPTH} levels, the most SchemaDiff compares — diff the nested part on its own`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Every subtree of both schemas numbered once, so the comparisons below at
|
|
412
|
+
// each level of nesting, and the enum diffs, are lookups rather than walks.
|
|
413
|
+
const ctx = { changes: [], same: indexShapes([before, after]) };
|
|
333
414
|
diffNode(before, after, "", ctx);
|
|
334
415
|
const counts = {
|
|
335
416
|
compatible: ctx.changes.filter((c) => c.compat === "compatible").length,
|
|
@@ -341,7 +422,7 @@ export function diffSchemas(before, after) {
|
|
|
341
422
|
// Structural identity, not "nothing was found": two schemas differing
|
|
342
423
|
// only in a `description` produce no changes, and saying they are
|
|
343
424
|
// identical would be a different claim from the one made here.
|
|
344
|
-
identical:
|
|
425
|
+
identical: ctx.same.equal(before, after),
|
|
345
426
|
verdict,
|
|
346
427
|
backwardCompatible: verdict === "compatible",
|
|
347
428
|
changes: ctx.changes,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ShapeIndex = {
|
|
2
|
+
/** `deepEqual(a, b)`. */
|
|
3
|
+
readonly equal: (a: unknown, b: unknown) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* A string two indexed values share exactly when `deepEqual` calls them
|
|
6
|
+
* equal, or undefined for a value the index does not cover.
|
|
7
|
+
*/
|
|
8
|
+
readonly key: (value: unknown) => string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The work of numbering: members visited plus signature characters
|
|
11
|
+
* written. A test reads it, since a map lookup is invisible to a meter
|
|
12
|
+
* that wraps the array and string methods.
|
|
13
|
+
*/
|
|
14
|
+
readonly steps: number;
|
|
15
|
+
};
|
|
16
|
+
/** No index: `deepEqual` for every comparison, and no keys. */
|
|
17
|
+
export declare const UNINDEXED: ShapeIndex;
|
|
18
|
+
/**
|
|
19
|
+
* Number every array and object inside `roots`, or return UNINDEXED when
|
|
20
|
+
* one of them holds a value the numbers cannot describe.
|
|
21
|
+
*/
|
|
22
|
+
export declare function indexShapes(roots: ReadonlyArray<unknown>): ShapeIndex;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deepEqual` answered by lookup, for the values inside two documents.
|
|
3
|
+
*
|
|
4
|
+
* SchemaDiff compared subtrees with `deepEqual` at every level it descended
|
|
5
|
+
* into — the whole remaining tree once per level, so a schema nested n deep
|
|
6
|
+
* cost n² — and diffed two enums by testing every value of one against every
|
|
7
|
+
* value of the other. Here every array and plain object inside the indexed
|
|
8
|
+
* values gets a number, shared by exactly the subtrees `deepEqual` calls
|
|
9
|
+
* equal, worked out once from the leaves up. Two subtrees are then equal
|
|
10
|
+
* when their numbers are, and a value can be looked up in a set.
|
|
11
|
+
*
|
|
12
|
+
* The numbers are defined only where `deepEqual` compares JSON shape: null,
|
|
13
|
+
* booleans, strings, numbers other than NaN, bigints, undefined, dense
|
|
14
|
+
* arrays, and objects whose prototype is `Object.prototype` or null and
|
|
15
|
+
* whose own properties are all data properties. Anything else — NaN, which
|
|
16
|
+
* equals nothing, a function, a Date, a sparse array, a cycle — leaves the
|
|
17
|
+
* values unindexed, and every comparison goes back to `deepEqual` itself.
|
|
18
|
+
* So each answer is the one `deepEqual` gives. A value parsed from JSON is
|
|
19
|
+
* always indexed.
|
|
20
|
+
*/
|
|
21
|
+
import { deepEqual } from "./value";
|
|
22
|
+
/** No index: `deepEqual` for every comparison, and no keys. */
|
|
23
|
+
export const UNINDEXED = { equal: deepEqual, key: () => undefined, steps: 0 };
|
|
24
|
+
/** Thrown while indexing a value `deepEqual` does not compare by shape. */
|
|
25
|
+
const NOT_A_SHAPE = Symbol("not a JSON shape");
|
|
26
|
+
/** The key of a value that is not an object, or NOT_A_SHAPE. */
|
|
27
|
+
function leafKey(value) {
|
|
28
|
+
if (value === null)
|
|
29
|
+
return "null";
|
|
30
|
+
switch (typeof value) {
|
|
31
|
+
case "undefined":
|
|
32
|
+
return "u";
|
|
33
|
+
case "boolean":
|
|
34
|
+
return value ? "t" : "f";
|
|
35
|
+
case "number":
|
|
36
|
+
// `deepEqual` is `===` here: NaN equals nothing, and 0 equals -0.
|
|
37
|
+
if (Number.isNaN(value))
|
|
38
|
+
return NOT_A_SHAPE;
|
|
39
|
+
return `n${value === 0 ? 0 : value}`;
|
|
40
|
+
case "string":
|
|
41
|
+
return `s${JSON.stringify(value)}`;
|
|
42
|
+
case "bigint":
|
|
43
|
+
return `b${value}`;
|
|
44
|
+
default:
|
|
45
|
+
return NOT_A_SHAPE;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Number every array and object inside `roots`, or return UNINDEXED when
|
|
50
|
+
* one of them holds a value the numbers cannot describe.
|
|
51
|
+
*/
|
|
52
|
+
export function indexShapes(roots) {
|
|
53
|
+
const ids = new Map();
|
|
54
|
+
const bySignature = new Map();
|
|
55
|
+
const work = { steps: 0 };
|
|
56
|
+
const keyOf = (value) => {
|
|
57
|
+
if (typeof value !== "object" || value === null)
|
|
58
|
+
return leafKey(value);
|
|
59
|
+
const id = ids.get(value);
|
|
60
|
+
return id === undefined ? NOT_A_SHAPE : `o${id}`;
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
for (const root of roots)
|
|
64
|
+
number(root, ids, bySignature, keyOf, work);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err === NOT_A_SHAPE)
|
|
68
|
+
return UNINDEXED;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
equal: (a, b) => {
|
|
73
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
|
74
|
+
const left = ids.get(a);
|
|
75
|
+
const right = ids.get(b);
|
|
76
|
+
if (left !== undefined && right !== undefined)
|
|
77
|
+
return left === right;
|
|
78
|
+
}
|
|
79
|
+
return deepEqual(a, b);
|
|
80
|
+
},
|
|
81
|
+
key: (value) => {
|
|
82
|
+
const key = keyOf(value);
|
|
83
|
+
return key === NOT_A_SHAPE ? undefined : key;
|
|
84
|
+
},
|
|
85
|
+
steps: work.steps,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Give `root` and everything inside it a number, children before parents,
|
|
90
|
+
* with an explicit stack: a schema nested tens of thousands deep must not
|
|
91
|
+
* overflow the call stack here when the comparisons it replaces did not.
|
|
92
|
+
*/
|
|
93
|
+
function number(root, ids, bySignature, keyOf, work) {
|
|
94
|
+
const frames = [];
|
|
95
|
+
const open = new Set();
|
|
96
|
+
const enter = (value) => {
|
|
97
|
+
if (typeof value !== "object" || value === null) {
|
|
98
|
+
if (leafKey(value) === NOT_A_SHAPE)
|
|
99
|
+
throw NOT_A_SHAPE;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (ids.has(value))
|
|
103
|
+
return;
|
|
104
|
+
// A cycle: `deepEqual` would recurse until the stack ran out.
|
|
105
|
+
if (open.has(value))
|
|
106
|
+
throw NOT_A_SHAPE;
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
// `deepEqual` skips a hole with `every`, which no number can say.
|
|
109
|
+
for (let i = 0; i < value.length; i++)
|
|
110
|
+
if (!(i in value))
|
|
111
|
+
throw NOT_A_SHAPE;
|
|
112
|
+
frames.push({ node: value, keys: null, members: value, next: 0 });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const proto = Object.getPrototypeOf(value);
|
|
116
|
+
if (proto !== Object.prototype && proto !== null)
|
|
117
|
+
throw NOT_A_SHAPE;
|
|
118
|
+
const keys = Object.keys(value).sort();
|
|
119
|
+
const members = [];
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
122
|
+
if (property === undefined || !("value" in property))
|
|
123
|
+
throw NOT_A_SHAPE;
|
|
124
|
+
members.push(property.value);
|
|
125
|
+
}
|
|
126
|
+
frames.push({ node: value, keys, members, next: 0 });
|
|
127
|
+
}
|
|
128
|
+
open.add(value);
|
|
129
|
+
};
|
|
130
|
+
enter(root);
|
|
131
|
+
while (frames.length > 0) {
|
|
132
|
+
const frame = frames[frames.length - 1];
|
|
133
|
+
if (frame.next < frame.members.length) {
|
|
134
|
+
enter(frame.members[frame.next]);
|
|
135
|
+
frame.next += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
frames.pop();
|
|
139
|
+
open.delete(frame.node);
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (let i = 0; i < frame.members.length; i++) {
|
|
142
|
+
const key = keyOf(frame.members[i]);
|
|
143
|
+
if (key === NOT_A_SHAPE)
|
|
144
|
+
throw NOT_A_SHAPE;
|
|
145
|
+
const name = frame.keys === null ? undefined : frame.keys[i];
|
|
146
|
+
parts.push(name === undefined ? key : `${JSON.stringify(name)}:${key}`);
|
|
147
|
+
}
|
|
148
|
+
const signature = frame.keys === null ? `[${parts.join(",")}]` : `{${parts.join(",")}}`;
|
|
149
|
+
work.steps += frame.members.length + signature.length;
|
|
150
|
+
let id = bySignature.get(signature);
|
|
151
|
+
if (id === undefined) {
|
|
152
|
+
id = bySignature.size;
|
|
153
|
+
bySignature.set(signature, id);
|
|
154
|
+
}
|
|
155
|
+
ids.set(frame.node, id);
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/lib/summarize.js
CHANGED
|
@@ -108,7 +108,10 @@ function walk(schema, path, required, depth, seenRefs, ctx) {
|
|
|
108
108
|
}
|
|
109
109
|
const ref = typeof schema["$ref"] === "string" ? schema["$ref"] : null;
|
|
110
110
|
if (ref !== null) {
|
|
111
|
-
|
|
111
|
+
// The refs being followed on the way down to here, as a set kept in
|
|
112
|
+
// step with the walk: copying and searching a list at every hop cost a
|
|
113
|
+
// chain of n refs n² (a ref adds no depth, so nothing else stops it).
|
|
114
|
+
if (seenRefs.has(ref)) {
|
|
112
115
|
push(ctx, { path, type: `${ref} (recursive)`, required, constraints: "", description: "" });
|
|
113
116
|
return;
|
|
114
117
|
}
|
|
@@ -117,7 +120,13 @@ function walk(schema, path, required, depth, seenRefs, ctx) {
|
|
|
117
120
|
push(ctx, { path, type: ref, required, constraints: "unresolved $ref", description: "" });
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
120
|
-
|
|
123
|
+
seenRefs.add(ref);
|
|
124
|
+
try {
|
|
125
|
+
walk(target, path, required, depth, seenRefs, ctx);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
seenRefs.delete(ref);
|
|
129
|
+
}
|
|
121
130
|
return;
|
|
122
131
|
}
|
|
123
132
|
const description = typeof schema["description"] === "string" ? schema["description"] : "";
|
|
@@ -136,19 +145,29 @@ function walk(schema, path, required, depth, seenRefs, ctx) {
|
|
|
136
145
|
return;
|
|
137
146
|
const properties = schema["properties"];
|
|
138
147
|
if (isPlainObject(properties)) {
|
|
139
|
-
|
|
148
|
+
// A set: searching the list once per property cost the properties times
|
|
149
|
+
// the required names, and went on past the last row the table holds.
|
|
150
|
+
const requiredNames = new Set(Array.isArray(schema["required"])
|
|
140
151
|
? schema["required"].filter((k) => typeof k === "string")
|
|
141
|
-
: [];
|
|
152
|
+
: []);
|
|
142
153
|
for (const key of Object.keys(properties)) {
|
|
154
|
+
// Once the table is full nothing below adds a row.
|
|
155
|
+
if (ctx.truncated)
|
|
156
|
+
return;
|
|
143
157
|
const childPath = path === "" ? key : `${path}.${key}`;
|
|
144
|
-
walk(properties[key], childPath, requiredNames.
|
|
158
|
+
walk(properties[key], childPath, requiredNames.has(key), depth + 1, seenRefs, ctx);
|
|
145
159
|
}
|
|
146
160
|
}
|
|
147
161
|
const items = schema["items"];
|
|
148
162
|
if (Array.isArray(items)) {
|
|
149
|
-
items.
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
for (let index = 0; index < items.length; index++) {
|
|
164
|
+
if (ctx.truncated)
|
|
165
|
+
return;
|
|
166
|
+
// A hole is skipped, as `forEach` skipped it.
|
|
167
|
+
if (!(index in items))
|
|
168
|
+
continue;
|
|
169
|
+
walk(items[index], `${path}[${index}]`, required, depth + 1, seenRefs, ctx);
|
|
170
|
+
}
|
|
152
171
|
}
|
|
153
172
|
else if (items !== undefined) {
|
|
154
173
|
walk(items, `${path}[]`, required, depth + 1, seenRefs, ctx);
|
|
@@ -167,7 +186,7 @@ export function summarizeSchema(schema, options = {}) {
|
|
|
167
186
|
maxDepth: options.maxDepth ?? DEFAULTS.maxDepth,
|
|
168
187
|
truncated: false,
|
|
169
188
|
};
|
|
170
|
-
walk(schema, "", false, 0,
|
|
189
|
+
walk(schema, "", false, 0, new Set(), ctx);
|
|
171
190
|
const rootObject = isPlainObject(schema) ? schema : {};
|
|
172
191
|
return {
|
|
173
192
|
title: typeof rootObject["title"] === "string" ? rootObject["title"] : "",
|
package/dist/lib/value.d.ts
CHANGED
|
@@ -80,5 +80,19 @@ export declare function canonicalize(value: unknown): string;
|
|
|
80
80
|
* the two differ once the value is serialized.
|
|
81
81
|
*/
|
|
82
82
|
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* A single-line, length-capped rendering of a value, for error messages.
|
|
85
|
+
*
|
|
86
|
+
* It reads only as much of the value as the rendering can show: a validator
|
|
87
|
+
* previews the failing value once per failed check, and serializing the
|
|
88
|
+
* whole of a 2 MB value to keep 60 characters of it made each check cost
|
|
89
|
+
* the value's size (a 794-character schema took 81 s). A run of whitespace
|
|
90
|
+
* folds to one space, so up to eight times `maxChars` is read before the
|
|
91
|
+
* rendering is called cut. Listing an object's keys costs all of them in
|
|
92
|
+
* this engine however few are shown, so a caller that previews the same
|
|
93
|
+
* objects many times (the validator) passes a cache that lives as long as
|
|
94
|
+
* its walk.
|
|
95
|
+
*/
|
|
96
|
+
export declare function preview(value: unknown, maxChars?: number, cache?: PreviewCache): string;
|
|
97
|
+
/** Renderings already made, by object and length; see `preview`. */
|
|
98
|
+
export type PreviewCache = WeakMap<object, Map<number, string>>;
|
package/dist/lib/value.js
CHANGED
|
@@ -222,20 +222,150 @@ export function deepEqual(a, b) {
|
|
|
222
222
|
}
|
|
223
223
|
return false;
|
|
224
224
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
225
|
+
/** Thrown by `jsonPrefix` for a value JSON.stringify would treat specially. */
|
|
226
|
+
class NotPlainJson extends Error {
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The first `cap` characters of `JSON.stringify(value)`, built without
|
|
230
|
+
* serializing the rest: a 2 MB string or a 100,000-key object costs what the
|
|
231
|
+
* prefix costs. `cut` is true when the text stops short of the whole.
|
|
232
|
+
* Throws NotPlainJson for anything but plain JSON data (a `toJSON` method, a
|
|
233
|
+
* class instance, a BigInt), which the caller serializes the old way.
|
|
234
|
+
*/
|
|
235
|
+
function jsonPrefix(value, cap) {
|
|
236
|
+
const parts = [];
|
|
237
|
+
let length = 0;
|
|
238
|
+
let cut = false;
|
|
239
|
+
const push = (piece) => {
|
|
240
|
+
if (length + piece.length > cap) {
|
|
241
|
+
parts.push(piece.slice(0, cap - length));
|
|
242
|
+
length = cap;
|
|
243
|
+
cut = true;
|
|
244
|
+
return false;
|
|
234
245
|
}
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
parts.push(piece);
|
|
247
|
+
length += piece.length;
|
|
248
|
+
return true;
|
|
249
|
+
};
|
|
250
|
+
// A string is escaped from a slice no longer than the room left, so its
|
|
251
|
+
// cost is the room, not its length.
|
|
252
|
+
const pushString = (text) => {
|
|
253
|
+
const room = cap - length;
|
|
254
|
+
if (text.length <= room)
|
|
255
|
+
return push(JSON.stringify(text));
|
|
256
|
+
push(JSON.stringify(text.slice(0, room)).slice(0, -1));
|
|
257
|
+
cut = true;
|
|
258
|
+
return false;
|
|
259
|
+
};
|
|
260
|
+
const emit = (item) => {
|
|
261
|
+
if (item === null)
|
|
262
|
+
return push("null");
|
|
263
|
+
switch (typeof item) {
|
|
264
|
+
case "string":
|
|
265
|
+
return pushString(item);
|
|
266
|
+
case "number":
|
|
267
|
+
return push(Number.isFinite(item) ? JSON.stringify(item) : "null");
|
|
268
|
+
case "boolean":
|
|
269
|
+
return push(item ? "true" : "false");
|
|
270
|
+
case "undefined":
|
|
271
|
+
case "function":
|
|
272
|
+
case "symbol":
|
|
273
|
+
// Reached only inside an array; an object property skips these.
|
|
274
|
+
return push("null");
|
|
275
|
+
case "object": {
|
|
276
|
+
if (typeof item.toJSON === "function")
|
|
277
|
+
throw new NotPlainJson();
|
|
278
|
+
if (Array.isArray(item)) {
|
|
279
|
+
if (!push("["))
|
|
280
|
+
return false;
|
|
281
|
+
for (let i = 0; i < item.length; i++) {
|
|
282
|
+
if (i > 0 && !push(","))
|
|
283
|
+
return false;
|
|
284
|
+
if (!emit(item[i]))
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return push("]");
|
|
288
|
+
}
|
|
289
|
+
const proto = Object.getPrototypeOf(item);
|
|
290
|
+
if (proto !== Object.prototype && proto !== null)
|
|
291
|
+
throw new NotPlainJson();
|
|
292
|
+
if (!push("{"))
|
|
293
|
+
return false;
|
|
294
|
+
let first = true;
|
|
295
|
+
// `for...in` with an own check, not Object.keys: it stops when the
|
|
296
|
+
// prefix is full instead of listing every key first.
|
|
297
|
+
for (const key in item) {
|
|
298
|
+
if (!Object.hasOwn(item, key))
|
|
299
|
+
continue;
|
|
300
|
+
const member = item[key];
|
|
301
|
+
if (member === undefined || typeof member === "function" || typeof member === "symbol") {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!first && !push(","))
|
|
305
|
+
return false;
|
|
306
|
+
first = false;
|
|
307
|
+
if (!pushString(key) || !push(":"))
|
|
308
|
+
return false;
|
|
309
|
+
if (!emit(member))
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
return push("}");
|
|
313
|
+
}
|
|
314
|
+
default:
|
|
315
|
+
throw new NotPlainJson();
|
|
237
316
|
}
|
|
317
|
+
};
|
|
318
|
+
emit(value);
|
|
319
|
+
return { text: parts.join(""), cut };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A single-line, length-capped rendering of a value, for error messages.
|
|
323
|
+
*
|
|
324
|
+
* It reads only as much of the value as the rendering can show: a validator
|
|
325
|
+
* previews the failing value once per failed check, and serializing the
|
|
326
|
+
* whole of a 2 MB value to keep 60 characters of it made each check cost
|
|
327
|
+
* the value's size (a 794-character schema took 81 s). A run of whitespace
|
|
328
|
+
* folds to one space, so up to eight times `maxChars` is read before the
|
|
329
|
+
* rendering is called cut. Listing an object's keys costs all of them in
|
|
330
|
+
* this engine however few are shown, so a caller that previews the same
|
|
331
|
+
* objects many times (the validator) passes a cache that lives as long as
|
|
332
|
+
* its walk.
|
|
333
|
+
*/
|
|
334
|
+
export function preview(value, maxChars = 120, cache) {
|
|
335
|
+
const limit = Math.max(0, maxChars);
|
|
336
|
+
if (cache === undefined || value === null || typeof value !== "object") {
|
|
337
|
+
return renderPreview(value, limit);
|
|
338
|
+
}
|
|
339
|
+
let byLimit = cache.get(value);
|
|
340
|
+
if (byLimit === undefined) {
|
|
341
|
+
byLimit = new Map();
|
|
342
|
+
cache.set(value, byLimit);
|
|
343
|
+
}
|
|
344
|
+
let text = byLimit.get(limit);
|
|
345
|
+
if (text === undefined) {
|
|
346
|
+
text = renderPreview(value, limit);
|
|
347
|
+
byLimit.set(limit, text);
|
|
348
|
+
}
|
|
349
|
+
return text;
|
|
350
|
+
}
|
|
351
|
+
function renderPreview(value, limit) {
|
|
352
|
+
const clipped = (text, cut) => !cut && text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
|
|
353
|
+
if (value === undefined)
|
|
354
|
+
return clipped("undefined", false);
|
|
355
|
+
try {
|
|
356
|
+
const { text, cut } = jsonPrefix(value, limit * 8 + 64);
|
|
357
|
+
return clipped(text.replace(/\s+/g, " "), cut);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (!(err instanceof NotPlainJson) && !(err instanceof RangeError))
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
let text;
|
|
364
|
+
try {
|
|
365
|
+
text = JSON.stringify(value) ?? String(value);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
text = String(value);
|
|
238
369
|
}
|
|
239
|
-
|
|
240
|
-
return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
370
|
+
return clipped(text.replace(/\s+/g, " "), false);
|
|
241
371
|
}
|