feat(phase0): complete GC1 feasibility and GC2 shared format contracts
- Record user evidence for directory fallback to complete GC1 (10/10 checks passed) - Expand Format Specification 0.1 to Revision 0.2 with normative shared contracts - Author JSON Schema Draft-07 at schema/xzbt-0.1.schema.json - Implement zero-dependency semantic validator at tools/validate-exhibit.mjs - Create 12-case conformance fixture suite and automated test runner (12/12 passing) - Update implementation status, verification gates, and gap closure decisions - Add devlog entry covering stall recovery and Phase 0 current state
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Dependency-free local development server for XZBT.
|
||||
*
|
||||
* It deliberately binds only to loopback. It is not part of the distributed
|
||||
* XZBT artifact and cannot establish direct-file Phase 0 feasibility results.
|
||||
*/
|
||||
import { createReadStream, statSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, normalize, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const host = '127.0.0.1';
|
||||
const loopbackAddresses = ['127.0.0.1', '::1'];
|
||||
const port = Number.parseInt(process.env.XZBT_DEV_PORT ?? '5173', 10);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('XZBT_DEV_PORT must be an integer from 1 through 65535.');
|
||||
}
|
||||
|
||||
const mimeTypes = new Map([
|
||||
['.css', 'text/css; charset=utf-8'],
|
||||
['.html', 'text/html; charset=utf-8'],
|
||||
['.js', 'text/javascript; charset=utf-8'],
|
||||
['.json', 'application/json; charset=utf-8'],
|
||||
['.mjs', 'text/javascript; charset=utf-8'],
|
||||
['.xzbt', 'application/json; charset=utf-8'],
|
||||
]);
|
||||
|
||||
function requestedPath(requestUrl) {
|
||||
const pathname = new URL(requestUrl, `http://${host}:${port}`).pathname;
|
||||
const decodedPath = decodeURIComponent(pathname);
|
||||
if (decodedPath.includes('\0')) return null;
|
||||
if (decodedPath.split('/').some((segment) => segment.startsWith('.'))) return null;
|
||||
|
||||
const relativePath = decodedPath === '/'
|
||||
? 'prototypes/phase0/XZBT-phase0-probe.html'
|
||||
: decodedPath.replace(/^\/+/, '');
|
||||
const candidate = resolve(root, normalize(relativePath));
|
||||
return candidate === root || candidate.startsWith(`${root}${sep}`) ? candidate : null;
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
if (!['GET', 'HEAD'].includes(request.method ?? '')) {
|
||||
response.writeHead(405, { Allow: 'GET, HEAD' }).end();
|
||||
return;
|
||||
}
|
||||
|
||||
let path;
|
||||
try {
|
||||
path = requestedPath(request.url ?? '/');
|
||||
} catch {
|
||||
response.writeHead(400).end('Malformed request path.');
|
||||
return;
|
||||
}
|
||||
if (!path) {
|
||||
response.writeHead(403).end('Path is outside the XZBT workspace.');
|
||||
return;
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(path);
|
||||
} catch {
|
||||
response.writeHead(404).end('Not found.');
|
||||
return;
|
||||
}
|
||||
if (!stats.isFile()) {
|
||||
response.writeHead(404).end('Not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Length': stats.size,
|
||||
'Content-Type': mimeTypes.get(extname(path).toLowerCase()) ?? 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
if (request.method === 'HEAD') response.end();
|
||||
else createReadStream(path).pipe(response);
|
||||
}
|
||||
|
||||
for (const address of loopbackAddresses) {
|
||||
const server = createServer(handleRequest);
|
||||
server.listen(port, address, () => {
|
||||
console.log(`XZBT development server: http://${address}:${port}/`);
|
||||
});
|
||||
}
|
||||
console.log(`Serving workspace root: ${root}`);
|
||||
@@ -0,0 +1,569 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* XZBT 0.1 Structural & Semantic Exhibit Validator
|
||||
* Zero external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.2).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
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 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 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();
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
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}]`);
|
||||
});
|
||||
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]
|
||||
|
||||
this.doc.bindings.forEach((binding, idx) => {
|
||||
const path = `$.bindings[${idx}]`;
|
||||
if (typeof binding !== 'object' || binding === null) {
|
||||
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}'.`);
|
||||
} else {
|
||||
this.resolveReference(binding.from, `${path}.from`);
|
||||
}
|
||||
|
||||
if (typeof binding.to !== 'string' || !REF_PATH_REGEX.test(binding.to)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.to`, `Invalid to reference: '${binding.to}'.`);
|
||||
}
|
||||
|
||||
if (binding.transform) {
|
||||
this.validateValueSpec(binding.transform, `${path}.transform`);
|
||||
}
|
||||
|
||||
if (binding.smoothing) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user