@kindgi/guardrails 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.
Files changed (55) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +78 -1
  3. package/dist/action-handler.d.ts +82 -0
  4. package/dist/action-handler.d.ts.map +1 -0
  5. package/dist/action-handler.js +120 -0
  6. package/dist/action-handler.js.map +1 -0
  7. package/dist/checks.d.ts +9 -0
  8. package/dist/checks.d.ts.map +1 -0
  9. package/dist/checks.js +235 -0
  10. package/dist/checks.js.map +1 -0
  11. package/dist/define-check.d.ts +93 -0
  12. package/dist/define-check.d.ts.map +1 -0
  13. package/dist/define-check.js +110 -0
  14. package/dist/define-check.js.map +1 -0
  15. package/dist/define.d.ts +27 -0
  16. package/dist/define.d.ts.map +1 -0
  17. package/dist/define.js +126 -0
  18. package/dist/define.js.map +1 -0
  19. package/dist/engine.d.ts +49 -0
  20. package/dist/engine.d.ts.map +1 -0
  21. package/dist/engine.js +198 -0
  22. package/dist/engine.js.map +1 -0
  23. package/dist/errors.d.ts +91 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +4 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/execution-strategy.d.ts +80 -0
  28. package/dist/execution-strategy.d.ts.map +1 -0
  29. package/dist/execution-strategy.js +96 -0
  30. package/dist/execution-strategy.js.map +1 -0
  31. package/dist/guardrail.schema.json +261 -0
  32. package/dist/index.d.ts +16 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/judge.d.ts +31 -0
  37. package/dist/judge.d.ts.map +1 -0
  38. package/dist/judge.js +171 -0
  39. package/dist/judge.js.map +1 -0
  40. package/dist/types.d.ts +407 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +11 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +64 -4
  45. package/src/action-handler.ts +179 -0
  46. package/src/checks.ts +236 -0
  47. package/src/define-check.ts +207 -0
  48. package/src/define.ts +146 -0
  49. package/src/engine.ts +271 -0
  50. package/src/errors.ts +107 -0
  51. package/src/execution-strategy.ts +184 -0
  52. package/src/guardrail.schema.json +261 -0
  53. package/src/index.ts +79 -0
  54. package/src/judge.ts +221 -0
  55. package/src/types.ts +455 -0
