Review Phase 3a before building on it, then implement Phase 3b. The Phase 3a draft had four blocking defects: nodes were described as a keyed map while every documented example carried an inline `id` field, so under the strict unknown-field policy each minimal example would have failed its own acceptance trace; no section said where a node lives; the `audioMaxFrequency` ceiling was declared a semantic-stage error while depending on a live AudioContext sample rate; and the sample-hold PRNG child key that section 9.3 requires was undocumented. Close all four, plus nine further gaps in noise seeding, spectral definitions, impulse decay math, Nyquist handling, missing-field codes, LFO phase origin, the units table, node-type staging, and a duplicated diagnostics table. Add Format Specification section 15 for Phase 3b: nine processing and routing node contracts, the component instance node, audio routing and modulation with an explicit modulatable-property registry, twelve graph legality rules, authoring limits, components with a component-scoped `inputs.*` namespace, sound definitions and recipes, and buses. Implement the subsystem in three modules. audio-contract.js holds the declarative node, limit, and modulation tables every consumer reads. audio-graph.js validates, expands components, and checks legality without ever opening an AudioContext. audio-engine.js resolves node fields once from the seeded stream, clamps frequencies to the live device ceiling, realizes the graph through Web Audio, and owns the runtime AudioSubsystem. Extend the schema, delegate the standalone validator's audio checks to the shared module rather than carrying a second implementation, and add a generic audio fixture. Phase 3 is not accepted. Automation precedence, the lifecycle state machine, unlock behavior, voice ceilings, and master protection are Phase 3c. No sound has been heard from any build, so the audio acceptance challenge, peak and finite-sample capture, and listening observations remain open. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011FWPdCqKaaDnP9NC3JAwh6
708 lines
24 KiB
JavaScript
708 lines
24 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';
|
|
|
|
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] <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);
|
|
}
|