#!/usr/bin/env node /** * XZBT 0.1 Structural & Semantic Exhibit Validator * No external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.4). * Audio subsystem checks (sections 14-15) delegate to the shared runtime module so the tool * and the runtime can never diverge. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { validateAudioSubsystem } from '../src/runtime/audio-graph.js'; const ID_REGEX = /^[a-z][a-z0-9_-]*$/; const REF_PATH_REGEX = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/; const DURATION_REGEX = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/; const RUNTIME_SIGNAL_TYPES = new Map([ ['signals.time.elapsed', 'number'], ['signals.time.delta', 'number'], ['signals.audio.low', 'number'], ['signals.audio.mid', 'number'], ['signals.audio.high', 'number'], ['signals.audio.energy', 'number'], ['signals.pointer.x', 'number'], ['signals.pointer.y', 'number'], ['signals.viewport.width', 'number'], ['signals.viewport.height', 'number'], ['signals.scenario.active', 'boolean'] ]); const ALLOWED_ROOT_KEYS = new Set([ 'xzbt', 'meta', 'runtime', 'parameters', 'state', 'signals', 'ui', 'components', 'visuals', 'audio', 'sounds', 'cadence', 'modulators', 'bindings', 'events', 'scenarios' ]); const ALLOWED_META_KEYS = new Set([ 'id', 'name', 'version', 'author', 'description', 'license', 'tags' ]); const ALLOWED_PARAM_KEYS = new Set([ 'type', 'default', 'min', 'max', 'step', 'values', 'label', 'unit' ]); const ALLOWED_STATE_KEYS = new Set([ 'type', 'initial', 'min', 'max' ]); const ALLOWED_BINDING_KEYS = new Set([ 'source', 'target', 'scale', 'offset', 'clamp', 'smoothing', 'when' ]); const OPERATOR_ARITY = { abs: 1, negate: 1, round: 1, floor: 1, ceil: 1, add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2, clamp: 3, lerp: 3 }; const COMPARISON_OPS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']); export class ExhibitValidator { constructor(doc, filename = 'document.xzbt') { this.doc = doc; this.filename = filename; this.errors = []; this.warnings = []; } addError(code, path, message) { this.errors.push({ code, path, message }); } validate() { if (typeof this.doc !== 'object' || this.doc === null || Array.isArray(this.doc)) { this.addError('ERR_SCHEMA_VALIDATION', '$', 'Top-level document must be a JSON object.'); return this.getResult(); } // 1. Format Version if (this.doc.xzbt !== '0.1') { this.addError( 'ERR_UNSUPPORTED_VERSION', '$.xzbt', `Unsupported XZBT format version: '${this.doc.xzbt}'. Expected '0.1'.` ); } // 2. Unknown Root Keys for (const key of Object.keys(this.doc)) { if (!ALLOWED_ROOT_KEYS.has(key)) { this.addError( 'ERR_UNKNOWN_FIELD', `$.${key}`, `Unrecognized top-level field: '${key}'.` ); } } // 3. Metadata this.validateMeta(); // 4. Parameters this.validateParameters(); // 5. State this.validateState(); // 6. Bindings & Cycle Detection this.validateBindings(); // 7. Audio subsystem (Format Specification sections 14-15) validateAudioSubsystem(this.doc, this.errors, { validateValueSpec: (unusedDocument, spec, path, unusedErrors, scope) => this.validateValueSpec(spec, path, scope), pushError: (errors, code, path, message) => errors.push({ code, path, message }) }); return this.getResult(); } validateMeta() { if (!this.doc.meta || typeof this.doc.meta !== 'object' || Array.isArray(this.doc.meta)) { this.addError('ERR_SCHEMA_VALIDATION', '$.meta', 'Missing or invalid required object: meta.'); return; } for (const key of Object.keys(this.doc.meta)) { if (!ALLOWED_META_KEYS.has(key)) { this.addError('ERR_UNKNOWN_FIELD', `$.meta.${key}`, `Unrecognized field in meta: '${key}'.`); } } const { id, name } = this.doc.meta; if (typeof id !== 'string' || !ID_REGEX.test(id)) { this.addError( 'ERR_INVALID_ID', '$.meta.id', `Exhibit id must match ^[a-z][a-z0-9_-]*$: got '${id}'.` ); } else if (id.length > 64) { this.addError('ERR_INVALID_ID', '$.meta.id', 'Exhibit id exceeds 64 characters limit.'); } if (typeof name !== 'string' || name.trim().length === 0) { this.addError('ERR_SCHEMA_VALIDATION', '$.meta.name', 'Exhibit meta.name must be a non-empty string.'); } } validateParameters() { if (!this.doc.parameters) return; if (typeof this.doc.parameters !== 'object' || Array.isArray(this.doc.parameters)) { this.addError('ERR_SCHEMA_VALIDATION', '$.parameters', 'parameters must be an object.'); return; } for (const [id, spec] of Object.entries(this.doc.parameters)) { const path = `$.parameters.${id}`; if (!ID_REGEX.test(id)) { this.addError('ERR_INVALID_ID', path, `Parameter ID '${id}' must match ^[a-z][a-z0-9_-]*$.`); } if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) { this.addError('ERR_SCHEMA_VALIDATION', path, 'ParameterSpec must be an object.'); continue; } for (const key of Object.keys(spec)) { if (!ALLOWED_PARAM_KEYS.has(key)) { this.addError('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unrecognized field in ParameterSpec: '${key}'.`); } } const validTypes = ['number', 'integer', 'boolean', 'string', 'color', 'enum']; if (!validTypes.includes(spec.type)) { this.addError('ERR_TYPE_MISMATCH', `${path}.type`, `Invalid parameter type: '${spec.type}'.`); } if (spec.default === undefined) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.default`, `Missing required field: default.`); } else { this.checkTypeMatches(spec.type, spec.default, `${path}.default`); } if (spec.type === 'number' || spec.type === 'integer') { if (spec.min !== undefined && spec.max !== undefined && spec.min > spec.max) { this.addError('ERR_OUT_OF_BOUNDS', `${path}.min`, `min (${spec.min}) cannot be greater than max (${spec.max}).`); } if (typeof spec.default === 'number') { if (spec.min !== undefined && spec.default < spec.min) { this.addError('ERR_OUT_OF_BOUNDS', `${path}.default`, `default (${spec.default}) is less than min (${spec.min}).`); } if (spec.max !== undefined && spec.default > spec.max) { this.addError('ERR_OUT_OF_BOUNDS', `${path}.default`, `default (${spec.default}) is greater than max (${spec.max}).`); } } } if (spec.type === 'enum') { if (!Array.isArray(spec.values) || spec.values.length === 0) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.values`, `Enum parameter must define a non-empty values array.`); } else if (!spec.values.includes(spec.default)) { this.addError('ERR_OUT_OF_BOUNDS', `${path}.default`, `Enum default '${spec.default}' is not in values list.`); } } } } validateState() { if (!this.doc.state) return; if (typeof this.doc.state !== 'object' || Array.isArray(this.doc.state)) { this.addError('ERR_SCHEMA_VALIDATION', '$.state', 'state must be an object.'); return; } for (const [id, spec] of Object.entries(this.doc.state)) { const path = `$.state.${id}`; if (!ID_REGEX.test(id)) { this.addError('ERR_INVALID_ID', path, `State ID '${id}' must match ^[a-z][a-z0-9_-]*$.`); } if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) { this.addError('ERR_SCHEMA_VALIDATION', path, 'StateSpec must be an object.'); continue; } for (const key of Object.keys(spec)) { if (!ALLOWED_STATE_KEYS.has(key)) { this.addError('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unrecognized field in StateSpec: '${key}'.`); } } const validTypes = ['number', 'integer', 'boolean', 'string']; if (!validTypes.includes(spec.type)) { this.addError('ERR_TYPE_MISMATCH', `${path}.type`, `Invalid state type: '${spec.type}'.`); } if (spec.initial === undefined) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.initial`, `Missing required field: initial.`); } else { this.checkTypeMatches(spec.type, spec.initial, `${path}.initial`); } } } validateValueSpec(valueSpec, path, scope = null) { if (typeof valueSpec === 'number') { if (!Number.isFinite(valueSpec)) { this.addError('ERR_TYPE_MISMATCH', path, `Number must be finite; got ${valueSpec}.`); } return; } if (typeof valueSpec === 'boolean' || typeof valueSpec === 'string') { return; } if (typeof valueSpec !== 'object' || valueSpec === null || Array.isArray(valueSpec)) { this.addError('ERR_SCHEMA_VALIDATION', path, `Invalid ValueSpec format.`); return; } // Reference if ('ref' in valueSpec) { if (typeof valueSpec.ref !== 'string' || !REF_PATH_REGEX.test(valueSpec.ref)) { this.addError('ERR_INVALID_REFERENCE', `${path}.ref`, `Invalid reference path: '${valueSpec.ref}'.`); } else if (valueSpec.ref.startsWith('inputs.')) { // Component-graph-scoped namespace (Format Specification 15.15). const name = valueSpec.ref.slice('inputs.'.length); if (!scope?.componentParameters?.has(name)) { this.addError('ERR_INVALID_REFERENCE', `${path}.ref`, `Component input '${name}' is not declared here.`); } } else { this.resolveReference(valueSpec.ref, `${path}.ref`); } return; } // Random Range if ('random' in valueSpec) { const { random } = valueSpec; if (typeof random !== 'object' || random === null) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.random`, 'random must be an object.'); return; } if (typeof random.min !== 'number' || typeof random.max !== 'number') { this.addError('ERR_SCHEMA_VALIDATION', `${path}.random`, 'random requires finite numeric min and max.'); } else if (random.min > random.max) { this.addError('ERR_OUT_OF_BOUNDS', `${path}.random`, `random min (${random.min}) cannot exceed max (${random.max}).`); } return; } // Weighted Choice if ('choose' in valueSpec) { if (!Array.isArray(valueSpec.choose) || valueSpec.choose.length === 0) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.choose`, 'choose must be a non-empty array.'); return; } for (let i = 0; i < valueSpec.choose.length; i++) { const item = valueSpec.choose[i]; if (typeof item !== 'object' || item === null || item.value === undefined || typeof item.weight !== 'number' || item.weight <= 0) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.choose[${i}]`, 'choose item requires value and weight > 0.'); } else { this.validateValueSpec(item.value, `${path}.choose[${i}].value`, scope); } } return; } // Calculation Operator if ('op' in valueSpec) { const { op, args } = valueSpec; if (!OPERATOR_ARITY[op]) { this.addError('ERR_INVALID_OPERATOR', `${path}.op`, `Unrecognized operator: '${op}'.`); return; } const expectedArity = OPERATOR_ARITY[op]; if (!Array.isArray(args)) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.args`, `args must be an array for op '${op}'.`); return; } if (args.length !== expectedArity) { this.addError( 'ERR_INVALID_ARITY', `${path}.args`, `Operator '${op}' requires exactly ${expectedArity} argument(s); got ${args.length}.` ); } args.forEach((arg, index) => { this.validateValueSpec(arg, `${path}.args[${index}]`, scope); }); return; } this.addError('ERR_SCHEMA_VALIDATION', path, `Unrecognized ValueSpec structure.`); } validateConditionSpec(cond, path) { if (typeof cond !== 'object' || cond === null || Array.isArray(cond)) { this.addError('ERR_SCHEMA_VALIDATION', path, 'ConditionSpec must be an object.'); return; } if ('op' in cond) { if (!COMPARISON_OPS.has(cond.op)) { this.addError('ERR_INVALID_OPERATOR', `${path}.op`, `Invalid comparison operator: '${cond.op}'.`); } if (cond.left === undefined || cond.right === undefined) { this.addError('ERR_SCHEMA_VALIDATION', path, `Comparison condition requires 'left' and 'right'.`); } else { this.validateValueSpec(cond.left, `${path}.left`); this.validateValueSpec(cond.right, `${path}.right`); } return; } if ('and' in cond) { if (!Array.isArray(cond.and) || cond.and.length === 0) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.and`, `'and' condition requires non-empty array.`); } else { cond.and.forEach((c, idx) => this.validateConditionSpec(c, `${path}.and[${idx}]`)); } return; } if ('or' in cond) { if (!Array.isArray(cond.or) || cond.or.length === 0) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.or`, `'or' condition requires non-empty array.`); } else { cond.or.forEach((c, idx) => this.validateConditionSpec(c, `${path}.or[${idx}]`)); } return; } if ('not' in cond) { this.validateConditionSpec(cond.not, `${path}.not`); return; } this.addError('ERR_SCHEMA_VALIDATION', path, `Unrecognized ConditionSpec structure.`); } validateBindings() { if (!this.doc.bindings) return; if (!Array.isArray(this.doc.bindings)) { this.addError('ERR_SCHEMA_VALIDATION', '$.bindings', 'bindings must be an array.'); return; } const graph = new Map(); // target -> [sources] const targetWriters = new Map(); this.doc.bindings.forEach((binding, idx) => { const path = `$.bindings[${idx}]`; if (typeof binding !== 'object' || binding === null || Array.isArray(binding)) { this.addError('ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.'); return; } for (const key of Object.keys(binding)) { if (!ALLOWED_BINDING_KEYS.has(key)) { this.addError('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unrecognized field in BindingSpec: '${key}'.`); } } if (typeof binding.source !== 'string' || !REF_PATH_REGEX.test(binding.source)) { this.addError('ERR_INVALID_REFERENCE', `${path}.source`, `Invalid source reference: '${binding.source}'.`); } else { this.resolveReference(binding.source, `${path}.source`); } if (typeof binding.target !== 'string' || !REF_PATH_REGEX.test(binding.target)) { this.addError('ERR_INVALID_REFERENCE', `${path}.target`, `Invalid target reference: '${binding.target}'.`); } else { this.validateBindingTarget(binding.target, `${path}.target`); } if (binding.scale !== undefined && (typeof binding.scale !== 'number' || !Number.isFinite(binding.scale))) { this.addError('ERR_TYPE_MISMATCH', `${path}.scale`, 'Binding scale must be a finite number.'); } if (binding.offset !== undefined && (typeof binding.offset !== 'number' || !Number.isFinite(binding.offset))) { this.addError('ERR_TYPE_MISMATCH', `${path}.offset`, 'Binding offset must be a finite number.'); } if (binding.clamp !== undefined) { if ( !Array.isArray(binding.clamp) || binding.clamp.length !== 2 || !binding.clamp.every((value) => typeof value === 'number' && Number.isFinite(value)) ) { this.addError('ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'Binding clamp must be two finite numbers.'); } else if (binding.clamp[0] > binding.clamp[1]) { this.addError('ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'Binding clamp minimum cannot exceed maximum.'); } } if (binding.smoothing !== undefined) { if (typeof binding.smoothing !== 'string' || !DURATION_REGEX.test(binding.smoothing)) { this.addError('ERR_INVALID_DURATION', `${path}.smoothing`, `Invalid duration: '${binding.smoothing}'.`); } } if (binding.when !== undefined) { this.validateConditionSpec(binding.when, `${path}.when`); } const sourceType = this.getReferenceType(binding.source); const targetType = this.getReferenceType(binding.target); if (sourceType && targetType) { const usesNumericTransform = binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined || (binding.smoothing !== undefined && binding.smoothing !== '0ms'); const sourceNumeric = sourceType === 'number' || sourceType === 'integer'; const targetNumeric = targetType === 'number' || targetType === 'integer'; if (usesNumericTransform && (!sourceNumeric || !targetNumeric)) { this.addError('ERR_TYPE_MISMATCH', path, 'Binding transforms and smoothing require numeric source and target types.'); } else if (!usesNumericTransform && sourceType !== targetType && !(sourceNumeric && targetNumeric)) { this.addError('ERR_TYPE_MISMATCH', path, `Binding source type '${sourceType}' does not match target type '${targetType}'.`); } } if (typeof binding.source === 'string' && typeof binding.target === 'string') { if (!graph.has(binding.target)) graph.set(binding.target, []); graph.get(binding.target).push(binding.source); const priorWriter = targetWriters.get(binding.target); if (priorWriter !== undefined) { this.addError( 'ERR_CONFLICTING_BINDING', `${path}.target`, `Binding target '${binding.target}' is already written by $.bindings[${priorWriter}].` ); } else { targetWriters.set(binding.target, idx); } } }); // Cycle detection using DFS const visited = new Set(); const visiting = new Set(); const checkCycle = (node, chain) => { if (visiting.has(node)) { this.addError( 'ERR_CYCLIC_DEPENDENCY', '$.bindings', `Dependency cycle detected: ${chain.concat(node).join(' -> ')}` ); return; } if (visited.has(node)) return; visiting.add(node); const dependencies = graph.get(node) || []; for (const dep of dependencies) { checkCycle(dep, chain.concat(node)); } visiting.delete(node); visited.add(node); }; for (const node of graph.keys()) { if (!visited.has(node)) { checkCycle(node, []); } } } validateBindingTarget(refPath, location) { const parts = refPath.split('.'); const namespace = parts[0]; if (namespace === 'parameters' || namespace === 'state') { if (parts.length !== 2) { this.addError('ERR_UNSUPPORTED_TARGET', location, `Target '${refPath}' is not a scalar ${namespace} target.`); return; } this.resolveReference(refPath, location); return; } if (namespace === 'audio' && parts.length === 4 && parts[1] === 'buses' && parts[3] === 'gain') { const busId = parts[2]; if (!this.doc.audio?.buses?.[busId]) { this.addError('ERR_INVALID_REFERENCE', location, `Target '${refPath}' refers to a missing audio bus '${busId}'.`); } return; } this.addError('ERR_UNSUPPORTED_TARGET', location, `Target '${refPath}' is not exposed by the shared 0.1 target registry.`); } getReferenceType(refPath) { if (typeof refPath !== 'string') return null; const parts = refPath.split('.'); if (parts[0] === 'parameters' && parts.length === 2) return this.doc.parameters?.[parts[1]]?.type || null; if (parts[0] === 'state' && parts.length === 2) return this.doc.state?.[parts[1]]?.type || null; if (parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain') return 'number'; if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES.get(refPath) || null; if (parts[0] === 'modulators') return 'number'; return null; } resolveReference(refPath, location) { const parts = refPath.split('.'); const namespace = parts[0]; if (namespace === 'parameters') { const paramId = parts[1]; if (!this.doc.parameters || !this.doc.parameters[paramId]) { this.addError( 'ERR_INVALID_REFERENCE', location, `Reference '${refPath}' refers to non-existent parameter '${paramId}'.` ); } } else if (namespace === 'state') { const stateId = parts[1]; if (!this.doc.state || !this.doc.state[stateId]) { this.addError( 'ERR_INVALID_REFERENCE', location, `Reference '${refPath}' refers to non-existent state variable '${stateId}'.` ); } } else if (namespace === 'signals') { if (!RUNTIME_SIGNAL_TYPES.has(refPath)) { this.addError('ERR_INVALID_REFERENCE', location, `Reference '${refPath}' is not a declared 0.1 runtime signal.`); } } else if (namespace === 'modulators') { const modulatorId = parts[1]; if (!this.doc.modulators || !this.doc.modulators[modulatorId]) { this.addError('ERR_INVALID_REFERENCE', location, `Reference '${refPath}' refers to non-existent modulator '${modulatorId}'.`); } } } checkTypeMatches(expectedType, value, path) { if (expectedType === 'number') { if (typeof value !== 'number' || !Number.isFinite(value)) { this.addError('ERR_TYPE_MISMATCH', path, `Expected finite number; got ${JSON.stringify(value)}.`); } } else if (expectedType === 'integer') { if (!Number.isInteger(value)) { this.addError('ERR_TYPE_MISMATCH', path, `Expected integer; got ${JSON.stringify(value)}.`); } } else if (expectedType === 'boolean') { if (typeof value !== 'boolean') { this.addError('ERR_TYPE_MISMATCH', path, `Expected boolean; got ${JSON.stringify(value)}.`); } } else if (expectedType === 'string') { if (typeof value !== 'string') { this.addError('ERR_TYPE_MISMATCH', path, `Expected string; got ${JSON.stringify(value)}.`); } } else if (expectedType === 'color') { if (typeof value !== 'string' || (!value.startsWith('#') && value.trim().length === 0)) { this.addError('ERR_TYPE_MISMATCH', path, `Expected valid color string; got ${JSON.stringify(value)}.`); } } else if (expectedType === 'enum') { if (typeof value !== 'string') { this.addError('ERR_TYPE_MISMATCH', path, `Expected enum string; got ${JSON.stringify(value)}.`); } } } getResult() { return { valid: this.errors.length === 0, filename: this.filename, errorCount: this.errors.length, errors: this.errors }; } } // CLI runner if (import.meta.url.endsWith(process.argv[1]) || process.argv[1]?.endsWith('validate-exhibit.mjs')) { const args = process.argv.slice(2); const jsonMode = args.includes('--json'); const files = args.filter((arg) => arg !== '--json'); if (files.length === 0) { console.error('Usage: node tools/validate-exhibit.mjs [--json] '); process.exit(2); } let allPassed = true; const results = []; for (const filePath of files) { try { const content = readFileSync(resolve(filePath), 'utf-8'); const doc = JSON.parse(content); const validator = new ExhibitValidator(doc, filePath); const result = validator.validate(); results.push(result); if (!result.valid) allPassed = false; } catch (err) { allPassed = false; results.push({ valid: false, filename: filePath, errorCount: 1, errors: [{ code: 'ERR_SCHEMA_VALIDATION', path: '$', message: err.message }] }); } } if (jsonMode) { console.log(JSON.stringify(results, null, 2)); } else { for (const res of results) { if (res.valid) { console.log(`[PASS] ${res.filename} (0 errors)`); } else { console.error(`[FAIL] ${res.filename} (${res.errorCount} error${res.errorCount === 1 ? '' : 's'}):`); for (const err of res.errors) { console.error(` - [${err.code}] ${err.path}: ${err.message}`); } } } } process.exit(allPassed ? 0 : 1); }