@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/dist/index.js ADDED
@@ -0,0 +1,678 @@
1
+ /**
2
+ * @crewhaus/tool-schema — deterministic validation, assertion and comparison
3
+ * tools.
4
+ *
5
+ * Every tool here is pure: no filesystem, no network, no clock, no
6
+ * randomness. Same input, same bytes, every time.
7
+ *
8
+ * These are the tools a harness reaches for when it needs a *verdict* rather
9
+ * than an opinion. Does this payload match the contract; did this batch of
10
+ * rows arrive clean; is this response still what it was last week; is the new
11
+ * schema safe to ship. A model asked those questions gives a plausible
12
+ * answer. These give the same answer twice.
13
+ *
14
+ * Each tool is a thin wrapper over a function in `./lib`, which is where the
15
+ * behaviour is tested and where each algorithm's exact supported subset is
16
+ * documented — in particular `./lib/jsonschema`, which states precisely which
17
+ * Draft-07 keywords are enforced and which are not.
18
+ */
19
+ import { buildTool } from "@crewhaus/tool-builder";
20
+ import { z } from "zod";
21
+ import { ASSERT_OPS, checkRequired, runChecks } from "./lib/assert";
22
+ import { compareValues, firstDifference } from "./lib/compare";
23
+ import { FORMAT_NAMES, checkFormat, matchingFormats } from "./lib/formats";
24
+ import { inferSchema } from "./lib/infer";
25
+ import { checkSchemaShape, validateValue } from "./lib/jsonschema";
26
+ import { checkReferences, collectFieldValues, findDuplicates, validateRecords as validateRecordsFn, } from "./lib/records";
27
+ import { diffSchemas } from "./lib/schemadiff";
28
+ import { closestMatch } from "./lib/suggest";
29
+ import { renderFieldTable, summarizeSchema } from "./lib/summarize";
30
+ import { canonicalize, preview } from "./lib/value";
31
+ /** Compact JSON — no indentation, since the reader is a model, not a person. */
32
+ const json = (value) => JSON.stringify(value);
33
+ /**
34
+ * A schema is a keyword object or a boolean, and nothing else. Declaring it
35
+ * this way means a caller who passes a string gets a schema rejection naming
36
+ * the field, not a confusing validation failure.
37
+ */
38
+ const schemaInput = z.union([z.boolean(), z.record(z.unknown())]);
39
+ /** Ceilings that keep one pathological call from filling a context window. */
40
+ const LIMITS = {
41
+ records: 20_000,
42
+ samples: 2_000,
43
+ checks: 500,
44
+ values: 20_000,
45
+ paths: 500,
46
+ };
47
+ /**
48
+ * Zod treats a field declared `z.unknown()` as optional, so a caller who
49
+ * forgets one would silently be comparing against `undefined`. These tools
50
+ * take a value as their subject, so an absent field is a mistake worth
51
+ * naming: this asserts the key is actually there.
52
+ */
53
+ const present = (...keys) => (value) => keys.every((key) => Object.hasOwn(value, key));
54
+ /**
55
+ * A schema with a structural mistake in it (a misspelled type, a `required`
56
+ * that is not an array) would otherwise report every value as invalid, which
57
+ * sends the caller looking in the wrong place. Validation is refused instead.
58
+ */
59
+ function schemaProblemReport(schema) {
60
+ const problems = checkSchemaShape(schema);
61
+ if (problems.length === 0)
62
+ return null;
63
+ return json({
64
+ schemaValid: false,
65
+ problems,
66
+ note: "the schema itself is malformed, so nothing was validated against it",
67
+ });
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ export const jsonSchemaValidate = buildTool({
71
+ name: "JsonSchemaValidate",
72
+ description: "Validate a value against a JSON Schema (a documented Draft-07 subset) and return every failure with a JSON Pointer path and a readable message. Use to check a payload, config or model output against its contract before acting on it, and to get the exact list of what is wrong rather than a yes/no.",
73
+ inputSchema: z
74
+ .object({
75
+ value: z.unknown().describe("the value to validate; may be any JSON value, including null"),
76
+ schema: schemaInput.describe("a JSON Schema; see the package README for the enforced subset"),
77
+ assertFormat: z
78
+ .boolean()
79
+ .optional()
80
+ .describe("enforce `format` instead of treating it as an annotation, as Draft-07 defaults to"),
81
+ maxErrors: z.number().int().positive().max(1000).optional(),
82
+ })
83
+ .refine(present("value"), { message: "value is required, even when it is null" }),
84
+ readOnly: true,
85
+ concurrencySafe: true,
86
+ execute: async (input) => {
87
+ const problems = schemaProblemReport(input.schema);
88
+ if (problems !== null)
89
+ return problems;
90
+ const result = validateValue(input.value, input.schema, {
91
+ assertFormat: input.assertFormat ?? false,
92
+ maxErrors: input.maxErrors ?? 100,
93
+ });
94
+ return json({
95
+ valid: result.valid,
96
+ errorCount: result.errors.length,
97
+ truncated: result.truncated,
98
+ errors: result.errors,
99
+ ...(result.unsupportedKeywords.length > 0
100
+ ? {
101
+ unsupportedKeywords: result.unsupportedKeywords,
102
+ note: "those keywords were present but not enforced — this pass does not cover them",
103
+ }
104
+ : {}),
105
+ });
106
+ },
107
+ });
108
+ export const jsonSchemaInfer = buildTool({
109
+ name: "JsonSchemaInfer",
110
+ description: "Infer a JSON Schema that describes one or more example values, with types, structure and optional formats. Use to bootstrap a contract from a sample API response or a few rows of data, then tighten it by hand — it describes the samples, and infers no numeric ranges or string lengths from them.",
111
+ inputSchema: z.object({
112
+ samples: z
113
+ .array(z.unknown())
114
+ .min(1)
115
+ .max(LIMITS.samples)
116
+ .describe("one or more example values; more samples make the result more accurate"),
117
+ requireAll: z
118
+ .boolean()
119
+ .optional()
120
+ .describe("mark a property required when every sample had it (default true)"),
121
+ detectFormats: z
122
+ .boolean()
123
+ .optional()
124
+ .describe("label strings with a format when all match one"),
125
+ detectEnums: z
126
+ .boolean()
127
+ .optional()
128
+ .describe("turn a small closed set of string values into an enum (default false)"),
129
+ enumThreshold: z.number().int().min(2).max(100).optional(),
130
+ closed: z.boolean().optional().describe("emit additionalProperties:false on every object"),
131
+ maxDepth: z.number().int().min(1).max(20).optional(),
132
+ }),
133
+ readOnly: true,
134
+ concurrencySafe: true,
135
+ execute: async (input) => {
136
+ const schema = inferSchema(input.samples, {
137
+ requireAll: input.requireAll ?? true,
138
+ detectFormats: input.detectFormats ?? true,
139
+ detectEnums: input.detectEnums ?? false,
140
+ enumThreshold: input.enumThreshold ?? 12,
141
+ closed: input.closed ?? false,
142
+ maxDepth: input.maxDepth ?? 12,
143
+ });
144
+ return json(schema);
145
+ },
146
+ });
147
+ export const validateRecords = buildTool({
148
+ name: "ValidateRecords",
149
+ description: "Validate an array of records against one schema and return a pass/fail summary, per-row errors and the failures grouped by what went wrong. Use as a data-quality gate on a batch, feed or export, where the useful answer is how many rows failed and on which field, not a wall of errors.",
150
+ inputSchema: z.object({
151
+ records: z.array(z.unknown()).max(LIMITS.records).describe("the rows to validate"),
152
+ schema: schemaInput,
153
+ assertFormat: z.boolean().optional(),
154
+ idField: z
155
+ .string()
156
+ .optional()
157
+ .describe("a dotted path whose value names each row in the report, e.g. 'id'"),
158
+ maxErrorsPerRow: z.number().int().positive().max(100).optional(),
159
+ maxFailedRows: z.number().int().positive().max(1000).optional(),
160
+ summaryOnly: z.boolean().optional().describe("return the counts and top issues, not the rows"),
161
+ }),
162
+ readOnly: true,
163
+ concurrencySafe: true,
164
+ execute: async (input) => {
165
+ const problems = schemaProblemReport(input.schema);
166
+ if (problems !== null)
167
+ return problems;
168
+ const report = validateRecordsFn(input.records, input.schema, {
169
+ assertFormat: input.assertFormat ?? false,
170
+ maxErrorsPerRow: input.maxErrorsPerRow ?? 10,
171
+ maxFailedRows: input.maxFailedRows ?? 50,
172
+ idField: input.idField ?? null,
173
+ });
174
+ const head = {
175
+ ok: report.ok,
176
+ total: report.total,
177
+ passed: report.passed,
178
+ failed: report.failed,
179
+ topIssues: report.topIssues.slice(0, 20),
180
+ ...(report.unsupportedKeywords.length > 0
181
+ ? { unsupportedKeywords: report.unsupportedKeywords }
182
+ : {}),
183
+ };
184
+ if (input.summaryOnly === true)
185
+ return json(head);
186
+ return json({ ...head, truncated: report.truncated, failures: report.failures });
187
+ },
188
+ });
189
+ export const assert = buildTool({
190
+ name: "Assert",
191
+ description: `Evaluate a list of declarative checks (${ASSERT_OPS.slice(0, 8).join(", ")} and more) against a value and report which passed, which failed, and what was found instead. Use as the gate that decides whether a step can proceed or must escalate — the condition lives in data the operator wrote, so the same input always reaches the same verdict.`,
192
+ inputSchema: z
193
+ .object({
194
+ value: z.unknown().describe("the value the checks run against"),
195
+ checks: z
196
+ .array(z.object({
197
+ path: z
198
+ .string()
199
+ .optional()
200
+ .describe("dotted path into the value, e.g. 'order.items[0].sku'; omit for the value itself"),
201
+ op: z.enum(ASSERT_OPS),
202
+ expected: z.unknown().describe("the operand; its meaning depends on the op"),
203
+ flags: z.string().optional().describe("regex flags for matches/notMatches"),
204
+ message: z
205
+ .string()
206
+ .optional()
207
+ .describe("replaces the generated reason when this check fails"),
208
+ }))
209
+ .min(1)
210
+ .max(LIMITS.checks),
211
+ failuresOnly: z.boolean().optional().describe("return only the checks that failed"),
212
+ })
213
+ .refine(present("value"), { message: "value is required, even when it is null" }),
214
+ readOnly: true,
215
+ concurrencySafe: true,
216
+ execute: async (input) => {
217
+ const report = runChecks(input.value, input.checks);
218
+ if (input.failuresOnly === true) {
219
+ return json({
220
+ ok: report.ok,
221
+ passed: report.passed,
222
+ failed: report.failed,
223
+ failures: report.failures,
224
+ });
225
+ }
226
+ return json({
227
+ ok: report.ok,
228
+ passed: report.passed,
229
+ failed: report.failed,
230
+ results: report.results,
231
+ });
232
+ },
233
+ });
234
+ export const compareGolden = buildTool({
235
+ name: "CompareGolden",
236
+ description: "Compare a value against an expected value, optionally ignoring given paths, ignoring array order and tolerating a numeric epsilon, and report every difference by JSON Pointer. Use as the snapshot check on a response or artifact, where ids, timestamps and result order legitimately move but nothing else may.",
237
+ inputSchema: z
238
+ .object({
239
+ actual: z.unknown(),
240
+ expected: z.unknown(),
241
+ ignorePaths: z
242
+ .array(z.string())
243
+ .max(LIMITS.paths)
244
+ .optional()
245
+ .describe("JSON Pointer patterns to skip; a '*' segment matches any one segment and a trailing '**' matches everything below"),
246
+ ignoreArrayOrder: z.boolean().optional().describe("compare arrays as multisets"),
247
+ epsilon: z.number().min(0).optional().describe("numbers within this distance count as equal"),
248
+ maxDifferences: z.number().int().positive().max(1000).optional(),
249
+ })
250
+ .refine(present("actual", "expected"), { message: "both actual and expected are required" }),
251
+ readOnly: true,
252
+ concurrencySafe: true,
253
+ execute: async (input) => {
254
+ const result = compareValues(input.actual, input.expected, {
255
+ ignorePaths: input.ignorePaths ?? [],
256
+ ignoreArrayOrder: input.ignoreArrayOrder ?? false,
257
+ epsilon: input.epsilon ?? 0,
258
+ maxDifferences: input.maxDifferences ?? 50,
259
+ });
260
+ return json({
261
+ equal: result.equal,
262
+ differenceCount: result.differences.length,
263
+ truncated: result.truncated,
264
+ differences: result.differences.map((d) => ({
265
+ path: d.path,
266
+ kind: d.kind,
267
+ message: d.message,
268
+ expected: preview(d.expected, 80),
269
+ actual: preview(d.actual, 80),
270
+ })),
271
+ ...(result.ignored.length > 0 ? { ignored: result.ignored } : {}),
272
+ ...(result.orderedFallbacks.length > 0
273
+ ? {
274
+ orderedFallbacks: result.orderedFallbacks,
275
+ note: "those arrays were too long to match unordered and were compared by position",
276
+ }
277
+ : {}),
278
+ });
279
+ },
280
+ });
281
+ export const deepEqual = buildTool({
282
+ name: "DeepEqual",
283
+ description: "Test two values for strict structural equality and, when they differ, report the first difference with its JSON Pointer path. Use for an exact equality gate where any drift matters — and read the one-line path instead of eyeballing two blobs.",
284
+ inputSchema: z
285
+ .object({
286
+ a: z.unknown(),
287
+ b: z.unknown(),
288
+ })
289
+ .refine(present("a", "b"), { message: "both a and b are required" }),
290
+ readOnly: true,
291
+ concurrencySafe: true,
292
+ execute: async (input) => {
293
+ const difference = firstDifference(input.a, input.b);
294
+ if (difference === null)
295
+ return json({ equal: true });
296
+ return json({
297
+ equal: false,
298
+ difference: {
299
+ path: difference.path,
300
+ kind: difference.kind,
301
+ message: difference.message,
302
+ expected: preview(difference.expected, 80),
303
+ actual: preview(difference.actual, 80),
304
+ },
305
+ });
306
+ },
307
+ });
308
+ export const matchSubset = buildTool({
309
+ name: "MatchSubset",
310
+ description: "Check that a value contains everything an expected partial value specifies, allowing any extra properties, and report what is missing or wrong. Use to assert the parts of an API response you care about without pinning the fields you do not.",
311
+ inputSchema: z
312
+ .object({
313
+ actual: z.unknown(),
314
+ expected: z.unknown().describe("the partial value that must be present inside actual"),
315
+ ignorePaths: z.array(z.string()).max(LIMITS.paths).optional(),
316
+ ignoreArrayOrder: z.boolean().optional(),
317
+ epsilon: z.number().min(0).optional(),
318
+ maxDifferences: z.number().int().positive().max(1000).optional(),
319
+ })
320
+ .refine(present("actual", "expected"), { message: "both actual and expected are required" }),
321
+ readOnly: true,
322
+ concurrencySafe: true,
323
+ execute: async (input) => {
324
+ const result = compareValues(input.actual, input.expected, {
325
+ subset: true,
326
+ ignorePaths: input.ignorePaths ?? [],
327
+ ignoreArrayOrder: input.ignoreArrayOrder ?? false,
328
+ epsilon: input.epsilon ?? 0,
329
+ maxDifferences: input.maxDifferences ?? 50,
330
+ });
331
+ return json({
332
+ matches: result.equal,
333
+ differenceCount: result.differences.length,
334
+ truncated: result.truncated,
335
+ differences: result.differences.map((d) => ({
336
+ path: d.path,
337
+ kind: d.kind,
338
+ message: d.message,
339
+ expected: preview(d.expected, 80),
340
+ actual: preview(d.actual, 80),
341
+ })),
342
+ note: "arrays are still compared in full — only object properties may be extra",
343
+ });
344
+ },
345
+ });
346
+ export const checkRequiredFields = buildTool({
347
+ name: "CheckRequiredFields",
348
+ description: "Verify that a set of dotted paths are present and non-empty in a value, or in every row of an array, and report which are missing and why. Use as the cheap first gate before work that needs those fields, so a null slips out here rather than three steps later.",
349
+ inputSchema: z
350
+ .object({
351
+ value: z.unknown().describe("an object, or an array of objects with eachRecord set"),
352
+ paths: z
353
+ .array(z.string().min(1))
354
+ .min(1)
355
+ .max(LIMITS.paths)
356
+ .describe("dotted paths, e.g. ['id', 'customer.email', 'items[0].sku']"),
357
+ allowEmpty: z
358
+ .boolean()
359
+ .optional()
360
+ .describe("count null, '', [] and {} as present (default false, so they count as missing)"),
361
+ eachRecord: z
362
+ .boolean()
363
+ .optional()
364
+ .describe("treat value as an array of rows and check each one"),
365
+ maxFailedRows: z.number().int().positive().max(1000).optional(),
366
+ })
367
+ .refine(present("value"), { message: "value is required" }),
368
+ readOnly: true,
369
+ concurrencySafe: true,
370
+ execute: async (input) => {
371
+ const allowEmpty = input.allowEmpty ?? false;
372
+ if (input.eachRecord === true) {
373
+ if (!Array.isArray(input.value)) {
374
+ return "eachRecord was set but value is not an array — pass an array of rows, or drop eachRecord";
375
+ }
376
+ if (input.value.length > LIMITS.records) {
377
+ return `${input.value.length} rows, over the ${LIMITS.records} limit — split the batch`;
378
+ }
379
+ const maxRows = input.maxFailedRows ?? 50;
380
+ const failures = [];
381
+ let failed = 0;
382
+ input.value.forEach((row, index) => {
383
+ const result = checkRequired(row, input.paths, allowEmpty);
384
+ if (result.ok)
385
+ return;
386
+ failed += 1;
387
+ if (failures.length < maxRows)
388
+ failures.push({ row: index, missing: result.missing });
389
+ });
390
+ return json({
391
+ ok: failed === 0,
392
+ total: input.value.length,
393
+ passed: input.value.length - failed,
394
+ failed,
395
+ truncated: failed > failures.length,
396
+ failures,
397
+ });
398
+ }
399
+ const result = checkRequired(input.value, input.paths, allowEmpty);
400
+ return json({
401
+ ok: result.ok,
402
+ presentCount: result.present.length,
403
+ missingCount: result.missing.length,
404
+ present: result.present,
405
+ missing: result.missing,
406
+ });
407
+ },
408
+ });
409
+ export const validateEnum = buildTool({
410
+ name: "ValidateEnum",
411
+ description: "Check values against an allowed set and report every one that is not a member, with the closest allowed value as a suggestion. Use to gate a status, category or code field, and to catch the near-miss typo that a plain membership test only tells you is wrong.",
412
+ inputSchema: z.object({
413
+ values: z
414
+ .array(z.unknown())
415
+ .min(1)
416
+ .max(LIMITS.values)
417
+ .describe("the values to check; may be strings, numbers, or any JSON value"),
418
+ allowed: z.array(z.unknown()).min(1).max(LIMITS.values).describe("the permitted values"),
419
+ caseInsensitive: z
420
+ .boolean()
421
+ .optional()
422
+ .describe("compare strings ignoring case (non-strings are unaffected)"),
423
+ maxReported: z.number().int().positive().max(1000).optional(),
424
+ }),
425
+ readOnly: true,
426
+ concurrencySafe: true,
427
+ execute: async (input) => {
428
+ const fold = (value) => input.caseInsensitive === true && typeof value === "string" ? value.toLowerCase() : value;
429
+ const allowedKeys = new Set(input.allowed.map((v) => canonicalize(fold(v))));
430
+ const allowedStrings = input.allowed.filter((v) => typeof v === "string");
431
+ const maxReported = input.maxReported ?? 100;
432
+ const invalid = [];
433
+ let invalidCount = 0;
434
+ input.values.forEach((value, index) => {
435
+ if (allowedKeys.has(canonicalize(fold(value))))
436
+ return;
437
+ invalidCount += 1;
438
+ if (invalid.length >= maxReported)
439
+ return;
440
+ const suggestion = typeof value === "string" ? closestMatch(value, allowedStrings) : null;
441
+ invalid.push({
442
+ index,
443
+ value: preview(value, 60),
444
+ ...(suggestion !== null ? { suggestion } : {}),
445
+ });
446
+ });
447
+ return json({
448
+ ok: invalidCount === 0,
449
+ total: input.values.length,
450
+ valid: input.values.length - invalidCount,
451
+ invalidCount,
452
+ truncated: invalidCount > invalid.length,
453
+ invalid,
454
+ });
455
+ },
456
+ });
457
+ export const validateFormat = buildTool({
458
+ name: "ValidateFormat",
459
+ description: `Check strings against a format implemented in this package (${FORMAT_NAMES.join(", ")}) and report each failure with the reason it failed. Use to validate emails, URLs, addresses, ids and timestamps without a regex you have to trust — each format's exact accepted subset is documented in the README.`,
460
+ inputSchema: z.object({
461
+ values: z.array(z.string()).min(1).max(LIMITS.values).describe("the strings to check"),
462
+ format: z.enum(FORMAT_NAMES),
463
+ maxReported: z.number().int().positive().max(1000).optional(),
464
+ suggestFormats: z
465
+ .boolean()
466
+ .optional()
467
+ .describe("for each failure, also report which formats the value does match"),
468
+ }),
469
+ readOnly: true,
470
+ concurrencySafe: true,
471
+ execute: async (input) => {
472
+ const maxReported = input.maxReported ?? 100;
473
+ const invalid = [];
474
+ let invalidCount = 0;
475
+ input.values.forEach((value, index) => {
476
+ const result = checkFormat(value, input.format);
477
+ if (result.valid)
478
+ return;
479
+ invalidCount += 1;
480
+ if (invalid.length >= maxReported)
481
+ return;
482
+ const matches = input.suggestFormats === true ? matchingFormats(value) : undefined;
483
+ invalid.push({
484
+ index,
485
+ value: preview(value, 80),
486
+ reason: result.reason ?? "invalid",
487
+ ...(matches !== undefined && matches.length > 0 ? { matches } : {}),
488
+ });
489
+ });
490
+ return json({
491
+ ok: invalidCount === 0,
492
+ format: input.format,
493
+ total: input.values.length,
494
+ valid: input.values.length - invalidCount,
495
+ invalidCount,
496
+ truncated: invalidCount > invalid.length,
497
+ invalid,
498
+ });
499
+ },
500
+ });
501
+ export const validateUniqueKeys = buildTool({
502
+ name: "ValidateUniqueKeys",
503
+ description: "Find rows that share a key, where the key may be several fields together, and report each colliding group with its row indices. Use to enforce a uniqueness constraint before an import or an upsert, when a duplicate would otherwise overwrite good data.",
504
+ inputSchema: z.object({
505
+ records: z.array(z.unknown()).max(LIMITS.records),
506
+ keyFields: z
507
+ .array(z.string().min(1))
508
+ .min(1)
509
+ .max(20)
510
+ .describe("dotted paths making up the key; several fields form a composite key"),
511
+ caseInsensitive: z.boolean().optional().describe("lowercase string values before comparing"),
512
+ maxGroups: z.number().int().positive().max(1000).optional(),
513
+ }),
514
+ readOnly: true,
515
+ concurrencySafe: true,
516
+ execute: async (input) => {
517
+ const report = findDuplicates(input.records, input.keyFields, {
518
+ caseInsensitive: input.caseInsensitive ?? false,
519
+ maxGroups: input.maxGroups ?? 50,
520
+ });
521
+ return json({
522
+ ok: report.ok,
523
+ total: report.total,
524
+ distinctKeys: report.distinctKeys,
525
+ duplicateGroups: report.duplicates.length,
526
+ truncated: report.truncated,
527
+ duplicates: report.duplicates,
528
+ ...(report.unkeyed.length > 0 ? { unkeyed: report.unkeyed.slice(0, 50) } : {}),
529
+ });
530
+ },
531
+ });
532
+ export const validateReferences = buildTool({
533
+ name: "ValidateReferences",
534
+ description: "Check that every row's reference field points at a value that exists, either in a list you give or in a field of a parent array, and report the dangling ones. Use as the referential-integrity gate on joined data before writing it anywhere.",
535
+ inputSchema: z
536
+ .object({
537
+ records: z.array(z.unknown()).max(LIMITS.records).describe("the rows carrying the reference"),
538
+ field: z.string().min(1).describe("dotted path to the reference, e.g. 'customerId'"),
539
+ allowed: z
540
+ .array(z.unknown())
541
+ .max(LIMITS.records)
542
+ .optional()
543
+ .describe("the permitted values; give this or parentRecords"),
544
+ parentRecords: z
545
+ .array(z.unknown())
546
+ .max(LIMITS.records)
547
+ .optional()
548
+ .describe("the parent rows to draw allowed values from"),
549
+ parentField: z.string().min(1).optional().describe("dotted path to the key in parentRecords"),
550
+ allowMissing: z
551
+ .boolean()
552
+ .optional()
553
+ .describe("treat an absent or null reference as acceptable"),
554
+ reportUnreferenced: z
555
+ .boolean()
556
+ .optional()
557
+ .describe("also list allowed values that no row referred to"),
558
+ maxDangling: z.number().int().positive().max(1000).optional(),
559
+ })
560
+ .refine((v) => v.allowed !== undefined || (v.parentRecords !== undefined && v.parentField !== undefined), {
561
+ message: "give allowed, or both parentRecords and parentField",
562
+ }),
563
+ readOnly: true,
564
+ concurrencySafe: true,
565
+ execute: async (input) => {
566
+ const allowed = input.allowed ?? collectFieldValues(input.parentRecords ?? [], input.parentField ?? "");
567
+ const report = checkReferences(input.records, input.field, allowed, {
568
+ allowMissing: input.allowMissing ?? false,
569
+ reportUnreferenced: input.reportUnreferenced ?? false,
570
+ maxDangling: input.maxDangling ?? 50,
571
+ });
572
+ return json({
573
+ ok: report.ok,
574
+ total: report.total,
575
+ checked: report.checked,
576
+ allowedCount: allowed.length,
577
+ danglingCount: report.dangling.length,
578
+ truncated: report.truncated,
579
+ dangling: report.dangling,
580
+ ...(report.unreferenced.length > 0
581
+ ? {
582
+ unreferencedCount: report.unreferenced.length,
583
+ unreferenced: report.unreferenced.slice(0, 50),
584
+ }
585
+ : {}),
586
+ });
587
+ },
588
+ });
589
+ export const schemaDiff = buildTool({
590
+ name: "SchemaDiff",
591
+ description: "Diff two JSON Schemas and classify every change as compatible, breaking or undecidable for a reader of the data. Use before shipping a contract change, to answer whether existing payloads still validate — an undecidable change is reported as such, never rounded to safe.",
592
+ inputSchema: z.object({
593
+ before: schemaInput.describe("the schema in production"),
594
+ after: schemaInput.describe("the proposed schema"),
595
+ breakingOnly: z
596
+ .boolean()
597
+ .optional()
598
+ .describe("return only the breaking and undecidable changes"),
599
+ }),
600
+ readOnly: true,
601
+ concurrencySafe: true,
602
+ execute: async (input) => {
603
+ const result = diffSchemas(input.before, input.after);
604
+ const changes = input.breakingOnly === true
605
+ ? result.changes.filter((c) => c.compat !== "compatible")
606
+ : result.changes;
607
+ return json({
608
+ identical: result.identical,
609
+ verdict: result.verdict,
610
+ backwardCompatible: result.backwardCompatible,
611
+ counts: result.counts,
612
+ changes,
613
+ note: "backward compatible means every value valid under `before` is still valid under `after`",
614
+ });
615
+ },
616
+ });
617
+ export const schemaSummarize = buildTool({
618
+ name: "SchemaSummarize",
619
+ description: "Flatten a JSON Schema into one row per field — path, type, required, constraints — as a markdown table or as JSON. Use to put a large schema in front of a person or a model compactly, or to generate the field table in a document from the schema itself.",
620
+ inputSchema: z.object({
621
+ schema: schemaInput,
622
+ format: z.enum(["table", "json"]).optional().describe("markdown table (default) or JSON rows"),
623
+ maxRows: z.number().int().positive().max(2000).optional(),
624
+ maxDepth: z.number().int().min(1).max(20).optional(),
625
+ requiredOnly: z.boolean().optional().describe("list only the required fields"),
626
+ }),
627
+ readOnly: true,
628
+ concurrencySafe: true,
629
+ execute: async (input) => {
630
+ const result = summarizeSchema(input.schema, {
631
+ maxRows: input.maxRows ?? 300,
632
+ maxDepth: input.maxDepth ?? 8,
633
+ });
634
+ const rows = input.requiredOnly === true ? result.rows.filter((r) => r.required) : result.rows;
635
+ if (input.format === "json") {
636
+ return json({
637
+ title: result.title,
638
+ rootType: result.rootType,
639
+ fieldCount: rows.length,
640
+ truncated: result.truncated,
641
+ fields: rows,
642
+ });
643
+ }
644
+ if (rows.length === 0) {
645
+ return `${result.title === "" ? "schema" : result.title}: ${result.rootType} with no described fields`;
646
+ }
647
+ const header = `${result.title === "" ? "Schema" : result.title} (${result.rootType}, ${rows.length} fields)`;
648
+ const footer = result.truncated ? "\n\n(truncated — raise maxRows to see the rest)" : "";
649
+ return `${header}\n\n${renderFieldTable(rows)}${footer}`;
650
+ },
651
+ });
652
+ /** Every tool this package registers, in the order a catalog should list them. */
653
+ export const SCHEMA_TOOLS = Object.freeze([
654
+ assert,
655
+ checkRequiredFields,
656
+ compareGolden,
657
+ deepEqual,
658
+ jsonSchemaInfer,
659
+ jsonSchemaValidate,
660
+ matchSubset,
661
+ schemaDiff,
662
+ schemaSummarize,
663
+ validateEnum,
664
+ validateFormat,
665
+ validateRecords,
666
+ validateReferences,
667
+ validateUniqueKeys,
668
+ ]);
669
+ /**
670
+ * The declarative check grammar, re-exported for other tool packages.
671
+ *
672
+ * `Assert` here and `Branch` in `@crewhaus/tool-flow` both have to answer
673
+ * "does this value satisfy this condition?", and two implementations of that
674
+ * question would drift: an operator who learned `startsWith` from one would
675
+ * find it missing, or subtly different, in the other. There is one
676
+ * vocabulary and one evaluator, and this is where it lives.
677
+ */
678
+ export { ASSERT_OPS, checkRequired, runChecks, } from "./lib/assert";