@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.
@@ -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
+ }