@crewhaus/tool-schema 0.7.0 → 0.8.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.
@@ -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 { deepEqual, isPlainObject, joinPointer, preview } from "./value";
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 (deepEqual(before, after))
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 (deepEqual(before, after))
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 (deepEqual(before, after))
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.filter((v) => !bEnum.some((w) => deepEqual(v, w)));
180
- const added = bEnum.filter((v) => !aEnum.some((w) => deepEqual(v, w)));
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 (!deepEqual(a["const"], b["const"]) || aConst !== bConst) {
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
- const nowRequired = bRequired.filter((k) => !aRequired.includes(k));
209
- const noLongerRequired = aRequired.filter((k) => !bRequired.includes(k));
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 (deepEqual(beforeValue, afterValue))
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 = bRequired.includes(key);
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 (!deepEqual(a["additionalProperties"], b["additionalProperties"])) {
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 (!deepEqual(aItems, bItems)) {
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 (deepEqual(a[keyword], b[keyword]))
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 (deepEqual(a[keyword], b[keyword]))
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 ctx = { changes: [] };
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: deepEqual(before, after),
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
+ }
@@ -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
- if (seenRefs.includes(ref)) {
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
- walk(target, path, required, depth, [...seenRefs, ref], ctx);
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
- const requiredNames = Array.isArray(schema["required"])
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.includes(key), depth + 1, seenRefs, ctx);
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.forEach((item, index) => {
150
- walk(item, `${path}[${index}]`, required, depth + 1, seenRefs, ctx);
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, [], ctx);
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"] : "",
@@ -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
- /** A single-line, length-capped rendering of a value, for error messages. */
84
- export declare function preview(value: unknown, maxChars?: number): string;
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
- /** A single-line, length-capped rendering of a value, for error messages. */
226
- export function preview(value, maxChars = 120) {
227
- let text;
228
- if (value === undefined) {
229
- text = "undefined";
230
- }
231
- else {
232
- try {
233
- text = JSON.stringify(value) ?? String(value);
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
- catch {
236
- text = String(value);
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
- text = text.replace(/\s+/g, " ");
240
- return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 3))}...`;
370
+ return clipped(text.replace(/\s+/g, " "), false);
241
371
  }