@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,62 @@
1
+ /** Every comparison {@link runChecks} understands. */
2
+ export declare const ASSERT_OPS: readonly ["equals", "notEquals", "contains", "notContains", "matches", "notMatches", "startsWith", "endsWith", "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual", "exists", "notExists", "isEmpty", "isNotEmpty", "isType", "hasLength", "minLength", "maxLength", "oneOf", "notOneOf", "hasFormat"];
3
+ export type AssertOp = (typeof ASSERT_OPS)[number];
4
+ /** One declarative check. `path` omitted means the value itself. */
5
+ export type Check = {
6
+ path?: string;
7
+ op: AssertOp;
8
+ expected?: unknown;
9
+ /** Regex flags, for `matches` and `notMatches`. */
10
+ flags?: string;
11
+ /** Replaces the generated reason when the check fails. */
12
+ message?: string;
13
+ };
14
+ export type CheckResult = {
15
+ index: number;
16
+ path: string;
17
+ op: AssertOp;
18
+ ok: boolean;
19
+ /** What was found there, length-capped. `null` when the path did not resolve. */
20
+ actual: string | null;
21
+ /** What the check asked for, length-capped. `null` for ops that take no operand. */
22
+ expected: string | null;
23
+ /** Empty when the check passed. */
24
+ reason: string;
25
+ };
26
+ export type AssertReport = {
27
+ ok: boolean;
28
+ passed: number;
29
+ failed: number;
30
+ results: CheckResult[];
31
+ /** Just the failures, for a caller that only wants to print those. */
32
+ failures: CheckResult[];
33
+ };
34
+ /** Empty means: absent, null, the empty string, an empty array or an empty object. */
35
+ export declare function isEmptyValue(value: unknown): boolean;
36
+ /**
37
+ * Run every check against `value` and report which held.
38
+ *
39
+ * Path resolution uses the dotted form (`order.items[0].sku`); an omitted or
40
+ * empty path means the value itself. Every op except `exists` and
41
+ * `notExists` fails when the path does not resolve, with that stated as the
42
+ * reason — a check against a field that is not there has not passed.
43
+ *
44
+ * A malformed check (a bad regex, a non-numeric bound) fails that check with
45
+ * an explanatory reason rather than throwing, so one typo cannot take down a
46
+ * whole gate.
47
+ */
48
+ export declare function runChecks(value: unknown, checks: Check[]): AssertReport;
49
+ /**
50
+ * Which of `paths` are present and non-empty in `value`, and which are not.
51
+ * "Present" means the path resolves; "non-empty" applies
52
+ * {@link isEmptyValue}, so `null`, `""`, `[]` and `{}` count as missing
53
+ * unless `allowEmpty` is set.
54
+ */
55
+ export declare function checkRequired(value: unknown, paths: string[], allowEmpty: boolean): {
56
+ ok: boolean;
57
+ present: string[];
58
+ missing: Array<{
59
+ path: string;
60
+ reason: string;
61
+ }>;
62
+ };
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Declarative assertions: a list of checks, a value, and a verdict.
3
+ *
4
+ * This is the gate a model-free step stands on. The point is that the
5
+ * *decision* — escalate or not, fail the run or not — comes out of data the
6
+ * operator wrote, evaluated the same way every time, with a report that says
7
+ * which check failed and what it saw instead. Nothing here interprets; it
8
+ * only compares.
9
+ */
10
+ import { checkFormat, isFormatName } from "./formats";
11
+ import { deepEqual, getPath, isPlainObject, matchesType, preview, typeOf, } from "./value";
12
+ /** Every comparison {@link runChecks} understands. */
13
+ export const ASSERT_OPS = [
14
+ "equals",
15
+ "notEquals",
16
+ "contains",
17
+ "notContains",
18
+ "matches",
19
+ "notMatches",
20
+ "startsWith",
21
+ "endsWith",
22
+ "greaterThan",
23
+ "greaterThanOrEqual",
24
+ "lessThan",
25
+ "lessThanOrEqual",
26
+ "exists",
27
+ "notExists",
28
+ "isEmpty",
29
+ "isNotEmpty",
30
+ "isType",
31
+ "hasLength",
32
+ "minLength",
33
+ "maxLength",
34
+ "oneOf",
35
+ "notOneOf",
36
+ "hasFormat",
37
+ ];
38
+ /** The ops that are about presence, so an absent path is not itself a failure. */
39
+ const PRESENCE_OPS = new Set(["exists", "notExists"]);
40
+ /** Length in code points for a string, items for an array, keys for an object. */
41
+ function lengthOf(value) {
42
+ if (typeof value === "string")
43
+ return [...value].length;
44
+ if (Array.isArray(value))
45
+ return value.length;
46
+ if (isPlainObject(value))
47
+ return Object.keys(value).length;
48
+ return null;
49
+ }
50
+ /** Empty means: absent, null, the empty string, an empty array or an empty object. */
51
+ export function isEmptyValue(value) {
52
+ if (value === undefined || value === null)
53
+ return true;
54
+ const length = lengthOf(value);
55
+ return length !== null && length === 0;
56
+ }
57
+ function compareNumbers(actual, expected, ok, symbol) {
58
+ if (typeof actual !== "number" || !Number.isFinite(actual)) {
59
+ return { ok: false, reason: `expected a number to compare, found ${typeOf(actual)}` };
60
+ }
61
+ if (typeof expected !== "number" || !Number.isFinite(expected)) {
62
+ return { ok: false, reason: `the check's expected value must be a finite number` };
63
+ }
64
+ return ok(actual, expected)
65
+ ? { ok: true, reason: "" }
66
+ : { ok: false, reason: `${actual} is not ${symbol} ${expected}` };
67
+ }
68
+ function compareLength(actual, expected, ok, describe) {
69
+ const length = lengthOf(actual);
70
+ if (length === null) {
71
+ return { ok: false, reason: `${typeOf(actual)} has no length — use a string, array or object` };
72
+ }
73
+ if (typeof expected !== "number") {
74
+ return { ok: false, reason: "the check's expected value must be a number" };
75
+ }
76
+ return ok(length, expected)
77
+ ? { ok: true, reason: "" }
78
+ : { ok: false, reason: describe(length, expected) };
79
+ }
80
+ function matchRegex(actual, expected, flags, want) {
81
+ if (typeof actual !== "string") {
82
+ return { ok: false, reason: `matches needs a string, found ${typeOf(actual)}` };
83
+ }
84
+ if (typeof expected !== "string") {
85
+ return { ok: false, reason: "the check's expected value must be a regex source string" };
86
+ }
87
+ let re;
88
+ try {
89
+ re = new RegExp(expected, flags);
90
+ }
91
+ catch (err) {
92
+ return { ok: false, reason: `invalid regex /${expected}/${flags}: ${err.message}` };
93
+ }
94
+ const matched = re.test(actual);
95
+ if (matched === want)
96
+ return { ok: true, reason: "" };
97
+ return {
98
+ ok: false,
99
+ reason: want
100
+ ? `${preview(actual, 60)} does not match /${expected}/${flags}`
101
+ : `${preview(actual, 60)} matches /${expected}/${flags} but must not`,
102
+ };
103
+ }
104
+ function evaluate(check, actual, found) {
105
+ const expected = check.expected;
106
+ switch (check.op) {
107
+ case "exists":
108
+ return found ? { ok: true, reason: "" } : { ok: false, reason: "the path does not resolve" };
109
+ case "notExists":
110
+ return found
111
+ ? { ok: false, reason: `the path resolves to ${preview(actual, 60)} but must not exist` }
112
+ : { ok: true, reason: "" };
113
+ case "equals":
114
+ return deepEqual(actual, expected)
115
+ ? { ok: true, reason: "" }
116
+ : { ok: false, reason: `expected ${preview(expected, 60)}, found ${preview(actual, 60)}` };
117
+ case "notEquals":
118
+ return deepEqual(actual, expected)
119
+ ? { ok: false, reason: `found ${preview(actual, 60)}, which the check forbids` }
120
+ : { ok: true, reason: "" };
121
+ case "contains":
122
+ case "notContains": {
123
+ const want = check.op === "contains";
124
+ let has;
125
+ if (typeof actual === "string") {
126
+ if (typeof expected !== "string") {
127
+ return { ok: false, reason: "a string only contains a string" };
128
+ }
129
+ has = actual.includes(expected);
130
+ }
131
+ else if (Array.isArray(actual)) {
132
+ has = actual.some((item) => deepEqual(item, expected));
133
+ }
134
+ else if (isPlainObject(actual)) {
135
+ if (typeof expected !== "string") {
136
+ return {
137
+ ok: false,
138
+ reason: "an object contains a property name, so expected must be a string",
139
+ };
140
+ }
141
+ has = Object.hasOwn(actual, expected);
142
+ }
143
+ else {
144
+ return {
145
+ ok: false,
146
+ reason: `contains needs a string, array or object, found ${typeOf(actual)}`,
147
+ };
148
+ }
149
+ if (has === want)
150
+ return { ok: true, reason: "" };
151
+ return {
152
+ ok: false,
153
+ reason: want
154
+ ? `${preview(actual, 60)} does not contain ${preview(expected, 40)}`
155
+ : `${preview(actual, 60)} contains ${preview(expected, 40)} but must not`,
156
+ };
157
+ }
158
+ case "matches":
159
+ return matchRegex(actual, expected, check.flags ?? "", true);
160
+ case "notMatches":
161
+ return matchRegex(actual, expected, check.flags ?? "", false);
162
+ case "startsWith":
163
+ case "endsWith": {
164
+ if (typeof actual !== "string" || typeof expected !== "string") {
165
+ return { ok: false, reason: `${check.op} needs two strings, found ${typeOf(actual)}` };
166
+ }
167
+ const ok = check.op === "startsWith" ? actual.startsWith(expected) : actual.endsWith(expected);
168
+ return ok
169
+ ? { ok: true, reason: "" }
170
+ : {
171
+ ok: false,
172
+ reason: `${preview(actual, 60)} does not ${check.op} ${preview(expected, 40)}`,
173
+ };
174
+ }
175
+ case "greaterThan":
176
+ return compareNumbers(actual, expected, (a, b) => a > b, ">");
177
+ case "greaterThanOrEqual":
178
+ return compareNumbers(actual, expected, (a, b) => a >= b, ">=");
179
+ case "lessThan":
180
+ return compareNumbers(actual, expected, (a, b) => a < b, "<");
181
+ case "lessThanOrEqual":
182
+ return compareNumbers(actual, expected, (a, b) => a <= b, "<=");
183
+ case "isEmpty":
184
+ return isEmptyValue(actual)
185
+ ? { ok: true, reason: "" }
186
+ : { ok: false, reason: `${preview(actual, 60)} is not empty` };
187
+ case "isNotEmpty":
188
+ return isEmptyValue(actual)
189
+ ? { ok: false, reason: "the value is empty" }
190
+ : { ok: true, reason: "" };
191
+ case "isType": {
192
+ const names = (Array.isArray(expected) ? expected : [expected]).filter((t) => typeof t === "string");
193
+ if (names.length === 0) {
194
+ return {
195
+ ok: false,
196
+ reason: "the check's expected value must be a type name or an array of them",
197
+ };
198
+ }
199
+ const actualType = typeOf(actual);
200
+ return names.some((name) => matchesType(actualType, name))
201
+ ? { ok: true, reason: "" }
202
+ : { ok: false, reason: `expected ${names.join(" or ")}, found ${actualType}` };
203
+ }
204
+ case "hasLength":
205
+ return compareLength(actual, expected, (a, b) => a === b, (a, b) => `length is ${a}, expected ${b}`);
206
+ case "minLength":
207
+ return compareLength(actual, expected, (a, b) => a >= b, (a, b) => `length is ${a}, under the minimum of ${b}`);
208
+ case "maxLength":
209
+ return compareLength(actual, expected, (a, b) => a <= b, (a, b) => `length is ${a}, over the maximum of ${b}`);
210
+ case "oneOf":
211
+ case "notOneOf": {
212
+ if (!Array.isArray(expected)) {
213
+ return {
214
+ ok: false,
215
+ reason: "the check's expected value must be an array of allowed values",
216
+ };
217
+ }
218
+ const member = expected.some((candidate) => deepEqual(candidate, actual));
219
+ const want = check.op === "oneOf";
220
+ if (member === want)
221
+ return { ok: true, reason: "" };
222
+ return {
223
+ ok: false,
224
+ reason: want
225
+ ? `${preview(actual, 60)} is not one of ${preview(expected, 80)}`
226
+ : `${preview(actual, 60)} is in the forbidden set`,
227
+ };
228
+ }
229
+ case "hasFormat": {
230
+ if (typeof actual !== "string") {
231
+ return { ok: false, reason: `hasFormat needs a string, found ${typeOf(actual)}` };
232
+ }
233
+ if (typeof expected !== "string" || !isFormatName(expected)) {
234
+ return {
235
+ ok: false,
236
+ reason: `"${String(expected)}" is not a format this package implements`,
237
+ };
238
+ }
239
+ const result = checkFormat(actual, expected);
240
+ return result.valid
241
+ ? { ok: true, reason: "" }
242
+ : { ok: false, reason: `not a valid ${expected}: ${result.reason}` };
243
+ }
244
+ default:
245
+ return { ok: false, reason: `unknown op "${String(check.op)}"` };
246
+ }
247
+ }
248
+ /**
249
+ * Run every check against `value` and report which held.
250
+ *
251
+ * Path resolution uses the dotted form (`order.items[0].sku`); an omitted or
252
+ * empty path means the value itself. Every op except `exists` and
253
+ * `notExists` fails when the path does not resolve, with that stated as the
254
+ * reason — a check against a field that is not there has not passed.
255
+ *
256
+ * A malformed check (a bad regex, a non-numeric bound) fails that check with
257
+ * an explanatory reason rather than throwing, so one typo cannot take down a
258
+ * whole gate.
259
+ */
260
+ export function runChecks(value, checks) {
261
+ const results = checks.map((check, index) => {
262
+ const path = check.path ?? "";
263
+ let found = true;
264
+ let actual = value;
265
+ if (path !== "") {
266
+ try {
267
+ const resolution = getPath(value, path);
268
+ found = resolution.found;
269
+ actual = resolution.value;
270
+ }
271
+ catch (err) {
272
+ return {
273
+ index,
274
+ path,
275
+ op: check.op,
276
+ ok: false,
277
+ actual: null,
278
+ expected: check.expected === undefined ? null : preview(check.expected, 80),
279
+ reason: check.message ?? `bad path: ${err.message}`,
280
+ };
281
+ }
282
+ }
283
+ if (!found && !PRESENCE_OPS.has(check.op)) {
284
+ return {
285
+ index,
286
+ path,
287
+ op: check.op,
288
+ ok: false,
289
+ actual: null,
290
+ expected: check.expected === undefined ? null : preview(check.expected, 80),
291
+ reason: check.message ?? `the path "${path}" does not resolve`,
292
+ };
293
+ }
294
+ const verdict = evaluate(check, actual, found);
295
+ return {
296
+ index,
297
+ path,
298
+ op: check.op,
299
+ ok: verdict.ok,
300
+ actual: found ? preview(actual, 80) : null,
301
+ expected: check.expected === undefined ? null : preview(check.expected, 80),
302
+ reason: verdict.ok ? "" : (check.message ?? verdict.reason),
303
+ };
304
+ });
305
+ const failures = results.filter((r) => !r.ok);
306
+ return {
307
+ ok: failures.length === 0,
308
+ passed: results.length - failures.length,
309
+ failed: failures.length,
310
+ results,
311
+ failures,
312
+ };
313
+ }
314
+ /**
315
+ * Which of `paths` are present and non-empty in `value`, and which are not.
316
+ * "Present" means the path resolves; "non-empty" applies
317
+ * {@link isEmptyValue}, so `null`, `""`, `[]` and `{}` count as missing
318
+ * unless `allowEmpty` is set.
319
+ */
320
+ export function checkRequired(value, paths, allowEmpty) {
321
+ const present = [];
322
+ const missing = [];
323
+ for (const path of paths) {
324
+ let resolution;
325
+ try {
326
+ resolution = getPath(value, path);
327
+ }
328
+ catch (err) {
329
+ missing.push({ path, reason: `bad path: ${err.message}` });
330
+ continue;
331
+ }
332
+ if (!resolution.found) {
333
+ missing.push({ path, reason: `absent (stops resolving at "${resolution.missingAt}")` });
334
+ continue;
335
+ }
336
+ if (!allowEmpty && isEmptyValue(resolution.value)) {
337
+ missing.push({ path, reason: `present but empty (${preview(resolution.value, 40)})` });
338
+ continue;
339
+ }
340
+ present.push(path);
341
+ }
342
+ return { ok: missing.length === 0, present, missing };
343
+ }
@@ -0,0 +1,67 @@
1
+ export type DifferenceKind = "missing" | "unexpected" | "type" | "value" | "length";
2
+ /** One place where actual and expected part ways. */
3
+ export type Difference = {
4
+ /** JSON Pointer into the compared values. */
5
+ path: string;
6
+ kind: DifferenceKind;
7
+ message: string;
8
+ expected: unknown;
9
+ actual: unknown;
10
+ };
11
+ export type CompareOptions = {
12
+ /**
13
+ * JSON Pointer patterns to skip. A star segment matches any one segment,
14
+ * so "/items" + star + "/id" skips the id of every item; a trailing
15
+ * double-star segment matches everything below that point.
16
+ */
17
+ ignorePaths: string[];
18
+ /** Compare arrays as multisets rather than by position. */
19
+ ignoreArrayOrder: boolean;
20
+ /** Numbers within this absolute distance count as equal. */
21
+ epsilon: number;
22
+ /** Allow object keys in actual that expected does not mention. */
23
+ subset: boolean;
24
+ /** Stop after this many differences. */
25
+ maxDifferences: number;
26
+ };
27
+ export declare const DEFAULT_COMPARE_OPTIONS: CompareOptions;
28
+ /**
29
+ * Unordered array matching is O(n*m) comparisons. Past this length the cost
30
+ * stops being worth it and the comparison falls back to positional, saying
31
+ * so in the result rather than silently changing meaning.
32
+ */
33
+ export declare const UNORDERED_ARRAY_LIMIT = 200;
34
+ export type CompareResult = {
35
+ equal: boolean;
36
+ differences: Difference[];
37
+ truncated: boolean;
38
+ /** Paths that `ignorePaths` actually matched — a pattern that matches nothing is usually a typo. */
39
+ ignored: string[];
40
+ /** Arrays too long to match unordered, compared positionally instead. */
41
+ orderedFallbacks: string[];
42
+ };
43
+ type Pattern = string[];
44
+ /** True when `pointer` is matched by one of the compiled ignore patterns. */
45
+ export declare function matchesAnyPattern(pointer: string, patterns: Pattern[]): boolean;
46
+ /**
47
+ * Compare `actual` against `expected` under the given options and return
48
+ * every difference found, each addressed by JSON Pointer.
49
+ *
50
+ * With `ignoreArrayOrder`, item paths refer to positions in the array they
51
+ * came from — an unmatched expected item is reported at its index in
52
+ * *expected*, an unmatched actual item at its index in *actual* — because
53
+ * with order ignored there is no single index that means both.
54
+ */
55
+ export declare function compareValues(actual: unknown, expected: unknown, options?: Partial<CompareOptions>): CompareResult;
56
+ /**
57
+ * The first place two values differ, walking objects in sorted key order and
58
+ * arrays by index, or `null` when they are structurally equal. This is the
59
+ * one line a failing equality check should print.
60
+ */
61
+ export declare function firstDifference(actual: unknown, expected: unknown): Difference | null;
62
+ /**
63
+ * Group values that canonicalize identically. Used for duplicate detection,
64
+ * where "identical" must ignore key order.
65
+ */
66
+ export declare function groupByCanonical<T>(items: T[], key: (item: T) => unknown): Map<string, number[]>;
67
+ export {};