Files
XZBT/tools/validate-exhibit.mjs
T
LabyricornandClaude Opus 5 1cde2f9f68 feat(scenario): implement the phase 6 scenario director
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
2026-09-06 22:54:31 +00:00

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);
}