@crewhaus/tool-schema 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +678 -0
- package/dist/lib/assert.d.ts +62 -0
- package/dist/lib/assert.js +343 -0
- package/dist/lib/compare.d.ts +67 -0
- package/dist/lib/compare.js +298 -0
- package/dist/lib/formats.d.ts +115 -0
- package/dist/lib/formats.js +395 -0
- package/dist/lib/infer.d.ts +51 -0
- package/dist/lib/infer.js +201 -0
- package/dist/lib/jsonschema.d.ts +50 -0
- package/dist/lib/jsonschema.js +605 -0
- package/dist/lib/records.d.ts +114 -0
- package/dist/lib/records.js +234 -0
- package/dist/lib/schemadiff.d.ts +26 -0
- package/dist/lib/schemadiff.js +350 -0
- package/dist/lib/suggest.d.ts +25 -0
- package/dist/lib/suggest.js +65 -0
- package/dist/lib/summarize.d.ts +28 -0
- package/dist/lib/summarize.js +212 -0
- package/dist/lib/value.d.ts +84 -0
- package/dist/lib/value.js +241 -0
- package/package.json +45 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value layer: what a JSON value *is*, how to address a part of it, and
|
|
3
|
+
* how to compare two of them.
|
|
4
|
+
*
|
|
5
|
+
* Everything above this file — the schema validator, the assertion runner,
|
|
6
|
+
* the golden comparison — addresses values through these helpers, so there is
|
|
7
|
+
* exactly one answer in this package to "what type is this", "what does this
|
|
8
|
+
* path point at" and "are these two equal".
|
|
9
|
+
*/
|
|
10
|
+
/** The seven JSON Schema type names. `integer` is a number with no fraction. */
|
|
11
|
+
export type JsonType = "null" | "boolean" | "number" | "integer" | "string" | "array" | "object";
|
|
12
|
+
/**
|
|
13
|
+
* The type name a JSON Schema `type` keyword would use. Note that a whole
|
|
14
|
+
* number reports `integer`, not `number` — callers that want the Draft-07
|
|
15
|
+
* rule (an integer also satisfies `number`) must widen it themselves, which
|
|
16
|
+
* `matchesType` below does.
|
|
17
|
+
*/
|
|
18
|
+
export declare function typeOf(value: unknown): JsonType;
|
|
19
|
+
/** True when `actual` satisfies the schema type name `expected`. */
|
|
20
|
+
export declare function matchesType(actual: JsonType, expected: string): boolean;
|
|
21
|
+
/** True for a value that is an object and neither null nor an array. */
|
|
22
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* True for a value JSON can represent losslessly. `undefined`, functions,
|
|
25
|
+
* symbols, NaN and the infinities are not JSON; neither is any object
|
|
26
|
+
* reachable from the value that contains one. Cycles report false rather
|
|
27
|
+
* than hanging.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isJsonValue(value: unknown, seen?: Set<object>): boolean;
|
|
30
|
+
/** Escape one reference token: a tilde becomes `~0`, a slash becomes `~1`. */
|
|
31
|
+
export declare function escapePointerToken(token: string): string;
|
|
32
|
+
/** Reverse of {@link escapePointerToken}; `~1` is undone before `~0`. */
|
|
33
|
+
export declare function unescapePointerToken(token: string): string;
|
|
34
|
+
/** Append one token to a pointer, escaping it. `joinPointer("", "a/b")` is `/a~1b`. */
|
|
35
|
+
export declare function joinPointer(base: string, token: string | number): string;
|
|
36
|
+
/**
|
|
37
|
+
* Split a JSON Pointer into its reference tokens. The empty pointer is the
|
|
38
|
+
* whole document and yields `[]`. A pointer that does not start with `/` is
|
|
39
|
+
* rejected, because silently accepting it would make `a/b` and `/a/b` mean
|
|
40
|
+
* the same thing and hide a caller's mistake.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parsePointer(pointer: string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* A dotted path, the form people actually type: `user.address.city`,
|
|
45
|
+
* `items[0].id`, `items.0.id`, and `["odd.key"]` for a key containing a dot.
|
|
46
|
+
* Returns the segments as strings; array indices arrive as their decimal
|
|
47
|
+
* text and are matched against array positions by {@link resolveSegments}.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseDottedPath(path: string): string[];
|
|
50
|
+
/** The outcome of walking a path: whether it resolved, and to what. */
|
|
51
|
+
export type Resolution = {
|
|
52
|
+
found: boolean;
|
|
53
|
+
value: unknown;
|
|
54
|
+
missingAt: string | null;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Walk already-split segments through a value. Stops at the first segment
|
|
58
|
+
* that does not resolve and reports it, so a caller can say *where* a path
|
|
59
|
+
* ran out rather than only that it did.
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveSegments(root: unknown, segments: string[]): Resolution;
|
|
62
|
+
/** Resolve a dotted path (`items[0].id`) against a value. */
|
|
63
|
+
export declare function getPath(root: unknown, path: string): Resolution;
|
|
64
|
+
/** Resolve a JSON Pointer (`/items/0/id`) against a value. */
|
|
65
|
+
export declare function getPointer(root: unknown, pointer: string): Resolution;
|
|
66
|
+
/**
|
|
67
|
+
* A stable string for a value: object keys sorted, so two values that differ
|
|
68
|
+
* only in key order produce the same bytes. Used for `uniqueItems`, for
|
|
69
|
+
* duplicate detection and for unordered array matching.
|
|
70
|
+
*
|
|
71
|
+
* Non-JSON values (undefined, NaN, functions) are encoded with a distinct
|
|
72
|
+
* marker rather than being dropped the way `JSON.stringify` drops them,
|
|
73
|
+
* because a dropped key silently changes the meaning of the comparison.
|
|
74
|
+
*/
|
|
75
|
+
export declare function canonicalize(value: unknown): string;
|
|
76
|
+
/**
|
|
77
|
+
* Structural equality with JSON semantics: same type, same members, key
|
|
78
|
+
* order irrelevant. `NaN` is not equal to itself, following `===`, and a
|
|
79
|
+
* key present with value `undefined` is not equal to a missing key, because
|
|
80
|
+
* the two differ once the value is serialized.
|
|
81
|
+
*/
|
|
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;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value layer: what a JSON value *is*, how to address a part of it, and
|
|
3
|
+
* how to compare two of them.
|
|
4
|
+
*
|
|
5
|
+
* Everything above this file — the schema validator, the assertion runner,
|
|
6
|
+
* the golden comparison — addresses values through these helpers, so there is
|
|
7
|
+
* exactly one answer in this package to "what type is this", "what does this
|
|
8
|
+
* path point at" and "are these two equal".
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The type name a JSON Schema `type` keyword would use. Note that a whole
|
|
12
|
+
* number reports `integer`, not `number` — callers that want the Draft-07
|
|
13
|
+
* rule (an integer also satisfies `number`) must widen it themselves, which
|
|
14
|
+
* `matchesType` below does.
|
|
15
|
+
*/
|
|
16
|
+
export function typeOf(value) {
|
|
17
|
+
if (value === null)
|
|
18
|
+
return "null";
|
|
19
|
+
if (Array.isArray(value))
|
|
20
|
+
return "array";
|
|
21
|
+
switch (typeof value) {
|
|
22
|
+
case "boolean":
|
|
23
|
+
return "boolean";
|
|
24
|
+
case "string":
|
|
25
|
+
return "string";
|
|
26
|
+
case "number":
|
|
27
|
+
return Number.isInteger(value) ? "integer" : "number";
|
|
28
|
+
default:
|
|
29
|
+
return "object";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** True when `actual` satisfies the schema type name `expected`. */
|
|
33
|
+
export function matchesType(actual, expected) {
|
|
34
|
+
if (actual === expected)
|
|
35
|
+
return true;
|
|
36
|
+
// Draft-07: every integer is also a number. The reverse does not hold.
|
|
37
|
+
return actual === "integer" && expected === "number";
|
|
38
|
+
}
|
|
39
|
+
/** True for a value that is an object and neither null nor an array. */
|
|
40
|
+
export function isPlainObject(value) {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True for a value JSON can represent losslessly. `undefined`, functions,
|
|
45
|
+
* symbols, NaN and the infinities are not JSON; neither is any object
|
|
46
|
+
* reachable from the value that contains one. Cycles report false rather
|
|
47
|
+
* than hanging.
|
|
48
|
+
*/
|
|
49
|
+
export function isJsonValue(value, seen = new Set()) {
|
|
50
|
+
if (value === null)
|
|
51
|
+
return true;
|
|
52
|
+
switch (typeof value) {
|
|
53
|
+
case "boolean":
|
|
54
|
+
case "string":
|
|
55
|
+
return true;
|
|
56
|
+
case "number":
|
|
57
|
+
return Number.isFinite(value);
|
|
58
|
+
case "object":
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const obj = value;
|
|
64
|
+
if (seen.has(obj))
|
|
65
|
+
return false;
|
|
66
|
+
seen.add(obj);
|
|
67
|
+
const ok = Array.isArray(value)
|
|
68
|
+
? value.every((v) => isJsonValue(v, seen))
|
|
69
|
+
: Object.values(obj).every((v) => isJsonValue(v, seen));
|
|
70
|
+
seen.delete(obj);
|
|
71
|
+
return ok;
|
|
72
|
+
}
|
|
73
|
+
// --- JSON Pointer (RFC 6901) ------------------------------------------------
|
|
74
|
+
/** Escape one reference token: a tilde becomes `~0`, a slash becomes `~1`. */
|
|
75
|
+
export function escapePointerToken(token) {
|
|
76
|
+
return token.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
77
|
+
}
|
|
78
|
+
/** Reverse of {@link escapePointerToken}; `~1` is undone before `~0`. */
|
|
79
|
+
export function unescapePointerToken(token) {
|
|
80
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
81
|
+
}
|
|
82
|
+
/** Append one token to a pointer, escaping it. `joinPointer("", "a/b")` is `/a~1b`. */
|
|
83
|
+
export function joinPointer(base, token) {
|
|
84
|
+
return `${base}/${escapePointerToken(String(token))}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Split a JSON Pointer into its reference tokens. The empty pointer is the
|
|
88
|
+
* whole document and yields `[]`. A pointer that does not start with `/` is
|
|
89
|
+
* rejected, because silently accepting it would make `a/b` and `/a/b` mean
|
|
90
|
+
* the same thing and hide a caller's mistake.
|
|
91
|
+
*/
|
|
92
|
+
export function parsePointer(pointer) {
|
|
93
|
+
if (pointer === "" || pointer === "#")
|
|
94
|
+
return [];
|
|
95
|
+
const body = pointer.startsWith("#") ? pointer.slice(1) : pointer;
|
|
96
|
+
if (body === "")
|
|
97
|
+
return [];
|
|
98
|
+
if (!body.startsWith("/"))
|
|
99
|
+
throw new Error(`JSON Pointer must start with "/": ${pointer}`);
|
|
100
|
+
return body.slice(1).split("/").map(unescapePointerToken);
|
|
101
|
+
}
|
|
102
|
+
const PATH_SEGMENT = /\[\s*"((?:[^"\\]|\\.)*)"\s*\]|\[\s*'((?:[^'\\]|\\.)*)'\s*\]|\[(\d+)\]|([^.[\]]+)/g;
|
|
103
|
+
/**
|
|
104
|
+
* A dotted path, the form people actually type: `user.address.city`,
|
|
105
|
+
* `items[0].id`, `items.0.id`, and `["odd.key"]` for a key containing a dot.
|
|
106
|
+
* Returns the segments as strings; array indices arrive as their decimal
|
|
107
|
+
* text and are matched against array positions by {@link resolveSegments}.
|
|
108
|
+
*/
|
|
109
|
+
export function parseDottedPath(path) {
|
|
110
|
+
if (path === "" || path === "$")
|
|
111
|
+
return [];
|
|
112
|
+
const out = [];
|
|
113
|
+
let consumed = 0;
|
|
114
|
+
for (const match of path.matchAll(PATH_SEGMENT)) {
|
|
115
|
+
const at = match.index ?? 0;
|
|
116
|
+
if (at > consumed) {
|
|
117
|
+
// Anything between two segments that is not a separator is malformed.
|
|
118
|
+
const gap = path.slice(consumed, at);
|
|
119
|
+
if (gap.replace(/\./g, "") !== "")
|
|
120
|
+
throw new Error(`malformed path near "${gap}" in ${path}`);
|
|
121
|
+
}
|
|
122
|
+
const quoted = match[1] ?? match[2];
|
|
123
|
+
out.push(quoted !== undefined ? quoted.replace(/\\(.)/g, "$1") : (match[3] ?? match[4] ?? ""));
|
|
124
|
+
consumed = at + match[0].length;
|
|
125
|
+
}
|
|
126
|
+
if (out.length === 0)
|
|
127
|
+
throw new Error(`malformed path: ${path}`);
|
|
128
|
+
if (consumed !== path.length && path.slice(consumed).replace(/\./g, "") !== "") {
|
|
129
|
+
throw new Error(`malformed path: ${path}`);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Walk already-split segments through a value. Stops at the first segment
|
|
135
|
+
* that does not resolve and reports it, so a caller can say *where* a path
|
|
136
|
+
* ran out rather than only that it did.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveSegments(root, segments) {
|
|
139
|
+
let current = root;
|
|
140
|
+
for (let i = 0; i < segments.length; i++) {
|
|
141
|
+
const seg = segments[i];
|
|
142
|
+
if (Array.isArray(current)) {
|
|
143
|
+
const index = /^\d+$/.test(seg) ? Number(seg) : Number.NaN;
|
|
144
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
145
|
+
return { found: false, value: undefined, missingAt: segments.slice(0, i + 1).join(".") };
|
|
146
|
+
}
|
|
147
|
+
current = current[index];
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (isPlainObject(current) && Object.hasOwn(current, seg)) {
|
|
151
|
+
current = current[seg];
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
return { found: false, value: undefined, missingAt: segments.slice(0, i + 1).join(".") };
|
|
155
|
+
}
|
|
156
|
+
return { found: true, value: current, missingAt: null };
|
|
157
|
+
}
|
|
158
|
+
/** Resolve a dotted path (`items[0].id`) against a value. */
|
|
159
|
+
export function getPath(root, path) {
|
|
160
|
+
return resolveSegments(root, parseDottedPath(path));
|
|
161
|
+
}
|
|
162
|
+
/** Resolve a JSON Pointer (`/items/0/id`) against a value. */
|
|
163
|
+
export function getPointer(root, pointer) {
|
|
164
|
+
return resolveSegments(root, parsePointer(pointer));
|
|
165
|
+
}
|
|
166
|
+
// --- comparison -------------------------------------------------------------
|
|
167
|
+
const NON_JSON_PREFIX = String.fromCharCode(0);
|
|
168
|
+
/**
|
|
169
|
+
* A stable string for a value: object keys sorted, so two values that differ
|
|
170
|
+
* only in key order produce the same bytes. Used for `uniqueItems`, for
|
|
171
|
+
* duplicate detection and for unordered array matching.
|
|
172
|
+
*
|
|
173
|
+
* Non-JSON values (undefined, NaN, functions) are encoded with a distinct
|
|
174
|
+
* marker rather than being dropped the way `JSON.stringify` drops them,
|
|
175
|
+
* because a dropped key silently changes the meaning of the comparison.
|
|
176
|
+
*/
|
|
177
|
+
export function canonicalize(value) {
|
|
178
|
+
if (value === undefined)
|
|
179
|
+
return `${NON_JSON_PREFIX}undefined`;
|
|
180
|
+
if (value === null)
|
|
181
|
+
return "null";
|
|
182
|
+
if (typeof value === "number") {
|
|
183
|
+
return Number.isFinite(value) ? JSON.stringify(value) : `${NON_JSON_PREFIX}${String(value)}`;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === "boolean" || typeof value === "string")
|
|
186
|
+
return JSON.stringify(value);
|
|
187
|
+
// Not JSON, but distinguishable: falling through to the `typeof` marker
|
|
188
|
+
// below would give every bigint the same key, and a uniqueness check would
|
|
189
|
+
// then call 1n and 2n duplicates.
|
|
190
|
+
if (typeof value === "bigint")
|
|
191
|
+
return `${NON_JSON_PREFIX}bigint:${value.toString()}`;
|
|
192
|
+
if (Array.isArray(value))
|
|
193
|
+
return `[${value.map(canonicalize).join(",")}]`;
|
|
194
|
+
if (isPlainObject(value)) {
|
|
195
|
+
const keys = Object.keys(value).sort();
|
|
196
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(",")}}`;
|
|
197
|
+
}
|
|
198
|
+
return `${NON_JSON_PREFIX}${typeof value}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Structural equality with JSON semantics: same type, same members, key
|
|
202
|
+
* order irrelevant. `NaN` is not equal to itself, following `===`, and a
|
|
203
|
+
* key present with value `undefined` is not equal to a missing key, because
|
|
204
|
+
* the two differ once the value is serialized.
|
|
205
|
+
*/
|
|
206
|
+
export function deepEqual(a, b) {
|
|
207
|
+
if (a === b)
|
|
208
|
+
return true;
|
|
209
|
+
if (typeof a !== typeof b)
|
|
210
|
+
return false;
|
|
211
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
212
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
213
|
+
return false;
|
|
214
|
+
return a.every((item, i) => deepEqual(item, b[i]));
|
|
215
|
+
}
|
|
216
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
217
|
+
const aKeys = Object.keys(a);
|
|
218
|
+
const bKeys = Object.keys(b);
|
|
219
|
+
if (aKeys.length !== bKeys.length)
|
|
220
|
+
return false;
|
|
221
|
+
return aKeys.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
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);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
text = String(value);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
text = text.replace(/\s+/g, " ");
|
|
240
|
+
return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
241
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-schema",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic validation tools: JSON Schema validation and inference, JSON Patch, deep diffing and assertions",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/errors": "0.7.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Max Meier",
|
|
29
|
+
"email": "max@crewhaus.ai",
|
|
30
|
+
"url": "https://crewhaus.ai"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
35
|
+
"directory": "packages/tool-schema"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-schema#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
45
|
+
}
|