@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,395 @@
1
+ /**
2
+ * String format checks, written out rather than borrowed.
3
+ *
4
+ * Every one of these is a *stated subset*, not a full grammar. The rule this
5
+ * file follows: reject what is definitely wrong, accept what is definitely
6
+ * right, and say in the doc comment which grey areas fall which way — so a
7
+ * harness gating on `ValidateFormat` knows exactly what it bought. A format
8
+ * check that quietly disagrees with the reader downstream is worse than no
9
+ * check at all.
10
+ */
11
+ /** Every format name {@link checkFormat} understands. */
12
+ export const FORMAT_NAMES = [
13
+ "email",
14
+ "uri",
15
+ "uri-reference",
16
+ "hostname",
17
+ "ipv4",
18
+ "ipv6",
19
+ "uuid",
20
+ "date",
21
+ "time",
22
+ "date-time",
23
+ "json-pointer",
24
+ "regex",
25
+ ];
26
+ const OK = { valid: true, reason: null };
27
+ const bad = (reason) => ({ valid: false, reason });
28
+ /** True when `name` is one this module implements. */
29
+ export function isFormatName(name) {
30
+ return FORMAT_NAMES.includes(name);
31
+ }
32
+ /**
33
+ * A DNS hostname: dot-separated labels, each 1-63 characters of letters,
34
+ * digits and hyphens, not starting or ending with a hyphen; 253 characters
35
+ * total at most.
36
+ *
37
+ * Deliberate choices: a trailing dot (the DNS root) is rejected, underscores
38
+ * are rejected (legal in some DNS records, not in a hostname), and no
39
+ * internationalized form is decoded — a Unicode domain must arrive already
40
+ * punycoded.
41
+ */
42
+ export function isHostname(value) {
43
+ if (value === "")
44
+ return bad("empty hostname");
45
+ if (value.length > 253)
46
+ return bad(`${value.length} characters, over the 253 limit`);
47
+ for (const label of value.split(".")) {
48
+ if (label === "")
49
+ return bad("empty label (a leading, doubled or trailing dot)");
50
+ if (label.length > 63)
51
+ return bad(`label "${label}" is over 63 characters`);
52
+ if (!/^[A-Za-z0-9-]+$/.test(label)) {
53
+ return bad(`label "${label}" has a character outside A-Z a-z 0-9 -`);
54
+ }
55
+ if (label.startsWith("-") || label.endsWith("-")) {
56
+ return bad(`label "${label}" starts or ends with a hyphen`);
57
+ }
58
+ }
59
+ return OK;
60
+ }
61
+ /**
62
+ * Dotted-quad IPv4. Four decimal octets 0-255, no leading zeros — `01.2.3.4`
63
+ * is rejected, because a leading zero means octal to some parsers and
64
+ * decimal to others, and that ambiguity has been a source of SSRF bugs.
65
+ */
66
+ export function isIpv4(value) {
67
+ const parts = value.split(".");
68
+ if (parts.length !== 4)
69
+ return bad(`expected 4 dot-separated octets, found ${parts.length}`);
70
+ for (const part of parts) {
71
+ if (!/^\d{1,3}$/.test(part))
72
+ return bad(`octet "${part}" is not 1-3 digits`);
73
+ if (part.length > 1 && part.startsWith("0"))
74
+ return bad(`octet "${part}" has a leading zero`);
75
+ if (Number(part) > 255)
76
+ return bad(`octet "${part}" is over 255`);
77
+ }
78
+ return OK;
79
+ }
80
+ /**
81
+ * IPv6, including `::` compression and a dotted-quad IPv4 tail
82
+ * (`::ffff:192.0.2.1`). A zone identifier (`fe80::1%eth0`) is rejected: it
83
+ * addresses an interface, not a host, and is not part of the `ipv6` format.
84
+ */
85
+ export function isIpv6(value) {
86
+ if (value === "")
87
+ return bad("empty address");
88
+ if (value.includes("%"))
89
+ return bad("zone identifiers are not part of the ipv6 format");
90
+ const doubleColons = value.split("::").length - 1;
91
+ if (doubleColons > 1)
92
+ return bad('"::" may appear at most once');
93
+ /**
94
+ * Count the groups in one run of colon-separated text. An empty run is
95
+ * zero groups, which is what each side of a leading or trailing `::` is;
96
+ * an empty *group* is a stray colon and is rejected, which is what keeps
97
+ * `:::`, `1:::2` and a trailing `1:2:...:8:` out. A dotted quad is only
98
+ * accepted as the final group of the run, and stands for two groups.
99
+ */
100
+ const countGroups = (text, allowQuad) => {
101
+ if (text === "")
102
+ return { groups: 0 };
103
+ const parts = text.split(":");
104
+ let groups = 0;
105
+ for (let i = 0; i < parts.length; i++) {
106
+ const part = parts[i];
107
+ if (allowQuad && i === parts.length - 1 && part.includes(".")) {
108
+ const v4 = isIpv4(part);
109
+ if (!v4.valid)
110
+ return { error: `embedded IPv4 "${part}": ${v4.reason}` };
111
+ groups += 2;
112
+ continue;
113
+ }
114
+ if (!/^[0-9A-Fa-f]{1,4}$/.test(part)) {
115
+ return { error: `group "${part}" is not 1-4 hex digits` };
116
+ }
117
+ groups += 1;
118
+ }
119
+ return { groups };
120
+ };
121
+ if (doubleColons === 1) {
122
+ // The quad may only appear after the compression, so the left side never
123
+ // carries one; a dot there is a malformed group and is rejected as such.
124
+ const [rawLeft = "", rawRight = ""] = value.split("::");
125
+ const left = countGroups(rawLeft, false);
126
+ if ("error" in left)
127
+ return bad(left.error);
128
+ const right = countGroups(rawRight, true);
129
+ if ("error" in right)
130
+ return bad(right.error);
131
+ const total = left.groups + right.groups;
132
+ if (total > 7) {
133
+ return bad(`"::" must stand for at least one group, but ${total} are written out`);
134
+ }
135
+ return OK;
136
+ }
137
+ const written = countGroups(value, true);
138
+ if ("error" in written)
139
+ return bad(written.error);
140
+ if (written.groups !== 8)
141
+ return bad(`expected 8 groups, found ${written.groups}`);
142
+ return OK;
143
+ }
144
+ /**
145
+ * A UUID in the 8-4-4-4-12 hex form, any case.
146
+ *
147
+ * Any version and variant is accepted, including the nil UUID — this checks
148
+ * the shape, not that the value came from a v4 generator. Braces and a
149
+ * `urn:uuid:` prefix are rejected.
150
+ */
151
+ export function isUuid(value) {
152
+ if (!/^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$/.test(value)) {
153
+ return bad("expected 8-4-4-4-12 hexadecimal digits");
154
+ }
155
+ return OK;
156
+ }
157
+ /** Days in a month, honouring the Gregorian leap-year rule. */
158
+ export function daysInMonth(year, month) {
159
+ if (month === 2) {
160
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
161
+ return leap ? 29 : 28;
162
+ }
163
+ return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
164
+ }
165
+ /**
166
+ * RFC 3339 `full-date`: `YYYY-MM-DD`, calendar-checked, so `2023-02-30` and
167
+ * `2023-13-01` are rejected and `2024-02-29` is accepted. No other layout is
168
+ * accepted; `2023-1-1` fails.
169
+ */
170
+ export function isDate(value) {
171
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
172
+ if (match === null)
173
+ return bad("expected YYYY-MM-DD");
174
+ const year = Number(match[1]);
175
+ const month = Number(match[2]);
176
+ const day = Number(match[3]);
177
+ if (month < 1 || month > 12)
178
+ return bad(`month ${month} is not 1-12`);
179
+ const max = daysInMonth(year, month);
180
+ if (day < 1 || day > max)
181
+ return bad(`day ${day} is outside 1-${max} for that month`);
182
+ return OK;
183
+ }
184
+ /**
185
+ * RFC 3339 `full-time`: `HH:MM:SS`, an optional fractional part, and a
186
+ * mandatory offset of `Z`, `z`, or `+HH:MM` / `-HH:MM`.
187
+ *
188
+ * A seconds value of 60 is accepted, because RFC 3339 allows it for leap
189
+ * seconds. An offset with minutes over 59 is rejected.
190
+ */
191
+ export function isTime(value) {
192
+ const match = value.match(/^(\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/);
193
+ if (match === null)
194
+ return bad("expected HH:MM:SS with an offset of Z or +HH:MM");
195
+ const hour = Number(match[1]);
196
+ const minute = Number(match[2]);
197
+ const second = Number(match[3]);
198
+ if (hour > 23)
199
+ return bad(`hour ${hour} is over 23`);
200
+ if (minute > 59)
201
+ return bad(`minute ${minute} is over 59`);
202
+ if (second > 60)
203
+ return bad(`second ${second} is over 60`);
204
+ const offset = match[5] ?? "";
205
+ if (offset !== "Z" && offset !== "z") {
206
+ const offsetHour = Number(offset.slice(1, 3));
207
+ const offsetMinute = Number(offset.slice(4, 6));
208
+ if (offsetHour > 23)
209
+ return bad(`offset hour ${offsetHour} is over 23`);
210
+ if (offsetMinute > 59)
211
+ return bad(`offset minute ${offsetMinute} is over 59`);
212
+ }
213
+ return OK;
214
+ }
215
+ /**
216
+ * RFC 3339 `date-time`: a date, a `T` (or lowercase `t`) separator, and a
217
+ * time with an offset. A space separator, as SQL writes it, is rejected —
218
+ * this is the strict interchange form.
219
+ */
220
+ export function isDateTime(value) {
221
+ const split = value.match(/^(.+?)[Tt](.+)$/);
222
+ if (split === null)
223
+ return bad('expected a "T" between the date and the time');
224
+ const date = isDate(split[1] ?? "");
225
+ if (!date.valid)
226
+ return bad(`date part: ${date.reason}`);
227
+ const time = isTime(split[2] ?? "");
228
+ if (!time.valid)
229
+ return bad(`time part: ${time.reason}`);
230
+ return OK;
231
+ }
232
+ /**
233
+ * An email address, in the form services actually accept: a dot-separated
234
+ * local part of unquoted ASCII characters and a hostname domain.
235
+ *
236
+ * Deliberately narrower than RFC 5322: quoted local parts, comments,
237
+ * IP-literal domains (`a@[192.0.2.1]`) and non-ASCII are all rejected. A
238
+ * domain with no dot (`a@localhost`) is rejected too, since a bare host is
239
+ * never a deliverable address in practice.
240
+ */
241
+ export function isEmail(value) {
242
+ const at = value.lastIndexOf("@");
243
+ if (at < 0)
244
+ return bad('no "@"');
245
+ const local = value.slice(0, at);
246
+ const domain = value.slice(at + 1);
247
+ if (local === "")
248
+ return bad("empty local part");
249
+ if (local.length > 64)
250
+ return bad(`local part is ${local.length} characters, over the 64 limit`);
251
+ if (!/^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+$/.test(local)) {
252
+ return bad("local part has a character outside the unquoted set");
253
+ }
254
+ if (local.startsWith(".") || local.endsWith(".")) {
255
+ return bad("local part starts or ends with a dot");
256
+ }
257
+ if (local.includes(".."))
258
+ return bad("local part has two consecutive dots");
259
+ const host = isHostname(domain);
260
+ if (!host.valid)
261
+ return bad(`domain: ${host.reason}`);
262
+ if (!domain.includes("."))
263
+ return bad("domain has no dot");
264
+ return OK;
265
+ }
266
+ /**
267
+ * True when the string holds a character no URI may contain: any whitespace,
268
+ * any C0 control character, or DEL. The codes are compared numerically rather
269
+ * than through a character class so no control character appears in source.
270
+ */
271
+ function hasIllegalUriChar(value) {
272
+ for (const char of value) {
273
+ const code = char.codePointAt(0) ?? 0;
274
+ if (code <= 0x20 || code === 0x7f || /\s/.test(char))
275
+ return true;
276
+ }
277
+ return false;
278
+ }
279
+ /**
280
+ * An absolute URI: a scheme (`[A-Za-z][A-Za-z0-9+.-]*`), a colon, and a
281
+ * non-empty remainder with no whitespace or control characters.
282
+ *
283
+ * This is the absolute-URI shape check, not the full RFC 3986 grammar: the
284
+ * authority, path, query and fragment are not parsed. `/path` and
285
+ * `page.html` are rejected — use `uri-reference` for those.
286
+ */
287
+ export function isUri(value) {
288
+ if (hasIllegalUriChar(value))
289
+ return bad("contains whitespace or a control character");
290
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:.+$/.test(value)) {
291
+ return bad("no scheme — an absolute URI needs one, e.g. https:");
292
+ }
293
+ return OK;
294
+ }
295
+ /**
296
+ * A URI reference: either an absolute URI or a relative one. Only whitespace,
297
+ * control characters and a second `#` are rejected; anything else a browser
298
+ * would resolve is accepted.
299
+ */
300
+ export function isUriReference(value) {
301
+ if (hasIllegalUriChar(value))
302
+ return bad("contains whitespace or a control character");
303
+ if (value.split("#").length > 2)
304
+ return bad('more than one "#"');
305
+ return OK;
306
+ }
307
+ /** RFC 6901 JSON Pointer: empty, or `/`-led tokens where `~` is followed by 0 or 1. */
308
+ export function isJsonPointer(value) {
309
+ if (value === "")
310
+ return OK;
311
+ if (!value.startsWith("/"))
312
+ return bad('a non-empty pointer must start with "/"');
313
+ if (/~(?![01])/.test(value))
314
+ return bad('"~" must be followed by 0 or 1');
315
+ return OK;
316
+ }
317
+ /** A string that compiles as a JavaScript regular expression. */
318
+ export function isRegex(value) {
319
+ try {
320
+ new RegExp(value);
321
+ return OK;
322
+ }
323
+ catch (err) {
324
+ return bad(err.message);
325
+ }
326
+ }
327
+ /**
328
+ * Check one string against one format name. An unknown format name is a
329
+ * caller error and throws; every implemented format returns a result.
330
+ */
331
+ export function checkFormat(value, format) {
332
+ switch (format) {
333
+ case "email":
334
+ return isEmail(value);
335
+ case "uri":
336
+ return isUri(value);
337
+ case "uri-reference":
338
+ return isUriReference(value);
339
+ case "hostname":
340
+ return isHostname(value);
341
+ case "ipv4":
342
+ return isIpv4(value);
343
+ case "ipv6":
344
+ return isIpv6(value);
345
+ case "uuid":
346
+ return isUuid(value);
347
+ case "date":
348
+ return isDate(value);
349
+ case "time":
350
+ return isTime(value);
351
+ case "date-time":
352
+ return isDateTime(value);
353
+ case "json-pointer":
354
+ return isJsonPointer(value);
355
+ case "regex":
356
+ return isRegex(value);
357
+ default:
358
+ throw new Error(`unknown format "${String(format)}"`);
359
+ }
360
+ }
361
+ /**
362
+ * The formats, narrowest first.
363
+ *
364
+ * These grammars overlap, and the overlap is not symmetric: every dotted
365
+ * quad, every UUID and every `YYYY-MM-DD` date is also a syntactically valid
366
+ * hostname, since a hostname label is just letters, digits and hyphens. A
367
+ * caller asking "what does this value look like" wants `uuid` before
368
+ * `hostname`, so the answer is ordered by how much each format rules out
369
+ * rather than by declaration order.
370
+ *
371
+ * `regex` and `uri-reference` are absent because they match nearly every
372
+ * string; reporting them would be noise rather than a finding.
373
+ */
374
+ const FORMAT_SPECIFICITY = [
375
+ "uuid",
376
+ "ipv4",
377
+ "ipv6",
378
+ "date-time",
379
+ "date",
380
+ "time",
381
+ "email",
382
+ "json-pointer",
383
+ "uri",
384
+ "hostname",
385
+ ];
386
+ /**
387
+ * The formats a value matches, narrowest first — see
388
+ * {@link FORMAT_SPECIFICITY}. Used by `ValidateFormat` to tell a caller what
389
+ * their value *does* look like when it fails the format they asked for, and
390
+ * by schema inference, which narrows the list further before labelling a
391
+ * field.
392
+ */
393
+ export function matchingFormats(value) {
394
+ return FORMAT_SPECIFICITY.filter((name) => checkFormat(value, name).valid);
395
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Inferring a JSON Schema from example values.
3
+ *
4
+ * What this produces is a *description of the samples*, which is the honest
5
+ * thing to produce: it states types, structure and (optionally) formats and
6
+ * small enumerations, and deliberately infers no numeric bounds, no string
7
+ * lengths and no patterns, because three samples never justify a range. The
8
+ * output is a starting point an operator tightens by hand, not a contract
9
+ * discovered from data.
10
+ *
11
+ * Property order follows first appearance across the samples, so the same
12
+ * samples always produce byte-identical output.
13
+ */
14
+ import { type FormatName } from "./formats";
15
+ export type InferOptions = {
16
+ /** Mark a property required when every object sample at that node had it. */
17
+ requireAll: boolean;
18
+ /** Label a string field with a format when every sample matches one. */
19
+ detectFormats: boolean;
20
+ /** Turn a small, closed set of string values into an `enum`. */
21
+ detectEnums: boolean;
22
+ /** At most this many distinct values may become an enum. */
23
+ enumThreshold: number;
24
+ /** Emit `additionalProperties: false` on every inferred object. */
25
+ closed: boolean;
26
+ /** Stop descending past this depth and emit an unconstrained schema. */
27
+ maxDepth: number;
28
+ };
29
+ export declare const DEFAULT_INFER_OPTIONS: InferOptions;
30
+ /**
31
+ * The most specific format every sample matches, or `null`. "Most specific"
32
+ * is the order {@link matchingFormats} returns, narrowest first, so a UUID or
33
+ * a date is labelled as such rather than as the `hostname` it also satisfies.
34
+ * {@link inferableFormats} first drops the grammars too loose to assert from
35
+ * samples.
36
+ */
37
+ export declare function commonFormat(samples: string[]): FormatName | null;
38
+ /**
39
+ * Infer one schema covering every sample. At least one sample is required;
40
+ * more samples make the result narrower in the parts that are genuinely
41
+ * shared and wider in the parts that are not (a property missing from one
42
+ * sample stops being required, a field that is sometimes null gains `null`
43
+ * in its type).
44
+ */
45
+ export declare function inferSchema(samples: unknown[], options?: Partial<InferOptions>): Record<string, unknown>;
46
+ /**
47
+ * How well an existing schema already covers a set of samples, counted by
48
+ * distinct canonical value — the number a caller wants before deciding to
49
+ * replace a schema with an inferred one.
50
+ */
51
+ export declare function distinctCount(samples: unknown[]): number;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Inferring a JSON Schema from example values.
3
+ *
4
+ * What this produces is a *description of the samples*, which is the honest
5
+ * thing to produce: it states types, structure and (optionally) formats and
6
+ * small enumerations, and deliberately infers no numeric bounds, no string
7
+ * lengths and no patterns, because three samples never justify a range. The
8
+ * output is a starting point an operator tightens by hand, not a contract
9
+ * discovered from data.
10
+ *
11
+ * Property order follows first appearance across the samples, so the same
12
+ * samples always produce byte-identical output.
13
+ */
14
+ import { matchingFormats } from "./formats";
15
+ import { canonicalize, isPlainObject, typeOf } from "./value";
16
+ export const DEFAULT_INFER_OPTIONS = {
17
+ requireAll: true,
18
+ detectFormats: true,
19
+ detectEnums: false,
20
+ enumThreshold: 12,
21
+ closed: false,
22
+ maxDepth: 12,
23
+ };
24
+ /** How many distinct string values a node remembers before giving up on enums. */
25
+ const MAX_TRACKED_STRINGS = 200;
26
+ function emptyNode() {
27
+ return {
28
+ count: 0,
29
+ types: new Set(),
30
+ props: new Map(),
31
+ propCounts: new Map(),
32
+ objectCount: 0,
33
+ items: null,
34
+ stringCount: 0,
35
+ distinctStrings: new Set(),
36
+ stringsOverflowed: false,
37
+ };
38
+ }
39
+ function observe(node, value, depth, maxDepth) {
40
+ node.count += 1;
41
+ const type = typeOf(value);
42
+ node.types.add(type);
43
+ if (type === "string") {
44
+ node.stringCount += 1;
45
+ if (node.distinctStrings.size < MAX_TRACKED_STRINGS)
46
+ node.distinctStrings.add(value);
47
+ else if (!node.distinctStrings.has(value))
48
+ node.stringsOverflowed = true;
49
+ return;
50
+ }
51
+ if (depth >= maxDepth)
52
+ return;
53
+ if (Array.isArray(value)) {
54
+ if (value.length > 0 && node.items === null)
55
+ node.items = emptyNode();
56
+ for (const item of value)
57
+ observe(node.items, item, depth + 1, maxDepth);
58
+ return;
59
+ }
60
+ if (isPlainObject(value)) {
61
+ node.objectCount += 1;
62
+ for (const key of Object.keys(value)) {
63
+ let child = node.props.get(key);
64
+ if (child === undefined) {
65
+ child = emptyNode();
66
+ node.props.set(key, child);
67
+ }
68
+ node.propCounts.set(key, (node.propCounts.get(key) ?? 0) + 1);
69
+ observe(child, value[key], depth + 1, maxDepth);
70
+ }
71
+ }
72
+ }
73
+ /** The order types are written in, so output is stable. */
74
+ const TYPE_ORDER = [
75
+ "null",
76
+ "boolean",
77
+ "integer",
78
+ "number",
79
+ "string",
80
+ "array",
81
+ "object",
82
+ ];
83
+ function renderTypes(types) {
84
+ const widened = new Set(types);
85
+ // A node that saw both 3 and 3.5 is a number, not "integer or number".
86
+ if (widened.has("number"))
87
+ widened.delete("integer");
88
+ return TYPE_ORDER.filter((t) => widened.has(t));
89
+ }
90
+ function render(node, opts, depth) {
91
+ if (depth > opts.maxDepth || node.types.size === 0)
92
+ return {};
93
+ const schema = {};
94
+ const types = renderTypes(node.types);
95
+ schema["type"] = types.length === 1 ? types[0] : types;
96
+ if (node.types.has("object")) {
97
+ const properties = {};
98
+ const required = [];
99
+ for (const [key, child] of node.props) {
100
+ properties[key] = render(child, opts, depth + 1);
101
+ if (opts.requireAll && node.propCounts.get(key) === node.objectCount)
102
+ required.push(key);
103
+ }
104
+ schema["properties"] = properties;
105
+ if (required.length > 0)
106
+ schema["required"] = required;
107
+ if (opts.closed)
108
+ schema["additionalProperties"] = false;
109
+ }
110
+ if (node.types.has("array")) {
111
+ // An array position that only ever held empty arrays says nothing about
112
+ // its items, so no `items` is emitted rather than a false `{}` claim.
113
+ if (node.items !== null)
114
+ schema["items"] = render(node.items, opts, depth + 1);
115
+ }
116
+ if (node.types.has("string") && node.stringCount > 0) {
117
+ if (opts.detectEnums &&
118
+ !node.stringsOverflowed &&
119
+ node.distinctStrings.size <= opts.enumThreshold) {
120
+ schema["enum"] = [...node.distinctStrings].sort();
121
+ }
122
+ else if (opts.detectFormats && !node.stringsOverflowed) {
123
+ // Past MAX_TRACKED_STRINGS the node stopped remembering values, so a
124
+ // format agreed on by the ones it kept says nothing about the ones it
125
+ // dropped. No label is the honest answer.
126
+ const format = commonFormat([...node.distinctStrings]);
127
+ if (format !== null)
128
+ schema["format"] = format;
129
+ }
130
+ }
131
+ return schema;
132
+ }
133
+ /**
134
+ * Formats too weak to infer from samples alone, and what it takes to earn
135
+ * them. Inference writes a *claim* about every future value of a field, so a
136
+ * grammar that almost everything satisfies is worse than no label at all:
137
+ *
138
+ * - **hostname** accepts any bare word, so `"active"` and `"pending"` are
139
+ * both valid hostnames. It is only inferred when every sample has a dot,
140
+ * which is the shape a real host name has.
141
+ * - **json-pointer** accepts the empty string and anything starting with
142
+ * `/`, so every Unix path qualifies. It is never inferred.
143
+ *
144
+ * Validation is unaffected — `ValidateFormat` and `assertFormat` still check
145
+ * these in full. This narrows only what inference is willing to assert.
146
+ */
147
+ function inferableFormats(sample) {
148
+ return matchingFormats(sample).filter((format) => {
149
+ if (format === "json-pointer")
150
+ return false;
151
+ if (format === "hostname")
152
+ return sample.includes(".");
153
+ return true;
154
+ });
155
+ }
156
+ /**
157
+ * The most specific format every sample matches, or `null`. "Most specific"
158
+ * is the order {@link matchingFormats} returns, narrowest first, so a UUID or
159
+ * a date is labelled as such rather than as the `hostname` it also satisfies.
160
+ * {@link inferableFormats} first drops the grammars too loose to assert from
161
+ * samples.
162
+ */
163
+ export function commonFormat(samples) {
164
+ if (samples.length === 0)
165
+ return null;
166
+ let shared = null;
167
+ for (const sample of samples) {
168
+ const formats = inferableFormats(sample);
169
+ if (formats.length === 0)
170
+ return null;
171
+ shared = shared === null ? formats : shared.filter((f) => formats.includes(f));
172
+ if (shared.length === 0)
173
+ return null;
174
+ }
175
+ return shared?.[0] ?? null;
176
+ }
177
+ /**
178
+ * Infer one schema covering every sample. At least one sample is required;
179
+ * more samples make the result narrower in the parts that are genuinely
180
+ * shared and wider in the parts that are not (a property missing from one
181
+ * sample stops being required, a field that is sometimes null gains `null`
182
+ * in its type).
183
+ */
184
+ export function inferSchema(samples, options = {}) {
185
+ const opts = { ...DEFAULT_INFER_OPTIONS, ...options };
186
+ if (samples.length === 0)
187
+ throw new Error("inferSchema needs at least one sample");
188
+ const root = emptyNode();
189
+ for (const sample of samples)
190
+ observe(root, sample, 0, opts.maxDepth);
191
+ const schema = render(root, opts, 0);
192
+ return { $schema: "http://json-schema.org/draft-07/schema#", ...schema };
193
+ }
194
+ /**
195
+ * How well an existing schema already covers a set of samples, counted by
196
+ * distinct canonical value — the number a caller wants before deciding to
197
+ * replace a schema with an inferred one.
198
+ */
199
+ export function distinctCount(samples) {
200
+ return new Set(samples.map(canonicalize)).size;
201
+ }
@@ -0,0 +1,50 @@
1
+ /** A schema is an object of keywords, or a boolean that accepts/rejects all. */
2
+ export type Schema = boolean | Record<string, unknown>;
3
+ /** One failure, addressed by a JSON Pointer into the value. */
4
+ export type ValidationError = {
5
+ /** JSON Pointer to the offending value; `""` is the whole document. */
6
+ path: string;
7
+ /** The keyword that rejected it. */
8
+ keyword: string;
9
+ /** A message written for a person reading a failed gate. */
10
+ message: string;
11
+ /** JSON Pointer to the keyword inside the schema. */
12
+ schemaPath: string;
13
+ };
14
+ export type ValidateOptions = {
15
+ /** Enforce `format` instead of treating it as an annotation. */
16
+ assertFormat: boolean;
17
+ /** Stop collecting after this many errors. */
18
+ maxErrors: number;
19
+ };
20
+ export type ValidationResult = {
21
+ valid: boolean;
22
+ errors: ValidationError[];
23
+ /** True when `maxErrors` cut the list short. */
24
+ truncated: boolean;
25
+ /**
26
+ * Constraint keywords this validator does not enforce, from the subschemas
27
+ * it actually walked for this value — not a static scan of the whole
28
+ * document. Sorted, so the result is stable.
29
+ */
30
+ unsupportedKeywords: string[];
31
+ };
32
+ /**
33
+ * Validate `value` against `schema`, returning every failure with a JSON
34
+ * Pointer path. See the module comment for the exact supported subset; the
35
+ * result's `unsupportedKeywords` names anything in the schema that this
36
+ * validator did not enforce, so a pass can be read honestly.
37
+ */
38
+ export declare function validateValue(value: unknown, schema: Schema, options?: Partial<ValidateOptions>): ValidationResult;
39
+ /**
40
+ * A structural sanity check on the schema itself, run before validation so a
41
+ * typo like `{"type": "sting"}` is reported as a bad schema rather than as
42
+ * every value being wrong. Returns the problems found; an empty array means
43
+ * the schema is well formed as far as this validator is concerned.
44
+ *
45
+ * Every position where the spec expects a subschema is descended into,
46
+ * including `$defs` and `definitions` — a typo in a definition is invisible
47
+ * at the top level and rejects every value that reaches it through a `$ref`,
48
+ * which is exactly the failure this check exists to catch.
49
+ */
50
+ export declare function checkSchemaShape(schema: unknown, path?: string, depth?: number): string[];