feat(phase0): complete shared contracts and milestone plan
This commit is contained in:
+136
-13
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* XZBT 0.1 Structural & Semantic Exhibit Validator
|
||||
* Zero external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.2).
|
||||
* Zero external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.3).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -11,6 +11,20 @@ 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',
|
||||
@@ -58,6 +72,16 @@ const ALLOWED_STATE_KEYS = new Set([
|
||||
'max'
|
||||
]);
|
||||
|
||||
const ALLOWED_BINDING_KEYS = new Set([
|
||||
'source',
|
||||
'target',
|
||||
'scale',
|
||||
'offset',
|
||||
'clamp',
|
||||
'smoothing',
|
||||
'when'
|
||||
]);
|
||||
|
||||
const OPERATOR_ARITY = {
|
||||
abs: 1,
|
||||
negate: 1,
|
||||
@@ -389,37 +413,92 @@ export class ExhibitValidator {
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof binding !== 'object' || binding === null || Array.isArray(binding)) {
|
||||
this.addError('ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof binding.from !== 'string' || !REF_PATH_REGEX.test(binding.from)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.from`, `Invalid from reference: '${binding.from}'.`);
|
||||
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.from, `${path}.from`);
|
||||
this.resolveReference(binding.source, `${path}.source`);
|
||||
}
|
||||
|
||||
if (typeof binding.to !== 'string' || !REF_PATH_REGEX.test(binding.to)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.to`, `Invalid to reference: '${binding.to}'.`);
|
||||
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.transform) {
|
||||
this.validateValueSpec(binding.transform, `${path}.transform`);
|
||||
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.smoothing) {
|
||||
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 (typeof binding.from === 'string' && typeof binding.to === 'string') {
|
||||
if (!graph.has(binding.to)) graph.set(binding.to, []);
|
||||
graph.get(binding.to).push(binding.from);
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -454,6 +533,41 @@ export class ExhibitValidator {
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
@@ -476,6 +590,15 @@ export class ExhibitValidator {
|
||||
`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}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user