@@ -0,0 +1,179 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { EvaluationResult, Guardrail, RunTrace } from './types.js';
5
+
6
+ /**
7
+ * A handler that knows how to APPLY one action (`halt`, `retry`,
8
+ * `escalate`, `log-only`, `compensate`, or custom). Registered once at
9
+ * boot. When `EvaluationBindings.actions` is set, the engine calls the
10
+ * handler for a violation's action after evaluating the guardrail;
11
+ * without it, the engine only reports the action and the caller applies
12
+ * it.
13
+ *
14
+ * Handlers don't mutate the trace; they emit side effects (schedule
15
+ * retry, enqueue HITL review, run compensating tool). The engine
16
+ * computes `action` from the guardrail's declaration and hands off to
17
+ * the registered handler.
18
+ *
19
+ * Adapter packages register their own action handlers (e.g. escalate →
20
+ * a HITL queue) without touching the engine.
21
+ */
22
+ export interface ActionHandler {
23
+ /** Discriminant matching `guardrail.action['on-violation']`. */
24
+ readonly action: string;
25
+ /** Human-friendly name for diagnostics. */
26
+ readonly name?: string;
27
+ /**
28
+ * Apply the action. Called after the engine identifies a violation.
29
+ * An `err` result is attached to the evaluation as
30
+ * `result.attributes.actionHandlerError`; it doesn't roll back the
31
+ * evaluation (violation still recorded, compliance evidence still
32
+ * emitted).
33
+ */
34
+ apply(context: ActionContext): Promise<ActionResult>;
35
+ }
36
+
37
+ export interface ActionContext {
38
+ readonly guardrail: Guardrail;
39
+ readonly evaluation: EvaluationResult;
40
+ readonly trace: RunTrace;
41
+ }
42
+
43
+ export type ActionResult =
44
+ | { readonly kind: 'ok' }
45
+ | { readonly kind: 'err'; readonly error: { readonly code: string; readonly message: string } };
46
+
47
+ /** Registry the engine consults when a violation surfaces. */
48
+ export interface ActionHandlerRegistry {
49
+ register(handler: ActionHandler): void;
50
+ get(action: string): ActionHandler | undefined;
51
+ list(): readonly ActionHandler[];
52
+ }
53
+
54
+ export function createActionHandlerRegistry(
55
+ seed: readonly ActionHandler[] = [],
56
+ ): ActionHandlerRegistry {
57
+ const handlers = new Map<string, ActionHandler>();
58
+ for (const h of seed) handlers.set(h.action, h);
59
+ return {
60
+ register(handler): void {
61
+ handlers.set(handler.action, handler);
62
+ },
63
+ get(action): ActionHandler | undefined {
64
+ return handlers.get(action);
65
+ },
66
+ list(): readonly ActionHandler[] {
67
+ return [...handlers.values()];
68
+ },
69
+ };
70
+ }
71
+
72
+ // ============ built-in action handlers (record-only defaults) ============
73
+
74
+ /**
75
+ * `halt` — the caller (e.g. the agent runtime) is expected to stop the
76
+ * run. This handler is a no-op record; the enforcement happens in the
77
+ * caller, which sees the `halt` action in the EvaluationResult.
78
+ */
79
+ export const haltHandler: ActionHandler = {
80
+ action: 'halt',
81
+ name: 'Halt run',
82
+ async apply(): Promise<ActionResult> {
83
+ return { kind: 'ok' };
84
+ },
85
+ };
86
+
87
+ /** `log-only` — record + carry on. */
88
+ export const logOnlyHandler: ActionHandler = {
89
+ action: 'log-only',
90
+ name: 'Log only',
91
+ async apply(): Promise<ActionResult> {
92
+ return { kind: 'ok' };
93
+ },
94
+ };
95
+
96
+ /** `noop` — action synthesized when the check passes; nothing to do. */
97
+ export const noopHandler: ActionHandler = {
98
+ action: 'noop',
99
+ name: 'No-op (check passed)',
100
+ async apply(): Promise<ActionResult> {
101
+ return { kind: 'ok' };
102
+ },
103
+ };
104
+
105
+ /**
106
+ * `retry` — records the retry intent. Actual retry scheduling happens
107
+ * in the caller. Handler validates the guardrail's action.retry policy
108
+ * is well-formed.
109
+ */
110
+ export const retryHandler: ActionHandler = {
111
+ action: 'retry',
112
+ name: 'Retry (caller-scheduled)',
113
+ async apply(ctx): Promise<ActionResult> {
114
+ const retry = ctx.guardrail.action.retry;
115
+ if (retry === undefined || retry.maxAttempts <= 0) {
116
+ return {
117
+ kind: 'err',
118
+ error: {
119
+ code: 'invalid-action-config',
120
+ message: `retry action on guardrail "${ctx.guardrail.id}" needs action.retry.maxAttempts > 0`,
121
+ },
122
+ };
123
+ }
124
+ return { kind: 'ok' };
125
+ },
126
+ };
127
+
128
+ /**
129
+ * `escalate` — records the escalate intent. Actual routing (e.g. to a
130
+ * HITL review queue) happens in the caller or an adapter handler.
131
+ * Handler validates `guardrail.action.escalateTo` is set.
132
+ */
133
+ export const escalateHandler: ActionHandler = {
134
+ action: 'escalate',
135
+ name: 'Escalate (caller-routed)',
136
+ async apply(ctx): Promise<ActionResult> {
137
+ if (ctx.guardrail.action.escalateTo === undefined) {
138
+ return {
139
+ kind: 'err',
140
+ error: {
141
+ code: 'invalid-action-config',
142
+ message: `escalate action on guardrail "${ctx.guardrail.id}" needs action.escalateTo`,
143
+ },
144
+ };
145
+ }
146
+ return { kind: 'ok' };
147
+ },
148
+ };
149
+
150
+ /**
151
+ * `compensate` — records the compensation intent. The caller invokes
152
+ * the compensating tool via `guardrail.action.compensateWith`.
153
+ */
154
+ export const compensateHandler: ActionHandler = {
155
+ action: 'compensate',
156
+ name: 'Compensate (caller-invoked)',
157
+ async apply(ctx): Promise<ActionResult> {
158
+ if (ctx.guardrail.action.compensateWith === undefined) {
159
+ return {
160
+ kind: 'err',
161
+ error: {
162
+ code: 'invalid-action-config',
163
+ message: `compensate action on guardrail "${ctx.guardrail.id}" needs action.compensateWith`,
164
+ },
165
+ };
166
+ }
167
+ return { kind: 'ok' };
168
+ },
169
+ };
170
+
171
+ /** Convenience seed with all built-in handlers. */
172
+ export const BUILT_IN_ACTION_HANDLERS: readonly ActionHandler[] = [
173
+ haltHandler,
174
+ logOnlyHandler,
175
+ noopHandler,
176
+ retryHandler,
177
+ escalateHandler,
178
+ compensateHandler,
179
+ ];
package/src/checks.ts ADDED
@@ -0,0 +1,236 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { CheckFunction, CheckRegistry, RegisteredCheck } from './types.js';
5
+
6
+ /**
7
+ * Built-in check library. Every entry is `kind: 'zero-llm'` — pure
8
+ * functions over the run trace, no external calls, deterministic. See
9
+ * `judge.ts` for LLM-judge evaluation.
10
+ *
11
+ * Adding a new built-in check: implement the function, add it to
12
+ * `BUILT_IN_CHECKS`, document its `config` schema in the JSDoc block.
13
+ */
14
+
15
+ /**
16
+ * `must-cite`: assistant output must include at least N matches of the
17
+ * citation pattern. Config:
18
+ * `{ minCitations?: number /* default 1 *\/, sourcePattern?: string /* regex *\/ }`
19
+ *
20
+ * Default `sourcePattern` matches `[<name>]`-style citations; override for
21
+ * verticals with different citation conventions.
22
+ */
23
+ const mustCite: CheckFunction = async (config, trace) => {
24
+ const minCitations = typeof config.minCitations === 'number' ? config.minCitations : 1;
25
+ const sourcePattern =
26
+ typeof config.sourcePattern === 'string' ? config.sourcePattern : '\\[[^\\]]+\\]';
27
+ if (trace.output === undefined || trace.output.length === 0) {
28
+ return { passed: false, reason: 'output empty; nothing to cite' };
29
+ }
30
+ const re = new RegExp(sourcePattern, 'g');
31
+ const matches = trace.output.match(re) ?? [];
32
+ if (matches.length >= minCitations) {
33
+ return { passed: true, attributes: { citationCount: matches.length } };
34
+ }
35
+ return {
36
+ passed: false,
37
+ reason: `expected >= ${minCitations} citations, found ${matches.length}`,
38
+ attributes: { citationCount: matches.length },
39
+ };
40
+ };
41
+
42
+ /**
43
+ * `never-call-tool`: fail if any of the listed tools were invoked.
44
+ * Config: `{ tools: string[] }` — tool ids or names to forbid.
45
+ */
46
+ const neverCallTool: CheckFunction = async (config, trace) => {
47
+ // Accept both tool id / name strings and ToolRef objects
48
+ // ({ id, version }) — pack config authors may use either.
49
+ const rawTools = Array.isArray(config.tools) ? (config.tools as unknown[]) : [];
50
+ const forbidden = new Set<string>();
51
+ for (const entry of rawTools) {
52
+ if (typeof entry === 'string') {
53
+ forbidden.add(entry);
54
+ } else if (
55
+ entry !== null &&
56
+ typeof entry === 'object' &&
57
+ 'id' in entry &&
58
+ typeof (entry as { id: unknown }).id === 'string'
59
+ ) {
60
+ forbidden.add((entry as { id: string }).id);
61
+ }
62
+ }
63
+ const violations: string[] = [];
64
+ for (const call of trace.toolCalls) {
65
+ if (forbidden.has(call.toolId) || forbidden.has(call.toolName)) {
66
+ violations.push(call.toolName);
67
+ }
68
+ }
69
+ if (violations.length === 0) return { passed: true };
70
+ return {
71
+ passed: false,
72
+ reason: `forbidden tool(s) invoked: ${[...new Set(violations)].join(', ')}`,
73
+ attributes: { violatingTools: [...new Set(violations)] },
74
+ };
75
+ };
76
+
77
+ /**
78
+ * `max-tool-calls`: cap total tool invocations. Config: `{ max?: number }` (default 10).
79
+ * Guards against runaway agents.
80
+ */
81
+ const maxToolCalls: CheckFunction = async (config, trace) => {
82
+ const max = typeof config.max === 'number' ? config.max : 10;
83
+ if (trace.toolCalls.length <= max) {
84
+ return { passed: true, attributes: { toolCallCount: trace.toolCalls.length } };
85
+ }
86
+ return {
87
+ passed: false,
88
+ reason: `expected <= ${max} tool calls, saw ${trace.toolCalls.length}`,
89
+ attributes: { toolCallCount: trace.toolCalls.length },
90
+ };
91
+ };
92
+
93
+ /**
94
+ * `output-matches`: final assistant output must match a regex (or fail
95
+ * if `negate: true`). Config: `{ pattern: string, flags?: string, negate?: boolean }`.
96
+ */
97
+ const outputMatches: CheckFunction = async (config, trace) => {
98
+ const pattern = typeof config.pattern === 'string' ? config.pattern : '';
99
+ const flags = typeof config.flags === 'string' ? config.flags : '';
100
+ const negate = config.negate === true;
101
+ if (pattern.length === 0) {
102
+ return { passed: false, reason: 'pattern config is required' };
103
+ }
104
+ const re = new RegExp(pattern, flags);
105
+ const output = trace.output ?? '';
106
+ const matched = re.test(output);
107
+ const passed = negate ? !matched : matched;
108
+ return passed
109
+ ? { passed: true }
110
+ : {
111
+ passed: false,
112
+ reason: negate
113
+ ? `output matched forbidden pattern /${pattern}/`
114
+ : `output did not match /${pattern}/`,
115
+ };
116
+ };
117
+
118
+ /**
119
+ * `tool-order`: tool calls must appear in a specified order. Config:
120
+ * `{ sequence: string[] }` — tool names in expected order (subsequence).
121
+ * Non-listed tools are allowed to appear anywhere; the sequence must exist
122
+ * as a subsequence of the actual call order.
123
+ */
124
+ const toolOrder: CheckFunction = async (config, trace) => {
125
+ const sequence = Array.isArray(config.sequence) ? (config.sequence as string[]) : [];
126
+ if (sequence.length === 0) return { passed: true };
127
+ let seqIndex = 0;
128
+ for (const call of trace.toolCalls) {
129
+ if (call.toolName === sequence[seqIndex]) seqIndex += 1;
130
+ if (seqIndex === sequence.length) return { passed: true };
131
+ }
132
+ return {
133
+ passed: false,
134
+ reason: `expected tool-call subsequence [${sequence.join(', ')}] not observed`,
135
+ attributes: { matchedPrefix: sequence.slice(0, seqIndex) },
136
+ };
137
+ };
138
+
139
+ /**
140
+ * `required-substring`: `trace.output` must contain EVERY listed pattern.
141
+ * Config: `{ patterns: string[], caseSensitive?: boolean }`.
142
+ *
143
+ * Mirror of `forbidden-substring` — enables "output must include the
144
+ * standard legal disclaimer / required attribution / policy footer"
145
+ * without needing an llm-judge.
146
+ *
147
+ * Empty `patterns` array passes vacuously. Missing `output` on the
148
+ * trace fails (nothing can contain the required text).
149
+ */
150
+ const requiredSubstring: CheckFunction = async (config, trace) => {
151
+ const patternsRaw = Array.isArray(config.patterns) ? (config.patterns as unknown[]) : [];
152
+ const patterns = patternsRaw.filter((p): p is string => typeof p === 'string' && p.length > 0);
153
+ if (patterns.length === 0) return { passed: true };
154
+ if (trace.output === undefined || trace.output.length === 0) {
155
+ return {
156
+ passed: false,
157
+ reason: `output empty; ${patterns.length} required pattern(s) not satisfied`,
158
+ attributes: { missing: patterns },
159
+ };
160
+ }
161
+ const caseSensitive = config.caseSensitive === true;
162
+ const haystack = caseSensitive ? trace.output : trace.output.toLowerCase();
163
+ const missing: string[] = [];
164
+ for (const p of patterns) {
165
+ const needle = caseSensitive ? p : p.toLowerCase();
166
+ if (!haystack.includes(needle)) missing.push(p);
167
+ }
168
+ if (missing.length === 0) return { passed: true };
169
+ return {
170
+ passed: false,
171
+ reason: `output missing required pattern(s): ${missing.join(', ')}`,
172
+ attributes: { missing },
173
+ };
174
+ };
175
+
176
+ /**
177
+ * `forbidden-substring`: `trace.output` must NOT contain any listed pattern.
178
+ * Config: `{ patterns: string[], caseSensitive?: boolean }`.
179
+ *
180
+ * Symmetric to `required-substring`. Enables "output must not contain
181
+ * PII markers / off-limits phrases / policy trigger words" without an
182
+ * llm-judge.
183
+ */
184
+ const forbiddenSubstring: CheckFunction = async (config, trace) => {
185
+ const patternsRaw = Array.isArray(config.patterns) ? (config.patterns as unknown[]) : [];
186
+ const patterns = patternsRaw.filter((p): p is string => typeof p === 'string' && p.length > 0);
187
+ if (patterns.length === 0) return { passed: true };
188
+ if (trace.output === undefined || trace.output.length === 0) return { passed: true };
189
+ const caseSensitive = config.caseSensitive === true;
190
+ const haystack = caseSensitive ? trace.output : trace.output.toLowerCase();
191
+ const violated: string[] = [];
192
+ for (const p of patterns) {
193
+ const needle = caseSensitive ? p : p.toLowerCase();
194
+ if (haystack.includes(needle)) violated.push(p);
195
+ }
196
+ if (violated.length === 0) return { passed: true };
197
+ return {
198
+ passed: false,
199
+ reason: `output contains forbidden pattern(s): ${violated.join(', ')}`,
200
+ attributes: { violated },
201
+ };
202
+ };
203
+
204
+ const BUILT_IN_CHECKS: readonly RegisteredCheck[] = [
205
+ { id: 'must-cite', kind: 'zero-llm', evaluate: mustCite },
206
+ { id: 'never-call-tool', kind: 'zero-llm', evaluate: neverCallTool },
207
+ { id: 'max-tool-calls', kind: 'zero-llm', evaluate: maxToolCalls },
208
+ { id: 'output-matches', kind: 'zero-llm', evaluate: outputMatches },
209
+ { id: 'tool-order', kind: 'zero-llm', evaluate: toolOrder },
210
+ { id: 'required-substring', kind: 'zero-llm', evaluate: requiredSubstring },
211
+ { id: 'forbidden-substring', kind: 'zero-llm', evaluate: forbiddenSubstring },
212
+ ];
213
+
214
+ /**
215
+ * Create a fresh CheckRegistry pre-populated with built-in checks.
216
+ * Consumers register custom checks at pack init.
217
+ */
218
+ export function createCheckRegistry(seed: readonly RegisteredCheck[] = []): CheckRegistry {
219
+ const checks = new Map<string, RegisteredCheck>();
220
+ for (const c of BUILT_IN_CHECKS) checks.set(c.id, c);
221
+ for (const c of seed) checks.set(c.id, c);
222
+ return {
223
+ register(check): void {
224
+ checks.set(check.id, check);
225
+ },
226
+ get(id): RegisteredCheck | undefined {
227
+ return checks.get(id);
228
+ },
229
+ list(): readonly RegisteredCheck[] {
230
+ return [...checks.values()];
231
+ },
232
+ };
233
+ }
234
+
235
+ /** Exported for tests + downstream introspection. */
236
+ export const BUILT_IN_CHECK_IDS = BUILT_IN_CHECKS.map((c) => c.id);
@@ -0,0 +1,207 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { ValidateFunction } from 'ajv';
5
+ import * as addFormatsModule from 'ajv-formats';
6
+ import { Ajv2020 } from 'ajv/dist/2020.js';
7
+
8
+ import { isZodSchema, loadZodConverterSync, toJSONSchemaSync } from '@kindgi/schema';
9
+ import type { AnySchema, ZodLikeSchema } from '@kindgi/schema';
10
+
11
+ import type { InvalidCheckDefinitionError } from './errors.js';
12
+ import type { CheckFunction, GuardrailKind, RegisteredCheck } from './types.js';
13
+
14
+ type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
15
+ const addFormatsRaw = addFormatsModule as unknown;
16
+ const addFormats: AddFormatsFn =
17
+ typeof addFormatsRaw === 'function'
18
+ ? (addFormatsRaw as AddFormatsFn)
19
+ : (addFormatsRaw as { default: AddFormatsFn }).default;
20
+
21
+ /**
22
+ * Author-time inference from a Zod schema's `_zod.output`. Non-Zod
23
+ * schemas fall through to `Readonly<Record<string, unknown>>` — the
24
+ * same type that `Guardrail.config` already carries.
25
+ */
26
+ export type InferCheckConfig<T> = T extends { readonly _zod: { readonly output: infer O } }
27
+ ? O
28
+ : Readonly<Record<string, unknown>>;
29
+
30
+ /**
31
+ * The author-facing spec accepted by `defineCheck`. Extends
32
+ * `RegisteredCheck` with an optional `configSchema` slot that can be
33
+ * either a JSON Schema object or a Zod v4 schema. When set,
34
+ * `defineCheck` derives `validateConfig` from the schema — authors
35
+ * don't have to write it separately.
36
+ *
37
+ * `evaluate` is typed against the schema-inferred config for Zod-
38
+ * authored checks; JSON-Schema-authored checks fall back to
39
+ * `Readonly<Record<string, unknown>>`.
40
+ */
41
+ export interface DefineCheckSpec<TConfigSchema extends AnySchema> {
42
+ /**
43
+ * Check identifier. Referenced by `Guardrail.check` on the wire.
44
+ * Convention: dot-namespaced for pack-authored checks (e.g.
45
+ * `'acme.max-citations'`); framework-shipped checks use stable ids
46
+ * like `'must-cite'` / `'never-call-tool'`.
47
+ */
48
+ readonly id: string;
49
+ /**
50
+ * `'zero-llm'` — pure function over the trace (fast, deterministic,
51
+ * default). `'llm-judge'` — uses a model (opt-in, costs money;
52
+ * needs `bindings.providerRegistry` or `bindings.judgeProvider`).
53
+ * `'external'` — evaluated by a caller-registered strategy.
54
+ */
55
+ readonly kind: GuardrailKind;
56
+ /**
57
+ * Config schema — Zod v4 or JSON Schema. When present, `defineCheck`
58
+ * derives `RegisteredCheck.validateConfig` from it, and
59
+ * `defineGuardrail` runs that against each `Guardrail.config`.
60
+ * Zod authors get schema-inferred config types on `evaluate`'s first
61
+ * parameter.
62
+ */
63
+ readonly configSchema?: TConfigSchema;
64
+ /**
65
+ * The check function. Signature: `(config, trace, bindings) => Promise<CheckResult>`.
66
+ * `config` is the guardrail's declared config (typed via `configSchema`
67
+ * when Zod-authored). `trace` is the accumulated `RunTrace` from the
68
+ * agent turn. `bindings` is the `EvaluationBindings` the caller
69
+ * passed to the engine (`{}` when none).
70
+ * Return `{passed: true}` or `{passed: false, reason: string, ...}`.
71
+ */
72
+ readonly evaluate: (
73
+ config: InferCheckConfig<TConfigSchema>,
74
+ trace: Parameters<CheckFunction>[1],
75
+ bindings: Parameters<CheckFunction>[2],
76
+ ) => ReturnType<CheckFunction>;
77
+ /**
78
+ * Optional custom config validator. When `configSchema` is also set,
79
+ * both run — `configSchema` first, then this. `undefined` return means
80
+ * "config is valid"; non-undefined string is the error message.
81
+ */
82
+ readonly validateConfig?: (config: unknown) => string | undefined;
83
+ }
84
+
85
+ /**
86
+ * The concrete RegisteredCheck returned by `defineCheck`. When the
87
+ * author provided a Zod schema at `configSchema`, `configZod` is
88
+ * present so TS callers can `z.infer<typeof check.configZod>` for
89
+ * static types.
90
+ */
91
+ export type DefinedCheck<TConfigSchema extends AnySchema> = RegisteredCheck & {
92
+ readonly configZod: TConfigSchema extends ZodLikeSchema ? TConfigSchema : undefined;
93
+ readonly configJsonSchema?: Readonly<Record<string, unknown>>;
94
+ };
95
+
96
+ /**
97
+ * Build a `RegisteredCheck` with optional schema-derived config
98
+ * validation. Two authoring surfaces coexist:
99
+ * 1. JSON Schema Draft 2020-12 objects — compiled at author time
100
+ * via Ajv; the check's `validateConfig` is derived from the
101
+ * compiled validator.
102
+ * 2. Zod v4 schemas — converted via `z.toJSONSchema()` (peer dep),
103
+ * then compiled the same way. TS callers get `z.infer<typeof
104
+ * check.configZod>` for static config typing.
105
+ *
106
+ * When `configSchema` is omitted, this behaves as a passthrough:
107
+ * returns the check with the caller's `validateConfig` (or undefined).
108
+ *
109
+ * Failures at author time (Zod conversion or Ajv compile) surface via
110
+ * throw — same failure mode as construction of a bad
111
+ * `RegisteredCheck` object literal. Consumers catch at pack-init;
112
+ * downstream `defineGuardrail` runs the derived `validateConfig` at
113
+ * spec-registration time and reports via `Result`.
114
+ */
115
+ export function defineCheck<TConfigSchema extends AnySchema = AnySchema>(
116
+ spec: DefineCheckSpec<TConfigSchema>,
117
+ ): DefinedCheck<TConfigSchema> {
118
+ const converter =
119
+ spec.configSchema !== undefined && isZodSchema(spec.configSchema)
120
+ ? loadZodConverterSync()
121
+ : undefined;
122
+
123
+ let derivedValidator: ((config: unknown) => string | undefined) | undefined;
124
+ let jsonSchema: Readonly<Record<string, unknown>> | undefined;
125
+ let zodSchema: ZodLikeSchema | undefined;
126
+
127
+ if (spec.configSchema !== undefined) {
128
+ if (isZodSchema(spec.configSchema)) {
129
+ zodSchema = spec.configSchema;
130
+ const converted = toJSONSchemaSync(spec.configSchema, converter, 'input');
131
+ if (converted.kind === 'err') {
132
+ const err: InvalidCheckDefinitionError = {
133
+ code: 'invalid-check-definition',
134
+ message: `Check "${spec.id}" configSchema Zod conversion failed: ${converted.error.message}`,
135
+ checkId: spec.id,
136
+ cause: converted.error.cause,
137
+ };
138
+ throw errorFromDefinition(err);
139
+ }
140
+ jsonSchema = converted.value;
141
+ } else {
142
+ jsonSchema = spec.configSchema as Readonly<Record<string, unknown>>;
143
+ }
144
+ const validator = compileValidator(jsonSchema, spec.id);
145
+ derivedValidator = (config: unknown) => {
146
+ if (!validator(config)) {
147
+ const errors = validator.errors ?? [];
148
+ const first = errors[0] as { instancePath?: string; message?: string } | undefined;
149
+ const path = first?.instancePath ?? '';
150
+ const message = first?.message ?? 'invalid config';
151
+ return `${path} ${message}`.trim();
152
+ }
153
+ return undefined;
154
+ };
155
+ }
156
+
157
+ const chainValidator = (
158
+ a?: (config: unknown) => string | undefined,
159
+ b?: (config: unknown) => string | undefined,
160
+ ): ((config: unknown) => string | undefined) | undefined => {
161
+ if (a === undefined) return b;
162
+ if (b === undefined) return a;
163
+ return (config) => {
164
+ const first = a(config);
165
+ if (first !== undefined) return first;
166
+ return b(config);
167
+ };
168
+ };
169
+
170
+ const validate = chainValidator(derivedValidator, spec.validateConfig);
171
+
172
+ const check = {
173
+ id: spec.id,
174
+ kind: spec.kind,
175
+ evaluate: spec.evaluate as CheckFunction,
176
+ ...(validate !== undefined && { validateConfig: validate }),
177
+ ...(zodSchema !== undefined && { configZod: zodSchema }),
178
+ ...(jsonSchema !== undefined && { configJsonSchema: jsonSchema }),
179
+ } as unknown as DefinedCheck<TConfigSchema>;
180
+
181
+ return check;
182
+ }
183
+
184
+ function compileValidator(
185
+ schema: Readonly<Record<string, unknown>>,
186
+ checkId: string,
187
+ ): ValidateFunction {
188
+ const ajv = new Ajv2020({ strict: true, allErrors: true, allowUnionTypes: false });
189
+ addFormats(ajv);
190
+ try {
191
+ return ajv.compile(schema as object);
192
+ } catch (cause) {
193
+ const err: InvalidCheckDefinitionError = {
194
+ code: 'invalid-check-definition',
195
+ message: `Check "${checkId}" configSchema does not compile as JSON Schema Draft 2020-12: ${cause instanceof Error ? cause.message : String(cause)}`,
196
+ checkId,
197
+ cause,
198
+ };
199
+ throw errorFromDefinition(err);
200
+ }
201
+ }
202
+
203
+ function errorFromDefinition(err: InvalidCheckDefinitionError): Error {
204
+ const wrapped = new Error(err.message);
205
+ Object.assign(wrapped, err);
206
+ return wrapped;
207
+ }