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