UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

476 lines (475 loc) • 23.1 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Structural JSON-Schema-subset validator (orchestration plan P1). * * The workflow engine's structured-output normalization needs to validate * unit results against the author-declared unit `output` schema on any harness — * including ones with no native schema support. Pulling in a full * draft-2020-12 validator is deliberately avoided (dependency surface); this * module implements the bounded subset that covers the schemas workflow * authors actually write: * * Supported: `type` (string | string[] — string, number, integer, boolean, * object, array, null), `properties`, `required`, `items`, * `additionalProperties: false`, `enum` (primitives), `minItems`, * `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`, and the * combinators `allOf`, `anyOf`, `oneOf`, `not`. * * Ignored (permissive): `$ref`, `pattern`, `format`, `patternProperties`, * tuple-form `items`, schema-form `additionalProperties`, and every other * keyword. Unknown keywords never throw — a schema using them simply * constrains less. Callers needing full JSON Schema semantics should * validate downstream. * * `pattern` is deliberately NOT evaluated. Matching an author-supplied regex * with the platform `RegExp` inside a synchronous gate decision would need a * static safety analysis to stay bounded, and any such analysis rejects * legitimate patterns — machinery that makes authoring fail for no benefit * the workflows here ask for. `pattern` is a recognized-but-unsupported * keyword instead: {@link checkJsonSchemaDefinition} reports it loudly at * authoring time, so nobody writes one believing it is enforced. * * ## Totality and bounds * * Evaluation is TOTAL and BOUNDED. Recursion is capped at * {@link MAX_DEFINITION_DEPTH} and the whole evaluation shares a single * node-visit budget ({@link MAX_VALIDATION_NODES}); exhausting either emits an * explicit error rather than silently accepting the value — the subset never * fails open. The schema tree is finite and acyclic (no `$ref`), so combinator * branching multiplies work by schema size, never exponentially. * * Returns a flat list of human-readable error strings (empty = valid), each * prefixed with a JSON-pointer-ish path — the shape `runStructured`'s * corrective-feedback builder wants. * * {@link checkJsonSchemaDefinition} is the companion DEFINITION checker: it * walks an author-declared schema OBJECT (not a value) and reports typo'd * `type` names / structurally malformed keywords as `"malformed"` issues, and * recognized JSON Schema keywords this subset silently ignores as * `"unsupported"` issues — so a schema that would constrain nothing at * runtime can be rejected loudly at authoring time (the workflow parser does * exactly that for `output:` and `params` schemas). It deliberately does NOT * change {@link validateJsonSchemaSubset}'s permissive evaluation semantics. */ import { isRecord } from "./common.js"; /** * Deepest schema nesting either walk descends into — {@link * validateJsonSchemaSubset} evaluating a value, and {@link * checkJsonSchemaDefinition} checking the schema object itself. Both walk the * same schema tree, so they share one bound. */ const MAX_DEFINITION_DEPTH = 64; export function validateJsonSchemaSubset(value, schema, options) { const errors = []; validateNode(value, schema, "$", { errors, depth: 0, redactValues: options?.redactValues ?? false }); return errors; } /** Human-readable list of the keywords {@link validateJsonSchemaSubset} enforces (for error messages). */ export const JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS = "type, enum, properties, required, items, additionalProperties: false, minItems, maxItems, " + "minLength, maxLength, minimum, maximum, allOf, anyOf, oneOf, not"; const KNOWN_TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]); // Annotation keywords (`title`, `description`, `default`, `examples`, // `$schema`, `$id`, `$comment`, `deprecated`, `readOnly`, `writeOnly`) are // deliberately NOT in the unsupported set below: they constrain nothing in // full JSON Schema either, so the subset ignoring them loses no semantics. // Like any other unrecognized keyword (e.g. `x-…` extensions), they fall // through the checker unreported — JSON Schema's own open-keyword behavior. /** * Recognized JSON Schema keywords {@link validateJsonSchemaSubset} silently * ignores — a schema relying on one of these constrains LESS at runtime than * its author intended, so definition checking reports each as `"unsupported"`. */ const UNSUPPORTED_KEYWORDS = new Set([ "$ref", "$defs", "definitions", "$anchor", "$dynamicRef", "$dynamicAnchor", "if", "then", "else", "const", "pattern", "format", "patternProperties", "propertyNames", "additionalItems", "prefixItems", "contains", "minContains", "maxContains", "uniqueItems", "multipleOf", "exclusiveMinimum", "exclusiveMaximum", "minProperties", "maxProperties", "dependencies", "dependentRequired", "dependentSchemas", "unevaluatedItems", "unevaluatedProperties", "contentEncoding", "contentMediaType", "contentSchema", ]); /** * Per-keyword follow-up for the `"unsupported"` message. Where the subset can * express the same intent, name the keyword that does it; where it cannot, say * so plainly rather than sending the author to another unsupported keyword. */ const UNSUPPORTED_KEYWORD_HINTS = new Map([ ["$ref", `inline the referenced schema (the subset resolves no references, so it cannot follow "$ref")`], ["$defs", `inline the definitions at their use sites — "$ref" is not resolved, so "$defs" can never be reached`], [ "definitions", `inline the definitions at their use sites — "$ref" is not resolved, so "definitions" can never be reached`, ], ["const", `use a single-value "enum" (e.g. enum: [pass])`], [ "pattern", `use "enum" when the allowed strings can be listed, or "minLength"/"maxLength" for a size bound — ` + `a regular-expression constraint is not expressible in the subset; check the shape in the step's gate rubric instead`, ], [ "format", `"format" is annotation-only in JSON Schema 2020-12 and the subset enforces no string-shape keyword — ` + `use "enum" when the allowed values can be listed, otherwise check the shape in the step's gate rubric`, ], ["patternProperties", `declare the properties explicitly under "properties", or drop the constraint`], ["if", `use "anyOf"/"oneOf" to express the alternatives directly`], ["then", `use "anyOf"/"oneOf" to express the alternatives directly`], ["else", `use "anyOf"/"oneOf" to express the alternatives directly`], ["uniqueItems", `drop the constraint, or validate uniqueness in the step's gate rubric`], ]); /** * Check a JSON Schema DEFINITION (the schema object itself, not a value) * against the subset {@link validateJsonSchemaSubset} enforces. Returns * accumulated issues (empty = the schema is a well-formed subset schema). * Keywords that are neither subset-enforced, unsupported-but-recognized, nor * annotations are ignored, matching JSON Schema's own open-keyword behavior. */ export function checkJsonSchemaDefinition(schema) { const issues = []; checkDefinitionNode(schema, [], issues, 0); return issues; } function pointerFor(path) { return path.length === 0 ? "$" : `$.${path.map(String).join(".")}`; } function pushIssue(issues, path, keyword, kind, message) { issues.push({ path: [...path], pointer: pointerFor(path), keyword, kind, message }); } /** Values the runtime's reference-equality `enum` check can enforce correctly. */ function isSupportedEnumValue(value) { return (value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))); } function checkDefinitionNode(schema, path, issues, depth) { if (depth > MAX_DEFINITION_DEPTH) { pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`); return; } for (const keyword of Object.keys(schema)) { if (UNSUPPORTED_KEYWORDS.has(keyword)) { const hint = UNSUPPORTED_KEYWORD_HINTS.get(keyword); pushIssue(issues, [...path, keyword], keyword, "unsupported", `keyword "${keyword}" is not enforced by the workflow schema subset — the schema would silently not ` + `constrain what it looks like it constrains${hint ? `; ${hint}` : ""}`); } } const declared = schema.type; if (declared !== undefined) { const names = Array.isArray(declared) ? declared : [declared]; if (names.length === 0) { pushIssue(issues, [...path, "type"], "type", "malformed", `"type" must name at least one type`); } for (const [index, name] of names.entries()) { const namePath = Array.isArray(declared) ? [...path, "type", index] : [...path, "type"]; if (typeof name !== "string") { pushIssue(issues, namePath, "type", "malformed", `"type" must be a string or an array of strings`); } else if (!KNOWN_TYPE_NAMES.has(name)) { pushIssue(issues, namePath, "type", "malformed", `unknown type ${JSON.stringify(name)} (valid types: ${[...KNOWN_TYPE_NAMES].join(", ")})`); } } } if (schema.enum !== undefined) { if (!Array.isArray(schema.enum) || schema.enum.length === 0) { pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`); } else { schema.enum.forEach((value, index) => { if (isSupportedEnumValue(value)) return; pushIssue(issues, [...path, "enum", index], "enum", "unsupported", `"enum" values must be JSON primitives (string, finite number, boolean, or null) in the workflow ` + `schema subset — object and array enum members cannot be matched by the runtime subset`); }); } } for (const keyword of ["allOf", "anyOf", "oneOf"]) { const branches = schema[keyword]; if (branches === undefined) continue; if (!Array.isArray(branches) || branches.length === 0) { pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-empty array of schema objects`); continue; } branches.forEach((branch, index) => { if (isRecord(branch)) { checkDefinitionNode(branch, [...path, keyword, index], issues, depth + 1); } else { pushIssue(issues, [...path, keyword, index], keyword, "malformed", `"${keyword}[${index}]" must be a schema object`); } }); } if (schema.not !== undefined) { if (isRecord(schema.not)) { checkDefinitionNode(schema.not, [...path, "not"], issues, depth + 1); } else { pushIssue(issues, [...path, "not"], "not", "malformed", `"not" must be a schema object`); } } if (schema.required !== undefined) { if (!Array.isArray(schema.required) || !schema.required.every((key) => typeof key === "string")) { pushIssue(issues, [...path, "required"], "required", "malformed", `"required" must be an array of property-name strings`); } } if (schema.properties !== undefined) { if (!isRecord(schema.properties)) { pushIssue(issues, [...path, "properties"], "properties", "malformed", `"properties" must be an object mapping property names to schemas`); } else { for (const [key, propSchema] of Object.entries(schema.properties)) { if (isRecord(propSchema)) { checkDefinitionNode(propSchema, [...path, "properties", key], issues, depth + 1); } else { pushIssue(issues, [...path, "properties", key], "properties", "malformed", `property ${JSON.stringify(key)} must be a schema object`); } } } } if (schema.items !== undefined) { if (isRecord(schema.items)) { checkDefinitionNode(schema.items, [...path, "items"], issues, depth + 1); } else if (Array.isArray(schema.items)) { pushIssue(issues, [...path, "items"], "items", "unsupported", `tuple-form "items" (an array of schemas) is not enforced by the workflow schema subset — use a single schema object`); } else { pushIssue(issues, [...path, "items"], "items", "malformed", `"items" must be a schema object`); } } if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") { if (isRecord(schema.additionalProperties)) { pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "unsupported", `schema-form "additionalProperties" is not enforced by the workflow schema subset — only "additionalProperties: false" is`); } else { pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "malformed", `"additionalProperties" must be a boolean (only "false" is enforced)`); } } for (const keyword of ["minItems", "maxItems", "minLength", "maxLength"]) { const value = schema[keyword]; if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < 0)) { pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-negative integer`); } } for (const keyword of ["minimum", "maximum"]) { const value = schema[keyword]; if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) { pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a finite number`); } } } function typeOf(value) { if (value === null) return "null"; if (Array.isArray(value)) return "array"; switch (typeof value) { case "string": return "string"; case "boolean": return "boolean"; case "number": return Number.isInteger(value) ? "integer" : "number"; default: return "object"; } } function matchesType(actual, expected) { if (expected === actual) return true; // JSON Schema: every integer is also a number. return expected === "number" && actual === "integer"; } /** Evaluate `schema` against `value` in a scratch error list, sharing the caller's budget. */ function branchErrors(value, schema, path, ctx) { const errors = []; validateNode(value, schema, path, { ...ctx, errors, depth: ctx.depth + 1 }); return errors; } /** The schemas of a combinator keyword, or `[]` when the keyword is absent/malformed (permissive). */ function combinatorBranches(schema, keyword) { const raw = schema[keyword]; if (!Array.isArray(raw)) return []; return raw.filter(isRecord); } /** First error of each failing branch, truncated — enough to act on without dumping every branch. */ function summarizeBranchFailures(failures) { const shown = failures.slice(0, 3).map((f) => `${f.index + 1}: ${f.errors[0] ?? "no match"}`); if (failures.length > shown.length) shown.push(`…${failures.length - shown.length} more`); return shown.join("; "); } function validateCombinators(value, schema, path, ctx) { for (const branch of combinatorBranches(schema, "allOf")) { // `allOf` failures ARE the value's failures — surface them verbatim. ctx.errors.push(...branchErrors(value, branch, path, ctx)); } for (const keyword of ["anyOf", "oneOf"]) { const branches = combinatorBranches(schema, keyword); if (branches.length === 0) continue; const failures = []; const matched = []; branches.forEach((branch, index) => { const errors = branchErrors(value, branch, path, ctx); if (errors.length === 0) matched.push(index + 1); else failures.push({ index, errors }); }); if (matched.length === 0) { ctx.errors.push(`${path}: value matches none of the ${branches.length} "${keyword}" schemas (${summarizeBranchFailures(failures)})`); } else if (keyword === "oneOf" && matched.length > 1) { ctx.errors.push(`${path}: value matches ${matched.length} "oneOf" schemas (branches ${matched.join(", ")}); exactly one must match`); } } const not = schema.not; if (isRecord(not) && branchErrors(value, not, path, ctx).length === 0) { ctx.errors.push(`${path}: value must not match the "not" schema`); } } function validateNode(value, schema, path, ctx) { const errors = ctx.errors; if (ctx.depth > MAX_DEFINITION_DEPTH) { errors.push(`${path}: schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`); return; } const actual = typeOf(value); const declared = schema.type; if (typeof declared === "string" || Array.isArray(declared)) { const expected = (Array.isArray(declared) ? declared : [declared]).filter((t) => typeof t === "string"); if (expected.length > 0 && !expected.some((t) => matchesType(actual, t))) { errors.push(`${path}: expected type ${expected.join(" | ")}, got ${actual}`); return; // type mismatch makes the remaining constraints meaningless } } if (Array.isArray(schema.enum) && schema.enum.length > 0) { const allowed = schema.enum; if (!allowed.some((candidate) => candidate === value)) { // Never echo the supplied value when redacting: enum-constrained inputs // can carry credentials, and this detail lands in stderr envelopes that // get pasted into CI logs. The allowed list is safe — it comes from the // author-declared schema, not from user-supplied data. errors.push(ctx.redactValues ? `${path}: value is not one of ${JSON.stringify(allowed)}` : `${path}: value ${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`); return; } } // Combinators are type-agnostic, so they run BEFORE the per-type branches // below (each of which returns). A schema with no combinator keyword is // untouched by this call. validateCombinators(value, schema, path, ctx); if (actual === "string" && typeof value === "string") { if (typeof schema.minLength === "number" && value.length < schema.minLength) { errors.push(`${path}: string shorter than minLength ${schema.minLength}`); } if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { errors.push(`${path}: string longer than maxLength ${schema.maxLength}`); } return; } if ((actual === "number" || actual === "integer") && typeof value === "number") { if (typeof schema.minimum === "number" && value < schema.minimum) { errors.push(ctx.redactValues ? `${path}: value is below minimum ${schema.minimum}` : `${path}: ${value} is below minimum ${schema.minimum}`); } if (typeof schema.maximum === "number" && value > schema.maximum) { errors.push(ctx.redactValues ? `${path}: value is above maximum ${schema.maximum}` : `${path}: ${value} is above maximum ${schema.maximum}`); } return; } if (actual === "array" && Array.isArray(value)) { if (typeof schema.minItems === "number" && value.length < schema.minItems) { errors.push(`${path}: array has fewer than minItems ${schema.minItems}`); } if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { errors.push(`${path}: array has more than maxItems ${schema.maxItems}`); } const items = schema.items; if (items && typeof items === "object" && !Array.isArray(items)) { value.forEach((element, index) => { validateNode(element, items, `${path}[${index}]`, { ...ctx, depth: ctx.depth + 1, }); }); } return; } if (actual === "object" && typeof value === "object" && value !== null) { const record = value; const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties) ? schema.properties : undefined; if (Array.isArray(schema.required)) { for (const key of schema.required) { // `Object.hasOwn`, not `key in record`: a required key satisfied only by // an inherited prototype member (e.g. "toString", "constructor") is NOT // present on the value itself, so `{}` must fail `required: ["toString"]`. if (typeof key === "string" && !Object.hasOwn(record, key)) { errors.push(`${path}: missing required property "${key}"`); } } } if (properties) { for (const [key, propSchema] of Object.entries(properties)) { if (!Object.hasOwn(record, key)) continue; if (propSchema && typeof propSchema === "object" && !Array.isArray(propSchema)) { validateNode(record[key], propSchema, `${path}.${key}`, { ...ctx, depth: ctx.depth + 1, }); } } } // `additionalProperties: false` closes the object to exactly its declared // `properties`. This MUST run even when no `properties` object is present: // `{ type: "object", additionalProperties: false }` admits only `{}`. Use // `Object.hasOwn` so an inherited key name (e.g. "toString") on the empty // property set is not mistaken for a declared property. if (schema.additionalProperties === false) { for (const key of Object.keys(record)) { if (!properties || !Object.hasOwn(properties, key)) { errors.push(`${path}: unexpected property "${key}" (additionalProperties: false)`); } } } } }