Round 2 candidate 7a585aa9606863a0d44a26094b585160914e3d8fd5f13436b56470cd69e6d867
(Rocko, packet fafee2f22), approved by Filbert in comment 28707.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
60 lines
3.5 KiB
JavaScript
60 lines
3.5 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
|
|
|
const KINDS = ['project', 'requirement', 'decision'];
|
|
const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required',
|
|
'additionalProperties', 'items', 'minItems', 'maxItems', 'minLength', 'maxLength', 'pattern', 'format']);
|
|
const schemas = Object.fromEntries(KINDS.map(kind => [kind,
|
|
JSON.parse(readFileSync(new URL(`../schemas/${kind}.schema.json`, import.meta.url), 'utf8')),
|
|
]));
|
|
// Package-owned schemas use this deliberately small closed subset. Never silently ignore a new constraint.
|
|
function known(schema) {
|
|
for (const key of Object.keys(schema)) if (!KEYWORDS.has(key)) throw Error(`unsupported schema keyword: ${key}`);
|
|
for (const child of Object.values(schema.properties ?? {})) known(child);
|
|
if (schema.items) known(schema.items);
|
|
}
|
|
for (const schema of Object.values(schemas)) known(schema);
|
|
|
|
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
function visit(schema, value, path, problems) {
|
|
const bad = text => problems.push(`${path}: ${text}`);
|
|
if (Object.hasOwn(schema, 'const') && value !== schema.const) bad(`must equal ${schema.const}`);
|
|
if (schema.enum && !schema.enum.includes(value)) bad(`must be one of ${schema.enum.join(', ')}`);
|
|
const types = schema.type ? [schema.type].flat() : [];
|
|
const matches = type => type === 'null' ? value === null : type === 'object' ? isObject(value)
|
|
: type === 'array' ? Array.isArray(value) : typeof value === type;
|
|
if (types.length && !types.some(matches)) { bad(`must be ${types.join(' or ')}`); return; }
|
|
if (typeof value === 'string') {
|
|
if (schema.minLength !== undefined && value.length < schema.minLength) bad('too short');
|
|
if (schema.maxLength !== undefined && value.length > schema.maxLength) bad('too long');
|
|
if (schema.pattern && !new RegExp(schema.pattern).test(value)) bad('invalid format');
|
|
if (schema.format === 'date-time' && (!Number.isFinite(Date.parse(value)) ||
|
|
new Date(value).toISOString() !== value || value.length !== 24)) bad('invalid canonical UTC date-time');
|
|
if (value.includes('\0')) bad('NUL is forbidden');
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (schema.minItems !== undefined && value.length < schema.minItems) bad('too few items');
|
|
if (schema.maxItems !== undefined && value.length > schema.maxItems) bad('too many items');
|
|
if (schema.items) value.forEach((item, index) => visit(schema.items, item, `${path}[${index}]`, problems));
|
|
}
|
|
if (isObject(value)) {
|
|
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) bad(`missing ${key}`);
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (Object.hasOwn(schema.properties ?? {}, key)) visit(schema.properties[key], child, `${path}.${key}`, problems);
|
|
else if (schema.additionalProperties === false) bad(`unknown key ${key}`);
|
|
}
|
|
}
|
|
}
|
|
export function validateRecord(kind, value) {
|
|
if (!Object.hasOwn(schemas, kind)) return [`unknown record kind: ${kind}`];
|
|
const problems = [];
|
|
visit(schemas[kind], value, kind, problems);
|
|
if (kind === 'decision' && problems.length === 0) {
|
|
const keys = value.options.map(option => option.key);
|
|
if (new Set(keys).size !== keys.length) problems.push('decision.options: duplicate keys');
|
|
if (!keys.includes(value.recommendation)) problems.push('decision.recommendation: unknown option');
|
|
if (value.status === 'open' ? value.choice !== null : !keys.includes(value.choice))
|
|
problems.push('decision.choice: must be null while open, or name the settled option');
|
|
}
|
|
return problems;
|
|
}
|