feat(phase0): complete shared contracts and milestone plan

This commit is contained in:
2026-09-05 12:08:36 -07:00
parent ee774d0307
commit 63895c4fe7
23 changed files with 1433 additions and 161 deletions
+136 -13
View File
@@ -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}'.`);
}
}
}