Add Format Specification section 21 (Scenario Model 0.1) and the runtime that realizes it: the scenario director, seven trigger classes, timelines with repeats and branches, admission and concurrency control, nested ownership with bounded cleanup, and the production GC5 resource counters. Exhibit E provides a reproducible forty-minute long-scenario reference and scenario-challenge.xzbt covers the PRD 131 cases. A standalone acceptance/soak page is built by tools/build-scenario-acceptance.mjs. 252 automated checks pass. The two-hour real-duration development soak required by the GC6 schedule remains pending. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
741 lines
26 KiB
JavaScript
741 lines
26 KiB
JavaScript
#!/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';
|
|
import { validateCadenceSubsystem } from '../src/runtime/cadence-validation.js';
|
|
import { validateScenarioSubsystem } from '../src/runtime/scenario-validation.js';
|
|
import { matchVisualTarget } from '../src/runtime/visual-contract.js';
|
|
import { validateVisualSubsystem } from '../src/runtime/visual-validation.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 })
|
|
});
|
|
|
|
// 8. Visual subsystem (Format Specification sections 17-19)
|
|
validateVisualSubsystem(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 })
|
|
});
|
|
|
|
// 9. Cadence and Event subsystems (Format Specification section 20)
|
|
validateCadenceSubsystem(this.doc, this.errors, {
|
|
validateValueSpec: (unusedDocument, spec, path, unusedErrors, scope) => this.validateValueSpec(spec, path, scope),
|
|
validateCondition: (unusedDocument, cond, path, unusedErrors) => this.validateConditionSpec(cond, path),
|
|
pushError: (errors, code, path, message) => errors.push({ code, path, message })
|
|
});
|
|
|
|
validateScenarioSubsystem(this.doc, this.errors, {
|
|
validateValueSpec: (unusedDocument, spec, path, unusedErrors, scope) => this.validateValueSpec(spec, path, scope),
|
|
validateCondition: (unusedDocument, cond, path) => this.validateConditionSpec(cond, path)
|
|
});
|
|
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') {
|
|
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;
|
|
}
|
|
|
|
const visual = matchVisualTarget(this.doc, refPath);
|
|
if (visual) {
|
|
if (visual.reason === 'reference') {
|
|
this.addError('ERR_INVALID_REFERENCE', location, `Binding target '${refPath}' does not resolve.`);
|
|
} else if (visual.reason === 'unsupported') {
|
|
this.addError('ERR_UNSUPPORTED_TARGET', location, `Binding target '${refPath}' is not exposed. Per-object visual properties are not externally addressable in 0.1.`);
|
|
}
|
|
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';
|
|
const visual = matchVisualTarget(this.doc, refPath);
|
|
if (visual && !visual.reason) return visual.spec?.type || '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] <path-to-exhibit.xzbt ...>');
|
|
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);
|
|
}
|