From 7499d534bd2968a61063fe3af057f96003e97407 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Mon, 3 Aug 2026 17:30:34 -0400 Subject: [PATCH 1/2] feat: importable validation harness (lib/validate.mjs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness's checks were already pure functions trapped in a CLI script; this makes the one-validator principle (rfc/dx3-bootstrap-design §4) importable instead of aspirational: - lib/validate.mjs: every check moved verbatim — buildVocabulary, checkVocabulary (S2), checkGovernance (incl. S1 over examples), checkCategories, validateDocument — plus compileSchemaSet (schemas INJECTED by the caller: no filesystem, no process control, ajv-only imports, so the identical code runs in a browser bundle) and two small additions: stripAdditiveBlocks (the back-compat strip, previously inline in main) and documentReport ({valid, version, errors} for hosts). - scripts/validate.mjs: now the filesystem-and-process front-end over the lib — a distribution of the harness, never a fork. CLI output is byte-identical to the pre-extraction harness (diffed; only npm's version banner changes). - scripts/check-lib-boundary.mjs (CI-wired as check:lib): purity gate (ajv-only imports) + non-vacuity through the IMPORT surface — all examples accepted (with back-compat strip), all 18 negative fixtures rejected, via the lib directly. - lib/validate.d.ts hand-written types; lib/ added to files; README programmatic-validation section; version 0.4.2. No exports map, deliberately: every published file stays deep-importable (af-site and siblings resolve schema/ and examples/ by path today). Co-Authored-By: Claude Fable 5 --- .github/workflows/validate.yml | 2 + README.md | 28 +++ lib/validate.d.ts | 54 ++++++ lib/validate.mjs | 332 +++++++++++++++++++++++++++++++ package.json | 6 +- scripts/check-lib-boundary.mjs | 84 ++++++++ scripts/validate.mjs | 345 +++------------------------------ 7 files changed, 536 insertions(+), 315 deletions(-) create mode 100644 lib/validate.d.ts create mode 100644 lib/validate.mjs create mode 100644 scripts/check-lib-boundary.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d89346e..fe09921 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,6 +19,8 @@ jobs: run: npm run validate - name: Negative fixtures (must all be rejected) run: npm run validate -- --fixtures negative + - name: lib boundary (pure imports; corpus through the import surface) + run: npm run check:lib - name: File mode accepts a valid document run: npm run validate -- --file examples/shadcn-ui.dspack.json - name: File mode rejects an invalid document diff --git a/README.md b/README.md index e6fbb7d..0952b8e 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,34 @@ dspack files can also simply be written by hand — the [shadcn/ui example](exam ds-mcp is one way to consume a dspack file, not the only way. The format is independent of MCP, independent of any specific AI agent or orchestration framework, and independent of any particular runtime environment. (On why the reference implementation is deliberately not the center of gravity, see [DESIGN.md](./DESIGN.md).) +### Validating dspack files programmatically + +The validation harness behind the `dspack-validate` CLI is importable — +pure functions, schemas injected, so the identical checks run under Node or +in a browser bundle. The CLI is a front-end over this one implementation, +never a second validator: + +```js +import { + compileSchemaSet, + documentReport, +} from "@aestheticfunction/dspack-spec/lib/validate.mjs"; +import v04 from "@aestheticfunction/dspack-spec/schema/dspack.v0.4.schema.json" with { type: "json" }; +import surface from "@aestheticfunction/dspack-spec/schema/dspack.surface.v0_1.schema.json" with { type: "json" }; + +const { validators } = compileSchemaSet({ + "dspack.v0.4.schema.json": v04, + "dspack.surface.v0_1.schema.json": surface, +}); +const report = documentReport(doc, validators); +// { valid, version, errors } — schema gate, governance consistency, +// categories, and S1/S2 over the contract's own examples. +``` + +Types ship alongside (`lib/validate.d.ts`). CI's `check:lib` gate keeps the +lib pure (ajv-only imports) and replays the full example + negative-fixture +corpus through the import surface. + If you want to build a dspack reader for a different use case, the format is available under the Apache-2.0 license. Potential directions include: - Readers for non-MCP agentic frameworks (LangChain, AutoGen, or similar) diff --git a/lib/validate.d.ts b/lib/validate.d.ts new file mode 100644 index 0000000..88be1fe --- /dev/null +++ b/lib/validate.d.ts @@ -0,0 +1,54 @@ +/** + * Types for the importable validation harness (lib/validate.mjs). + * Import path (no exports map, by design — every published file stays + * reachable): "@aestheticfunction/dspack-spec/lib/validate.mjs". + */ + +export declare const DSPACK_SCHEMAS: Record; +export declare const GOVERNANCE_VERSIONS: Set; +export declare const SURFACE_SCHEMA: string; + +/** ajv validate function shape (kept structural to avoid an ajv type dependency). */ +export interface SchemaValidator { + (data: unknown): boolean; + errors?: Array<{ instancePath?: string; message?: string }> | null; +} + +export type ValidatorMap = Map; + +export interface CompiledSchemaSet { + validators: ValidatorMap; + failures: string[]; +} + +/** Compile an injected schema set: { [schemaFileName]: schemaJson }. */ +export declare function compileSchemaSet(schemas: Record): CompiledSchemaSet; + +export interface Vocabulary { + components: Map; slots: Set }>; + subComponents: Map; + duplicateSubIds: Set; +} + +export declare function buildVocabulary(doc: Record): Vocabulary; + +/** Gate S2: walk a surface tree against a contract vocabulary. Returns error strings. */ +export declare function checkVocabulary(surface: Record, vocab: Vocabulary): string[]; + +export declare function checkCategories(doc: Record): string[]; + +export declare function checkGovernance(doc: Record, validateSurface: SchemaValidator): string[]; + +/** The back-compat strip: a governance-version document minus its additive blocks. */ +export declare function stripAdditiveBlocks(doc: Record): Record; + +/** Fully validate one dspack document. Returns error strings (empty = valid). */ +export declare function validateDocument(doc: unknown, validators: ValidatorMap): string[]; + +export interface DocumentReport { + valid: boolean; + version: string | undefined; + errors: string[]; +} + +export declare function documentReport(doc: unknown, validators: ValidatorMap): DocumentReport; diff --git a/lib/validate.mjs b/lib/validate.mjs new file mode 100644 index 0000000..9f9b9df --- /dev/null +++ b/lib/validate.mjs @@ -0,0 +1,332 @@ +/** + * The dspack validation harness as an importable library. + * + * Every check the CLI performs lives here, as pure functions: no filesystem, + * no process control, no environment — schemas are injected by the caller, + * so the same code runs under Node (the `dspack-validate` CLI reads + * schema/*.json and passes them in) and in a browser bundle (a bundler + * imports the schema JSON and passes it in). scripts/validate.mjs is a + * front-end over these functions, never a second validator (the one-validator + * principle, rfc/dx3-bootstrap-design §4); this module is that principle made + * importable. + * + * Scope split, unchanged: this harness validates the DOCUMENT (schema gate, + * back-compat strip, governance consistency, categories, and S1/S2 over the + * contract's own examples). S3 rule evaluation over arbitrary surfaces is a + * consumer concern (dspack-gen's linter). + */ +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +/** dspack version -> schema key expected in the injected schema set. */ +export const DSPACK_SCHEMAS = { + "0.1": "dspack.v0.1.schema.json", + "0.2": "dspack.v0.2.schema.json", + "0.3": "dspack.v0.3.schema.json", + "0.4": "dspack.v0.4.schema.json", +}; +/** Versions with governance blocks (and, from 0.4, categories) to consistency-check. */ +export const GOVERNANCE_VERSIONS = new Set(["0.3", "0.4"]); +export const SURFACE_SCHEMA = "dspack.surface.v0_1.schema.json"; + +function newAjv() { + const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: true }); + addFormats(ajv); + return ajv; +} + +const fmtErr = (e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim(); + +/** + * Compile an injected schema set: { [name]: schemaJson }. Returns + * { validators: Map name -> ajv validate fn, failures: string[] }. + * Names are the schema/*.json filenames (DSPACK_SCHEMAS values plus + * SURFACE_SCHEMA); a name the caller omits simply cannot validate that + * version, and validateDocument reports it. + */ +export function compileSchemaSet(schemas) { + const validators = new Map(); + const failures = []; + for (const [name, schema] of Object.entries(schemas)) { + try { + validators.set(name, newAjv().compile(schema)); + } catch (e) { + failures.push(`${name}: ${e instanceof Error ? e.message : String(e)}`); + } + } + return { validators, failures }; +} + +/** + * Build the vocabulary of a contract: + * - components: Map componentId -> { props: Map propName -> descriptor, slots: Set slotName } + * - subComponents: Map subComponentId -> parent componentId + * - duplicateSubIds: sub-component IDs declared by more than one component. + * Duplicates would make S2 checks and rule reference resolution depend on + * object iteration order, so callers MUST surface them as consistency + * errors (spec §5: sub-component IDs must be unique document-wide). + */ +export function buildVocabulary(doc) { + const components = new Map(); + const subComponents = new Map(); + const duplicateSubIds = new Set(); + for (const [id, entry] of Object.entries(doc.components ?? {})) { + const props = new Map(Object.entries(entry.props ?? {})); + const slots = new Set(); + for (const sub of entry.composition?.subComponents ?? []) { + if (sub.id) { + if (subComponents.has(sub.id) && subComponents.get(sub.id) !== id) duplicateSubIds.add(sub.id); + subComponents.set(sub.id, id); + } + if (sub.slot) slots.add(sub.slot); + } + components.set(id, { props, slots }); + } + return { components, subComponents, duplicateSubIds }; +} + +/** Allowed values for an enum prop descriptor (bare values or valueDescriptor objects). */ +function enumValues(descriptor) { + if (descriptor.type !== "enum" || !Array.isArray(descriptor.values)) return null; + return descriptor.values.map((v) => (v && typeof v === "object" ? v.value : v)); +} + +/** Gate S2: walk a surface tree against a contract vocabulary. Returns error strings. */ +export function checkVocabulary(surface, vocab) { + const errors = []; + const walk = (node, path) => { + if (!node || typeof node !== "object") return; + const cid = node.component; + const isComponent = vocab.components.has(cid); + const isSub = vocab.subComponents.has(cid); + if (!isComponent && !isSub) { + errors.push(`${path}: component '${cid}' is not a component or sub-component of the contract`); + } + if (node.props && Object.keys(node.props).length > 0) { + if (isSub) { + errors.push(`${path}: sub-component '${cid}' does not declare props in this contract`); + } else if (isComponent) { + const { props } = vocab.components.get(cid); + for (const [name, value] of Object.entries(node.props)) { + const descriptor = props.get(name); + if (!descriptor) { + errors.push(`${path}: prop '${name}' is not declared on component '${cid}'`); + continue; + } + const allowed = enumValues(descriptor); + if (allowed && !allowed.includes(value)) { + errors.push( + `${path}: prop '${name}' on '${cid}' has value ${JSON.stringify(value)}; allowed: ${allowed.map((v) => JSON.stringify(v)).join(", ")}`, + ); + } + } + } + } + if (node.slots) { + const slots = isComponent ? vocab.components.get(cid).slots : new Set(); + for (const [slotName, children] of Object.entries(node.slots)) { + if (!slots.has(slotName)) { + errors.push(`${path}: slot '${slotName}' is not declared on component '${cid}'`); + } + children.forEach((child, i) => walk(child, `${path}.slots.${slotName}[${i}]`)); + } + } + (node.children ?? []).forEach((child, i) => walk(child, `${path}.children[${i}]`)); + }; + walk(surface.root, "$.root"); + return errors; +} + +/** Every component/sub-component reference inside a rule, for resolution checks. */ +function ruleComponentRefs(rule) { + const refs = []; + const push = (kind, ids) => { + for (const id of ids ?? []) refs.push({ kind, id }); + }; + push("require", rule.require); + push("forbid", rule.forbid); + // required-props (v0.4) is the one type whose `component` accepts a + // sub-component id (spec v0.4 §4.1); `within` accepts either kind. + if (rule.component) { + refs.push({ kind: rule.type === "required-props" ? "componentOrSub" : "component", id: rule.component }); + } + if (rule.within) refs.push({ kind: "componentOrSub", id: rule.within }); + push("forbiddenDescendants", rule.forbiddenDescendants); + push("requiredSubComponents", (rule.requiredSubComponents ?? []).map((s) => s.id)); + // `on` entries exist only on required-composition/forbidden-composition + // requiredProps/forbiddenProps; required-props (v0.4) entries have no `on`. + if (rule.type !== "required-props") { + push("on", (rule.requiredProps ?? []).map((p) => p.on).filter(Boolean)); + } + push("on", (rule.forbiddenProps ?? []).map((p) => p.on).filter(Boolean)); + return refs; +} + +/** Category consistency checks for a v0.4 document. Returns error strings. */ +export function checkCategories(doc) { + const errors = []; + const registry = new Set(Object.keys(doc.categories ?? {})); + const checkMember = (where, ids) => { + for (const id of ids ?? []) { + if (!registry.has(id)) errors.push(`${where}: category '${id}' is not registered in categories`); + } + }; + for (const [cid, entry] of Object.entries(doc.components ?? {})) { + checkMember(`components.${cid}`, entry.categories); + for (const sub of entry.composition?.subComponents ?? []) { + checkMember(`components.${cid} sub-component '${sub.id}'`, sub.categories); + } + } + for (const rule of doc.rules ?? []) { + checkMember(rule.id ?? "(rule without id)", rule.forbiddenCategories); + } + return errors; +} + +/** Governance consistency checks for a v0.3+ document. Returns error strings. */ +export function checkGovernance(doc, validateSurface) { + const errors = []; + // Spec §5 scopes governance consistency (incl. sub-component id uniqueness) + // to contracts that USE governance blocks — a pure v0.2-shaped document with + // "dspack": "0.3" must keep the strictly-additive guarantee. + if (!doc.intents && !doc.rules && !doc.examples) return errors; + const vocab = buildVocabulary(doc); + // Fail loudly on ambiguous vocabulary before any check that depends on it. + for (const id of vocab.duplicateSubIds) { + const parents = Object.entries(doc.components ?? {}) + .filter(([, entry]) => (entry.composition?.subComponents ?? []).some((s) => s.id === id)) + .map(([componentId]) => componentId); + errors.push( + `sub-component id '${id}' is declared by multiple components (${parents.join(", ")}); ` + + `sub-component ids must be unique document-wide for deterministic S2 and rule resolution`, + ); + } + const intents = new Set((doc.intents ?? []).map((i) => i.id)); + const exampleIds = new Set((doc.examples ?? []).map((e) => e.id)); + + const seen = new Set(); + for (const [block, key] of [ + ["intents", "id"], + ["rules", "id"], + ["examples", "id"], + ]) { + for (const entry of doc[block] ?? []) { + const tag = `${block}:${entry[key]}`; + if (seen.has(tag)) errors.push(`duplicate ${block} id '${entry[key]}'`); + seen.add(tag); + } + } + + for (const rule of doc.rules ?? []) { + for (const intent of rule.appliesTo?.intents ?? []) { + if (!intents.has(intent)) errors.push(`${rule.id}: appliesTo intent '${intent}' is not registered in intents[]`); + } + for (const { kind, id } of ruleComponentRefs(rule)) { + const resolvesToComponent = vocab.components.has(id); + const resolvesToSub = vocab.subComponents.has(id); + // `requiredSubComponents` entries match descendant NODES by component id + // at lint time (spec §5), so the id may be declared as a top-level + // component or as a composition sub-component. Resolution here only + // guards the vocabulary; satisfaction (matching descendants beneath each + // governed node) is the S3 gate's concern, not this harness's. + // `on` remains sub-component-only (spec §5: "the sub-component id `on`"). + const ok = + kind === "on" + ? resolvesToSub + : kind === "component" + ? resolvesToComponent + : resolvesToComponent || resolvesToSub; // requiredSubComponents, componentOrSub, require, forbid, forbiddenDescendants + if (!ok) errors.push(`${rule.id}: ${kind} reference '${id}' does not resolve in the contract`); + } + for (const ex of rule.examples ?? []) { + if (!exampleIds.has(ex)) errors.push(`${rule.id}: example reference '${ex}' does not resolve`); + } + } + + for (const example of doc.examples ?? []) { + const where = example.id ?? "(example without id)"; + if (example.intent && !intents.has(example.intent)) { + errors.push(`${where}: intent '${example.intent}' is not registered in intents[]`); + } + const surface = example.surface; + if (!surface) continue; + // S1 — generic surface schema. + if (!validateSurface(surface)) { + for (const e of validateSurface.errors ?? []) errors.push(`${where}: S1 ${fmtErr(e)}`); + continue; // vocabulary walk needs a well-formed tree + } + if (surface.intent !== example.intent) { + errors.push(`${where}: surface.intent '${surface.intent}' does not match example intent '${example.intent}'`); + } + if (surface.system !== doc.name) { + errors.push(`${where}: surface.system '${surface.system}' does not match contract name '${doc.name}'`); + } + // S2 — contract vocabulary. + for (const e of checkVocabulary(surface, vocab)) errors.push(`${where}: S2 ${e}`); + } + + return errors; +} + +/** + * Remove a governance-version document's additive blocks: the back-compat + * guarantee is that the remaining core shape still validates ("v0.2 shape + + * a newer dspack version is valid", per each version's strictly-additive + * promise). For 0.4 that also strips categories (the registry AND the + * membership fields). + */ +export function stripAdditiveBlocks(doc) { + const stripped = { ...doc }; + delete stripped.intents; + delete stripped.rules; + delete stripped.examples; + if (doc.dspack === "0.4") { + delete stripped.categories; + stripped.components = Object.fromEntries( + Object.entries(doc.components ?? {}).map(([id, entry]) => { + const e = { ...entry }; + delete e.categories; + if (e.composition?.subComponents) { + e.composition = { + ...e.composition, + subComponents: e.composition.subComponents.map(({ categories, ...sub }) => sub), + }; + } + return [id, e]; + }), + ); + } + return stripped; +} + +/** Fully validate one dspack document. Returns error strings (empty = valid). */ +export function validateDocument(doc, validators) { + const errors = []; + const version = doc?.dspack; + const schemaFile = DSPACK_SCHEMAS[version]; + if (!schemaFile) return [`unknown or missing dspack version: ${JSON.stringify(version)}`]; + const validate = validators.get(schemaFile); + if (!validate) return [`schema ${schemaFile} did not compile`]; + if (!validate(doc)) { + for (const e of validate.errors ?? []) errors.push(`schema ${fmtErr(e)}`); + return errors; + } + if (GOVERNANCE_VERSIONS.has(version)) { + errors.push(...checkGovernance(doc, validators.get(SURFACE_SCHEMA))); + } + if (version === "0.4") { + errors.push(...checkCategories(doc)); + } + return errors; +} + +/** + * Convenience wrapper for hosts (the studio composer, editors): compile once + * via compileSchemaSet, then report per document. `errors` are the same + * strings validateDocument produces — paths are embedded in the text, and + * the wording is shared with the CLI by construction. + */ +export function documentReport(doc, validators) { + const errors = validateDocument(doc, validators); + return { valid: errors.length === 0, version: doc?.dspack, errors }; +} diff --git a/package.json b/package.json index dd6698d..ce3fe5d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@aestheticfunction/dspack-spec", - "version": "0.4.1", + "version": "0.4.2", "description": "The dspack specification: spec documents, JSON Schemas, reference example contracts, and the validation harness (bin: dspack-validate).", "type": "module", "license": "Apache-2.0", "scripts": { - "validate": "node scripts/validate.mjs" + "validate": "node scripts/validate.mjs", + "check:lib": "node scripts/check-lib-boundary.mjs" }, "devDependencies": {}, "engines": { @@ -15,6 +16,7 @@ "dspack-validate": "scripts/validate.mjs" }, "files": [ + "lib/", "schema", "scripts/validate.mjs", "spec", diff --git a/scripts/check-lib-boundary.mjs b/scripts/check-lib-boundary.mjs new file mode 100644 index 0000000..cd68574 --- /dev/null +++ b/scripts/check-lib-boundary.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Library-boundary gate for lib/validate.mjs. + * + * Two properties, both load-bearing for the importable harness: + * + * 1. PURITY — the lib imports only ajv/ajv-formats: no node:* modules, no + * filesystem, no process control. This is what lets the identical checks + * run in a browser bundle (the studio composer) and inside ds-mcp's + * no-network boundary. + * + * 2. NON-VACUITY THROUGH THE IMPORT SURFACE — the same corpus CI runs + * through the CLI is run here through the lib import directly: every + * examples/*.dspack.json validates (including the back-compat strip), + * and every fixtures/negative/*.dspack.json is rejected. If extraction + * ever drifted a check, this gate and the CLI would disagree loudly. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + GOVERNANCE_VERSIONS, + SURFACE_SCHEMA, + compileSchemaSet, + documentReport, + stripAdditiveBlocks, + validateDocument, +} from "../lib/validate.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const load = (p) => JSON.parse(readFileSync(p, "utf8")); +const list = (dir) => readdirSync(dir).filter((f) => f.endsWith(".dspack.json")).map((f) => join(dir, f)); + +let failures = 0; +const fail = (msg) => { + console.error(` ✖ ${msg}`); + failures++; +}; + +// 1. Purity: static import scan of the lib source. +const libSource = readFileSync(join(ROOT, "lib", "validate.mjs"), "utf8"); +const imports = [...libSource.matchAll(/from\s+"([^"]+)"|import\s*\(\s*"([^"]+)"\s*\)/g)].map((m) => m[1] ?? m[2]); +const allowed = new Set(["ajv/dist/2020.js", "ajv-formats"]); +for (const specifier of imports) { + if (!allowed.has(specifier)) fail(`lib/validate.mjs imports '${specifier}' (allowed: ajv, ajv-formats only)`); +} +if (imports.length === 0) fail("lib/validate.mjs has no imports at all (scan defect?)"); + +// 2. Non-vacuity through the import surface. +const schemas = {}; +for (const file of readdirSync(join(ROOT, "schema")).filter((f) => f.endsWith(".schema.json"))) { + schemas[file] = load(join(ROOT, "schema", file)); +} +const { validators, failures: compileFailures } = compileSchemaSet(schemas); +for (const f of compileFailures) fail(`schema compile through lib: ${f}`); +if (!validators.has(SURFACE_SCHEMA)) fail(`${SURFACE_SCHEMA} missing from compiled set`); + +for (const path of list(join(ROOT, "examples"))) { + const doc = load(path); + const report = documentReport(doc, validators); + if (!report.valid) { + fail(`${basename(path)} rejected by the lib: ${report.errors[0]}`); + continue; + } + if (GOVERNANCE_VERSIONS.has(doc.dspack)) { + const strippedErrors = validateDocument(stripAdditiveBlocks(doc), validators); + if (strippedErrors.length) fail(`${basename(path)} back-compat strip rejected: ${strippedErrors[0]}`); + } +} + +const negatives = list(join(ROOT, "fixtures", "negative")); +if (negatives.length === 0) fail("no negative fixtures found"); +for (const path of negatives) { + const report = documentReport(load(path), validators); + if (report.valid) fail(`${basename(path)} unexpectedly valid through the lib`); +} + +if (failures) { + console.error(`lib-boundary FAIL (${failures} finding(s))`); + process.exit(1); +} +console.log( + `lib-boundary PASS (pure imports; ${list(join(ROOT, "examples")).length} examples accepted, ${negatives.length} negatives rejected through the import surface)`, +); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index a31b920..3f9256f 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,6 +1,13 @@ #!/usr/bin/env node /** - * Validation harness for the dspack specification repository. + * Validation harness CLI for the dspack specification repository. + * + * Every check lives in lib/validate.mjs (pure functions, schemas injected); + * this file is the filesystem-and-process front-end over that one + * implementation — a distribution of the harness, never a fork + * (rfc/dx3-bootstrap-design §4). Programmatic consumers import + * "@aestheticfunction/dspack-spec/lib/validate.mjs" and get the identical + * checks, wording included. * * Default mode (`npm run validate`): * 1. schema-compile — every schema in schema/ compiles as a draft 2020-12 @@ -9,310 +16,46 @@ * 2. examples — every examples/*.dspack.json validates against the schema * matching its declared `dspack` version. * 3. back-compat — for v0.3+ documents, the document with that version's - * additive blocks removed (intents/rules/examples; for 0.4 also the - * categories registry and membership fields) still validates against - * its own schema (the "v0.2 shape + a newer dspack version is valid" - * guarantee, per each version's strictly-additive promise). - * 4. governance consistency — for v0.3+ documents: unique IDs, intent - * references resolve, rule component references resolve, rule example - * references resolve, and every examples[].surface passes: - * S1 — the generic dspack surface schema, and - * S2 — the contract vocabulary (component/sub-component IDs, prop - * names, enum prop values, declared slot names). - * S2 here checks exactly what the v0.3 spec defines for the gate; it - * does not check acceptsChildren semantics or non-enum prop types. - * 5. categories consistency — for v0.4 documents: category ids referenced - * by component/sub-component metadata and by rule forbiddenCategories - * resolve in the top-level categories registry. + * additive blocks removed still validates against its own schema (the + * "v0.2 shape + a newer dspack version is valid" guarantee). + * 4. governance consistency — for v0.3+ documents: unique IDs, reference + * resolution, and S1/S2 over every examples[].surface. + * 5. categories consistency — for v0.4 documents. * * Negative mode (`npm run validate -- --fixtures negative`): * Runs the same full validation over fixtures/negative/*.dspack.json and - * exits 0 iff every fixture is rejected (each must fail schema validation - * or a consistency check). A fixture that unexpectedly passes is a harness - * defect and fails the run. + * exits 0 iff every fixture is rejected. A fixture that unexpectedly + * passes is a harness defect and fails the run. * * File mode (`npm run validate -- --file [...]`, also the - * `dspack-validate` bin): - * Runs the identical validation (schema, back-compat, governance - * consistency, categories) over the named document(s) instead of - * examples/. This is the standalone surface from rfc/dx3-bootstrap-design - * §4 — a front-end over the same function, never a second validator. + * `dspack-validate` bin): the identical validation over the named + * document(s) instead of examples/. */ import { readFileSync, readdirSync } from "node:fs"; import { join, dirname, basename } from "node:path"; import { fileURLToPath } from "node:url"; -import Ajv2020 from "ajv/dist/2020.js"; -import addFormats from "ajv-formats"; +import { + GOVERNANCE_VERSIONS, + SURFACE_SCHEMA, + compileSchemaSet, + stripAdditiveBlocks, + validateDocument, +} from "../lib/validate.mjs"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const SCHEMA_DIR = join(ROOT, "schema"); const EXAMPLES_DIR = join(ROOT, "examples"); const NEGATIVE_DIR = join(ROOT, "fixtures", "negative"); -const DSPACK_SCHEMAS = { - "0.1": "dspack.v0.1.schema.json", - "0.2": "dspack.v0.2.schema.json", - "0.3": "dspack.v0.3.schema.json", - "0.4": "dspack.v0.4.schema.json", -}; -/** Versions with governance blocks (and, from 0.4, categories) to consistency-check. */ -const GOVERNANCE_VERSIONS = new Set(["0.3", "0.4"]); -const SURFACE_SCHEMA = "dspack.surface.v0_1.schema.json"; - -function newAjv() { - const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: true }); - addFormats(ajv); - return ajv; -} - const loadJson = (path) => JSON.parse(readFileSync(path, "utf8")); -const fmtErr = (e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim(); -/** Compile every schema; returns { validators, failures }. */ +/** Read every schema/*.schema.json and compile through the lib. */ function compileSchemas() { - const validators = new Map(); - const failures = []; - const files = readdirSync(SCHEMA_DIR).filter((f) => f.endsWith(".schema.json")); - for (const file of files) { - try { - validators.set(file, newAjv().compile(loadJson(join(SCHEMA_DIR, file)))); - } catch (e) { - failures.push(`${file}: ${e instanceof Error ? e.message : String(e)}`); - } - } - return { validators, failures }; -} - -/** - * Build the vocabulary of a contract: - * - components: Map componentId -> { props: Map propName -> descriptor, slots: Set slotName } - * - subComponents: Map subComponentId -> parent componentId - * - duplicateSubIds: sub-component IDs declared by more than one component. - * Duplicates would make S2 checks and rule reference resolution depend on - * object iteration order, so callers MUST surface them as consistency - * errors (spec §5: sub-component IDs must be unique document-wide). - */ -function buildVocabulary(doc) { - const components = new Map(); - const subComponents = new Map(); - const duplicateSubIds = new Set(); - for (const [id, entry] of Object.entries(doc.components ?? {})) { - const props = new Map(Object.entries(entry.props ?? {})); - const slots = new Set(); - for (const sub of entry.composition?.subComponents ?? []) { - if (sub.id) { - if (subComponents.has(sub.id) && subComponents.get(sub.id) !== id) duplicateSubIds.add(sub.id); - subComponents.set(sub.id, id); - } - if (sub.slot) slots.add(sub.slot); - } - components.set(id, { props, slots }); - } - return { components, subComponents, duplicateSubIds }; -} - -/** Allowed values for an enum prop descriptor (bare values or valueDescriptor objects). */ -function enumValues(descriptor) { - if (descriptor.type !== "enum" || !Array.isArray(descriptor.values)) return null; - return descriptor.values.map((v) => (v && typeof v === "object" ? v.value : v)); -} - -/** Gate S2: walk a surface tree against a contract vocabulary. Returns error strings. */ -function checkVocabulary(surface, vocab) { - const errors = []; - const walk = (node, path) => { - if (!node || typeof node !== "object") return; - const cid = node.component; - const isComponent = vocab.components.has(cid); - const isSub = vocab.subComponents.has(cid); - if (!isComponent && !isSub) { - errors.push(`${path}: component '${cid}' is not a component or sub-component of the contract`); - } - if (node.props && Object.keys(node.props).length > 0) { - if (isSub) { - errors.push(`${path}: sub-component '${cid}' does not declare props in this contract`); - } else if (isComponent) { - const { props } = vocab.components.get(cid); - for (const [name, value] of Object.entries(node.props)) { - const descriptor = props.get(name); - if (!descriptor) { - errors.push(`${path}: prop '${name}' is not declared on component '${cid}'`); - continue; - } - const allowed = enumValues(descriptor); - if (allowed && !allowed.includes(value)) { - errors.push( - `${path}: prop '${name}' on '${cid}' has value ${JSON.stringify(value)}; allowed: ${allowed.map((v) => JSON.stringify(v)).join(", ")}`, - ); - } - } - } - } - if (node.slots) { - const slots = isComponent ? vocab.components.get(cid).slots : new Set(); - for (const [slotName, children] of Object.entries(node.slots)) { - if (!slots.has(slotName)) { - errors.push(`${path}: slot '${slotName}' is not declared on component '${cid}'`); - } - children.forEach((child, i) => walk(child, `${path}.slots.${slotName}[${i}]`)); - } - } - (node.children ?? []).forEach((child, i) => walk(child, `${path}.children[${i}]`)); - }; - walk(surface.root, "$.root"); - return errors; -} - -/** Every component/sub-component reference inside a rule, for resolution checks. */ -function ruleComponentRefs(rule) { - const refs = []; - const push = (kind, ids) => { - for (const id of ids ?? []) refs.push({ kind, id }); - }; - push("require", rule.require); - push("forbid", rule.forbid); - // required-props (v0.4) is the one type whose `component` accepts a - // sub-component id (spec v0.4 §4.1); `within` accepts either kind. - if (rule.component) { - refs.push({ kind: rule.type === "required-props" ? "componentOrSub" : "component", id: rule.component }); - } - if (rule.within) refs.push({ kind: "componentOrSub", id: rule.within }); - push("forbiddenDescendants", rule.forbiddenDescendants); - push("requiredSubComponents", (rule.requiredSubComponents ?? []).map((s) => s.id)); - // `on` entries exist only on required-composition/forbidden-composition - // requiredProps/forbiddenProps; required-props (v0.4) entries have no `on`. - if (rule.type !== "required-props") { - push("on", (rule.requiredProps ?? []).map((p) => p.on).filter(Boolean)); + const schemas = {}; + for (const file of readdirSync(SCHEMA_DIR).filter((f) => f.endsWith(".schema.json"))) { + schemas[file] = loadJson(join(SCHEMA_DIR, file)); } - push("on", (rule.forbiddenProps ?? []).map((p) => p.on).filter(Boolean)); - return refs; -} - -/** Category consistency checks for a v0.4 document. Returns error strings. */ -function checkCategories(doc) { - const errors = []; - const registry = new Set(Object.keys(doc.categories ?? {})); - const checkMember = (where, ids) => { - for (const id of ids ?? []) { - if (!registry.has(id)) errors.push(`${where}: category '${id}' is not registered in categories`); - } - }; - for (const [cid, entry] of Object.entries(doc.components ?? {})) { - checkMember(`components.${cid}`, entry.categories); - for (const sub of entry.composition?.subComponents ?? []) { - checkMember(`components.${cid} sub-component '${sub.id}'`, sub.categories); - } - } - for (const rule of doc.rules ?? []) { - checkMember(rule.id ?? "(rule without id)", rule.forbiddenCategories); - } - return errors; -} - -/** Governance consistency checks for a v0.3+ document. Returns error strings. */ -function checkGovernance(doc, validateSurface) { - const errors = []; - // Spec §5 scopes governance consistency (incl. sub-component id uniqueness) - // to contracts that USE governance blocks — a pure v0.2-shaped document with - // "dspack": "0.3" must keep the strictly-additive guarantee. - if (!doc.intents && !doc.rules && !doc.examples) return errors; - const vocab = buildVocabulary(doc); - // Fail loudly on ambiguous vocabulary before any check that depends on it. - for (const id of vocab.duplicateSubIds) { - const parents = Object.entries(doc.components ?? {}) - .filter(([, entry]) => (entry.composition?.subComponents ?? []).some((s) => s.id === id)) - .map(([componentId]) => componentId); - errors.push( - `sub-component id '${id}' is declared by multiple components (${parents.join(", ")}); ` + - `sub-component ids must be unique document-wide for deterministic S2 and rule resolution`, - ); - } - const intents = new Set((doc.intents ?? []).map((i) => i.id)); - const exampleIds = new Set((doc.examples ?? []).map((e) => e.id)); - - const seen = new Set(); - for (const [block, key] of [ - ["intents", "id"], - ["rules", "id"], - ["examples", "id"], - ]) { - for (const entry of doc[block] ?? []) { - const tag = `${block}:${entry[key]}`; - if (seen.has(tag)) errors.push(`duplicate ${block} id '${entry[key]}'`); - seen.add(tag); - } - } - - for (const rule of doc.rules ?? []) { - for (const intent of rule.appliesTo?.intents ?? []) { - if (!intents.has(intent)) errors.push(`${rule.id}: appliesTo intent '${intent}' is not registered in intents[]`); - } - for (const { kind, id } of ruleComponentRefs(rule)) { - const resolvesToComponent = vocab.components.has(id); - const resolvesToSub = vocab.subComponents.has(id); - // `requiredSubComponents` entries match descendant NODES by component id - // at lint time (spec §5), so the id may be declared as a top-level - // component or as a composition sub-component. Resolution here only - // guards the vocabulary; satisfaction (matching descendants beneath each - // governed node) is the S3 gate's concern, not this harness's. - // `on` remains sub-component-only (spec §5: "the sub-component id `on`"). - const ok = - kind === "on" - ? resolvesToSub - : kind === "component" - ? resolvesToComponent - : resolvesToComponent || resolvesToSub; // requiredSubComponents, componentOrSub, require, forbid, forbiddenDescendants - if (!ok) errors.push(`${rule.id}: ${kind} reference '${id}' does not resolve in the contract`); - } - for (const ex of rule.examples ?? []) { - if (!exampleIds.has(ex)) errors.push(`${rule.id}: example reference '${ex}' does not resolve`); - } - } - - for (const example of doc.examples ?? []) { - const where = example.id ?? "(example without id)"; - if (example.intent && !intents.has(example.intent)) { - errors.push(`${where}: intent '${example.intent}' is not registered in intents[]`); - } - const surface = example.surface; - if (!surface) continue; - // S1 — generic surface schema. - if (!validateSurface(surface)) { - for (const e of validateSurface.errors ?? []) errors.push(`${where}: S1 ${fmtErr(e)}`); - continue; // vocabulary walk needs a well-formed tree - } - if (surface.intent !== example.intent) { - errors.push(`${where}: surface.intent '${surface.intent}' does not match example intent '${example.intent}'`); - } - if (surface.system !== doc.name) { - errors.push(`${where}: surface.system '${surface.system}' does not match contract name '${doc.name}'`); - } - // S2 — contract vocabulary. - for (const e of checkVocabulary(surface, vocab)) errors.push(`${where}: S2 ${e}`); - } - - return errors; -} - -/** Fully validate one dspack document. Returns error strings (empty = valid). */ -function validateDocument(doc, validators) { - const errors = []; - const version = doc?.dspack; - const schemaFile = DSPACK_SCHEMAS[version]; - if (!schemaFile) return [`unknown or missing dspack version: ${JSON.stringify(version)}`]; - const validate = validators.get(schemaFile); - if (!validate) return [`schema ${schemaFile} did not compile`]; - if (!validate(doc)) { - for (const e of validate.errors ?? []) errors.push(`schema ${fmtErr(e)}`); - return errors; - } - if (GOVERNANCE_VERSIONS.has(version)) { - errors.push(...checkGovernance(doc, validators.get(SURFACE_SCHEMA))); - } - if (version === "0.4") { - errors.push(...checkCategories(doc)); - } - return errors; + return compileSchemaSet(schemas); } function listDocs(dir) { @@ -384,34 +127,10 @@ function main() { const doc = loadJson(path); const errors = validateDocument(doc, validators); - // Back-compat guarantee: a v0.3+ document minus that version's additive - // blocks stays valid — i.e. the pre-governance core shape is untouched. - // For 0.4 that means also stripping categories (the registry AND the - // membership fields), so the check really exercises the "v0.2 shape + a - // newer dspack version is valid" guarantee rather than passing v0.4 - // features through. + // Back-compat guarantee (lib strips the version's additive blocks; for + // 0.4 that includes categories — registry and membership fields). if (GOVERNANCE_VERSIONS.has(doc?.dspack) && errors.length === 0) { - const stripped = { ...doc }; - delete stripped.intents; - delete stripped.rules; - delete stripped.examples; - if (doc.dspack === "0.4") { - delete stripped.categories; - stripped.components = Object.fromEntries( - Object.entries(doc.components ?? {}).map(([id, entry]) => { - const e = { ...entry }; - delete e.categories; - if (e.composition?.subComponents) { - e.composition = { - ...e.composition, - subComponents: e.composition.subComponents.map(({ categories, ...sub }) => sub), - }; - } - return [id, e]; - }), - ); - } - const strippedErrors = validateDocument(stripped, validators); + const strippedErrors = validateDocument(stripAdditiveBlocks(doc), validators); for (const e of strippedErrors) errors.push(`back-compat (version's additive blocks removed): ${e}`); } From 6fa57a701cb8187eced5144d3af67fe567e80acf Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Mon, 3 Aug 2026 17:38:10 -0400 Subject: [PATCH 2/2] fix: declaration file is .d.mts (TS resolution for .mjs imports) Co-Authored-By: Claude Fable 5 --- README.md | 2 +- lib/{validate.d.ts => validate.d.mts} | 0 scripts/validate.mjs | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename lib/{validate.d.ts => validate.d.mts} (100%) mode change 100644 => 100755 scripts/validate.mjs diff --git a/README.md b/README.md index 0952b8e..7e693b3 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ const report = documentReport(doc, validators); // categories, and S1/S2 over the contract's own examples. ``` -Types ship alongside (`lib/validate.d.ts`). CI's `check:lib` gate keeps the +Types ship alongside (`lib/validate.d.mts`). CI's `check:lib` gate keeps the lib pure (ajv-only imports) and replays the full example + negative-fixture corpus through the import surface. diff --git a/lib/validate.d.ts b/lib/validate.d.mts similarity index 100% rename from lib/validate.d.ts rename to lib/validate.d.mts diff --git a/scripts/validate.mjs b/scripts/validate.mjs old mode 100644 new mode 100755