@kindgi/schema 0.0.0-bootstrap.0 → 0.1.1

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/src/zod.ts ADDED
@@ -0,0 +1,313 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { createRequire } from 'node:module';
5
+
6
+ import type { Result } from '@kindgi/types';
7
+
8
+ import type { InvalidZodConversionError } from './errors.js';
9
+
10
+ // `createRequire` is Node's ESM-side bridge to CJS-style synchronous
11
+ // resolution. We only use it to resolve the `zod` peer dependency
12
+ // synchronously — the actual JSON conversion function works ESM or CJS.
13
+ const nodeRequire = createRequire(import.meta.url);
14
+
15
+ /**
16
+ * Wire-authoritative JSON Schema shape. Matches the JsonSchema alias used by
17
+ * `@kindgi/tools` and `@kindgi/guardrails` — a Draft 2020-12 object
18
+ * with `type`, `properties`, etc. Kept as `Readonly<Record<string, unknown>>`
19
+ * here to avoid importing any consumer type.
20
+ */
21
+ export type JSONSchemaObject = Readonly<Record<string, unknown>>;
22
+
23
+ /**
24
+ * Structural shape of a Zod v4 schema. We deliberately avoid importing
25
+ * `zod` at type-position here so `@kindgi/schema` keeps zero runtime OR
26
+ * peer dependency on Zod — the utils compile even in a workspace that
27
+ * never installs `zod`. Downstream packages that DO peer-depend on `zod`
28
+ * import `z.ZodType` themselves and pass instances through this alias.
29
+ *
30
+ * Zod v4 schemas carry `_zod` (internals) + `~standard` (Standard Schema
31
+ * spec adapter) at the type-level; both properties are stable across
32
+ * Zod v4's minor releases.
33
+ */
34
+ export interface ZodLikeSchema {
35
+ readonly _zod: unknown;
36
+ readonly '~standard': unknown;
37
+ }
38
+
39
+ /**
40
+ * The alternate authoring input accepted at framework boundaries. Callers
41
+ * pass either a JSON Schema object (Draft 2020-12) or a Zod v4 schema.
42
+ *
43
+ * Non-TS consumers see no change — the framework converts Zod to JSON
44
+ * Schema at author time via `toJSONSchema`, and the wire form is always
45
+ * JSON Schema.
46
+ */
47
+ export type AnySchema = JSONSchemaObject | ZodLikeSchema;
48
+
49
+ /**
50
+ * Structural detection. Returns true iff `value` looks like a Zod v4
51
+ * schema — `_zod` property present AND `~standard` present. Purely
52
+ * structural: never imports from `zod` at runtime, so the check is safe
53
+ * even when zod is not installed at all.
54
+ *
55
+ * The check is deliberately narrow — a JSON Schema object never has a
56
+ * `_zod` property, so the two authoring surfaces cannot collide.
57
+ */
58
+ export function isZodSchema(value: unknown): value is ZodLikeSchema {
59
+ if (value === null || typeof value !== 'object') return false;
60
+ const rec = value as Record<string, unknown>;
61
+ return '_zod' in rec && '~standard' in rec && typeof rec._zod === 'object';
62
+ }
63
+
64
+ /**
65
+ * Structural detection for a JSON Schema object. Cheap heuristic — accepts
66
+ * any object that isn't a Zod schema. JSON Schema validity (Draft 2020-12
67
+ * compilability) is checked separately by each consumer via Ajv.
68
+ */
69
+ export function isJsonSchemaObject(value: unknown): value is JSONSchemaObject {
70
+ if (value === null || typeof value !== 'object') return false;
71
+ if (isZodSchema(value)) return false;
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Author-time detection: how to interpret an `AnySchema` value. Used by
77
+ * downstream packages when they need to route on the authoring surface —
78
+ * for example, to preserve the original Zod schema for TS `z.infer<...>`
79
+ * while caching the converted JSON Schema for wire use.
80
+ */
81
+ export type SchemaKind = 'zod' | 'json-schema';
82
+
83
+ /** Return `'zod'` for Zod schemas, `'json-schema'` for JSON Schema objects. */
84
+ export function schemaKindOf(value: AnySchema): SchemaKind {
85
+ return isZodSchema(value) ? 'zod' : 'json-schema';
86
+ }
87
+
88
+ /**
89
+ * Which side of a schema a JSON Schema describes:
90
+ *
91
+ * - `input` — what a caller may send. A field with a `.default()` is
92
+ * optional (the default fills it). Tool inputs, guardrail config.
93
+ * - `output` — what parsing produces. A defaulted field is always
94
+ * present. Tool outputs, an agent's typed answer.
95
+ *
96
+ * Every conversion names one: they differ exactly where Zod defaults,
97
+ * so the wrong one silently requires fields callers may leave out.
98
+ */
99
+ export type SchemaIo = 'input' | 'output';
100
+
101
+ /**
102
+ * Convert an `AnySchema` to its authoritative JSON Schema wire form.
103
+ *
104
+ * - Zod schema → calls `z.toJSONSchema(schema, { io })` (Zod v4 native —
105
+ * no third-party `zod-to-json-schema` dependency).
106
+ * - JSON Schema object → returned as-is.
107
+ * - Anything else → `err`.
108
+ *
109
+ * Conversion failures (unrepresentable Zod types like `.transform()`,
110
+ * cycles without a registry, function schemas) surface as
111
+ * `InvalidZodConversionError` with the underlying cause preserved. No
112
+ * throws escape.
113
+ *
114
+ * Zod is imported dynamically the first time a Zod schema flows through:
115
+ * a workspace that never uses Zod never pays the import cost, and one
116
+ * that hasn't installed the peer dep gets a clean typed error instead of
117
+ * a module-resolution crash.
118
+ */
119
+ export async function toJSONSchema(
120
+ schema: AnySchema,
121
+ io: SchemaIo,
122
+ ): Promise<Result<JSONSchemaObject, InvalidZodConversionError>> {
123
+ if (!isZodSchema(schema)) {
124
+ return { kind: 'ok', value: schema };
125
+ }
126
+ const converterResult = await loadZodToJSONSchema();
127
+ if (converterResult.kind === 'err') return converterResult;
128
+ try {
129
+ const converted = converterResult.value(schema, { io });
130
+ return { kind: 'ok', value: converted as JSONSchemaObject };
131
+ } catch (cause) {
132
+ return {
133
+ kind: 'err',
134
+ error: {
135
+ code: 'invalid-zod-conversion',
136
+ message: `z.toJSONSchema() failed: ${cause instanceof Error ? cause.message : String(cause)}`,
137
+ cause,
138
+ },
139
+ };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Synchronous variant — for defineTool / defineGuardrail sites that must
145
+ * stay sync. Requires the caller to have already resolved the converter
146
+ * (via `loadZodConverter()`) at package init. When Zod is not installed
147
+ * but a Zod schema is passed, returns `err` with `peer-dep-missing`
148
+ * distinguishable by its `cause`.
149
+ */
150
+ export function toJSONSchemaSync(
151
+ schema: AnySchema,
152
+ converter: ZodConverter | undefined,
153
+ io: SchemaIo,
154
+ ): Result<JSONSchemaObject, InvalidZodConversionError> {
155
+ if (!isZodSchema(schema)) {
156
+ return { kind: 'ok', value: schema };
157
+ }
158
+ if (converter === undefined) {
159
+ return {
160
+ kind: 'err',
161
+ error: {
162
+ code: 'invalid-zod-conversion',
163
+ message:
164
+ 'Zod schema passed but the `zod` peer dependency is not installed. ' +
165
+ 'Install `zod` (>=4.0.0) or author with a JSON Schema object.',
166
+ cause: new Error('zod peer dep not installed'),
167
+ },
168
+ };
169
+ }
170
+ try {
171
+ const converted = converter(schema, { io });
172
+ return { kind: 'ok', value: converted as JSONSchemaObject };
173
+ } catch (cause) {
174
+ return {
175
+ kind: 'err',
176
+ error: {
177
+ code: 'invalid-zod-conversion',
178
+ message: `z.toJSONSchema() failed: ${cause instanceof Error ? cause.message : String(cause)}`,
179
+ cause,
180
+ },
181
+ };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * A resolved handle on `z.toJSONSchema`. Passed to `toJSONSchemaSync`
187
+ * from consumers that eagerly loaded the peer dep once at package init.
188
+ */
189
+ export type ZodConverter = (
190
+ schema: ZodLikeSchema,
191
+ params?: { readonly io?: SchemaIo },
192
+ ) => JSONSchemaObject;
193
+
194
+ /** A problem `parseWithSchema` found, as the schema reported it. */
195
+ export interface SchemaIssue {
196
+ readonly message: string;
197
+ readonly path?: readonly unknown[];
198
+ }
199
+
200
+ /**
201
+ * Parse a value with a Zod schema through its Standard Schema interface
202
+ * (`'~standard'.validate`): the parsed value — defaults filled,
203
+ * transforms and refinements applied — or the issues. What a handler
204
+ * typed with `z.infer` of the schema expects to receive.
205
+ */
206
+ export async function parseWithSchema(
207
+ schema: ZodLikeSchema,
208
+ value: unknown,
209
+ ): Promise<
210
+ | { readonly kind: 'ok'; readonly value: unknown }
211
+ | { readonly kind: 'err'; readonly issues: readonly SchemaIssue[] }
212
+ > {
213
+ const standard = schema['~standard'] as {
214
+ readonly validate: (
215
+ value: unknown,
216
+ ) =>
217
+ | { readonly value?: unknown; readonly issues?: readonly SchemaIssue[] }
218
+ | Promise<{ readonly value?: unknown; readonly issues?: readonly SchemaIssue[] }>;
219
+ };
220
+ const result = await standard.validate(value);
221
+ return result.issues !== undefined
222
+ ? {
223
+ kind: 'err',
224
+ issues: result.issues.map((i) => ({
225
+ message: i.message,
226
+ ...(i.path !== undefined && { path: [...i.path] }),
227
+ })),
228
+ }
229
+ : { kind: 'ok', value: result.value };
230
+ }
231
+
232
+ let cachedConverter: ZodConverter | undefined | null = null;
233
+
234
+ /**
235
+ * Load `z.toJSONSchema` from the `zod` peer dependency. Returns
236
+ * `undefined` when zod is not installed — callers decide whether that's
237
+ * an error or a silent no-op (JSON-Schema-only workspaces are legitimate).
238
+ *
239
+ * The result is cached — repeat calls resolve synchronously after the
240
+ * first.
241
+ */
242
+ export async function loadZodConverter(): Promise<ZodConverter | undefined> {
243
+ if (cachedConverter !== null) return cachedConverter;
244
+ try {
245
+ const zod = (await import('zod')) as { toJSONSchema?: unknown };
246
+ if (typeof zod.toJSONSchema !== 'function') {
247
+ cachedConverter = undefined;
248
+ return undefined;
249
+ }
250
+ cachedConverter = zod.toJSONSchema as ZodConverter;
251
+ return cachedConverter;
252
+ } catch {
253
+ cachedConverter = undefined;
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Synchronous variant of `loadZodConverter`. Uses Node's `createRequire`
260
+ * so sync authoring surfaces (`defineTool`, `defineGuardrail`) can stay
261
+ * sync even on their first Zod-authored call. Falls back cleanly when
262
+ * `zod` isn't installed: returns `undefined` and callers surface the
263
+ * missing peer dep as a typed error at the boundary.
264
+ *
265
+ * Runtime: Node ≥18 (both CJS and ESM entry points to zod resolve
266
+ * through the same `createRequire` handle). No behavior change for
267
+ * workspaces that never author with Zod — the first sync call to
268
+ * `loadZodConverterSync` returns `undefined` cheaply.
269
+ */
270
+ export function loadZodConverterSync(): ZodConverter | undefined {
271
+ if (cachedConverter !== null) return cachedConverter;
272
+ try {
273
+ const zod = nodeRequire('zod') as { toJSONSchema?: unknown };
274
+ if (typeof zod.toJSONSchema !== 'function') {
275
+ cachedConverter = undefined;
276
+ return undefined;
277
+ }
278
+ cachedConverter = zod.toJSONSchema as ZodConverter;
279
+ return cachedConverter;
280
+ } catch {
281
+ cachedConverter = undefined;
282
+ return undefined;
283
+ }
284
+ }
285
+
286
+ async function loadZodToJSONSchema(): Promise<Result<ZodConverter, InvalidZodConversionError>> {
287
+ const converter = await loadZodConverter();
288
+ if (converter === undefined) {
289
+ return {
290
+ kind: 'err',
291
+ error: {
292
+ code: 'invalid-zod-conversion',
293
+ message:
294
+ 'Zod schema passed but the `zod` peer dependency is not installed. ' +
295
+ 'Install `zod` (>=4.0.0) or author with a JSON Schema object.',
296
+ cause: new Error('zod peer dep not installed'),
297
+ },
298
+ };
299
+ }
300
+ return { kind: 'ok', value: converter };
301
+ }
302
+
303
+ /**
304
+ * Test-only: reset the cached converter so tests can exercise both the
305
+ * peer-dep-present and peer-dep-missing branches. Not part of the public
306
+ * API — callers outside this package must not depend on this being
307
+ * present.
308
+ *
309
+ * @internal
310
+ */
311
+ export function __resetZodConverterCacheForTests(): void {
312
+ cachedConverter = null;
313
+ }
@@ -0,0 +1,101 @@
1
+ {
2
+ "$schema": "https://kindgi.com/schemas/v1/type-manifest.schema.json",
3
+ "package": "@kindgi/schema",
4
+ "version": "0.0.0",
5
+ "description": "JSON Schema utilities: registry, validation, versioning. Backed by Ajv (Draft 2020-12).",
6
+ "exports": {
7
+ "SpecRegistry": {
8
+ "kind": "type",
9
+ "form": "interface",
10
+ "methods": {
11
+ "validate": "<T = unknown>(id: string, data: unknown) => Result<T, SchemaError>",
12
+ "getSchema": "(id: string) => Result<unknown, SchemaError>",
13
+ "ids": "() => readonly string[]"
14
+ }
15
+ },
16
+ "createSpecRegistry": {
17
+ "kind": "function",
18
+ "signature": "(schemas: readonly unknown[]) => Result<SpecRegistry, SchemaError>",
19
+ "description": "Build a registry from already-parsed schema documents. Synchronous."
20
+ },
21
+ "loadSpecRegistry": {
22
+ "kind": "function",
23
+ "signature": "(dir: string) => Promise<Result<SpecRegistry, SchemaError>>",
24
+ "description": "Build a registry by reading every *.schema.json in a directory. Async."
25
+ },
26
+ "versionOf": {
27
+ "kind": "function",
28
+ "signature": "(schema: unknown) => Result<SchemaVersion, SchemaError>",
29
+ "description": "Extract { major, semver } from a schema's $id and $comment."
30
+ },
31
+ "SchemaVersion": {
32
+ "kind": "type",
33
+ "form": "interface",
34
+ "fields": {
35
+ "major": "SchemaMajor",
36
+ "semver": "Semver"
37
+ }
38
+ },
39
+ "SchemaError": {
40
+ "kind": "type",
41
+ "form": "discriminated-union",
42
+ "discriminator": "code",
43
+ "members": [
44
+ "SchemaLoadError",
45
+ "SchemaParseError",
46
+ "SchemaCompileError",
47
+ "SchemaNotFoundError",
48
+ "ValidationError"
49
+ ]
50
+ },
51
+ "SchemaLoadError": {
52
+ "kind": "type",
53
+ "form": "interface",
54
+ "fields": {
55
+ "code": "'schema-load-error'",
56
+ "message": "string",
57
+ "path": "string",
58
+ "cause": "unknown"
59
+ }
60
+ },
61
+ "SchemaParseError": {
62
+ "kind": "type",
63
+ "form": "interface",
64
+ "fields": {
65
+ "code": "'schema-parse-error'",
66
+ "message": "string",
67
+ "path": "string",
68
+ "cause": "unknown"
69
+ }
70
+ },
71
+ "SchemaCompileError": {
72
+ "kind": "type",
73
+ "form": "interface",
74
+ "fields": {
75
+ "code": "'schema-compile-error'",
76
+ "message": "string",
77
+ "id": "string",
78
+ "cause": "unknown"
79
+ }
80
+ },
81
+ "SchemaNotFoundError": {
82
+ "kind": "type",
83
+ "form": "interface",
84
+ "fields": { "code": "'schema-not-found'", "message": "string", "id": "string" }
85
+ },
86
+ "ValidationError": {
87
+ "kind": "type",
88
+ "form": "interface",
89
+ "fields": {
90
+ "code": "'validation-error'",
91
+ "message": "string",
92
+ "id": "string",
93
+ "errors": "readonly unknown[]"
94
+ }
95
+ }
96
+ },
97
+ "runtime": {
98
+ "hasCode": true,
99
+ "dependencies": ["ajv", "ajv-formats", "@kindgi/types"]
100
+ }
101
+ }