mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 16:01:54 +00:00
Six JSON Schema 2020-12 contracts under docs/v2/schemas — campaign,
milestone, task-contract, pledge, evidence-bundle, and the public
stewardship-manifest — with valid/invalid fixtures and a dependency-
free subset validator (scripts/validate-v2-contracts.mjs) wired into
npm test as validate:v2. Unsupported schema keywords fail loudly so
the interpreter and the schema files cannot drift apart.
test_integrity_note:
change_type: [new_tests]
scenarios:
- id: SCN-v2-fixture-expectations
given: committed schemas plus valid/ and invalid/ fixtures
when: the full validation run executes
then: valid fixtures pass, invalid fixtures fail, >=12 checked
- id: SCN-v2-no-ceiling-omission
given: a well-formed task contract
when: spendingCeiling is removed
then: validation reports the missing property
- id: SCN-v2-pledge-not-money
given: a well-formed pledge
when: a balance-shaped extra field is added
then: validation rejects the unexpected property
- id: SCN-v2-evidence-completeness
given: a well-formed evidence bundle
when: an artifact family or digest is removed/truncated
then: validation fails the required/pattern check
observable_outcomes:
- validator error lists at the runValidation()/validateAgainstSchema() boundary
determinism_controls:
- hermetic filesystem fixtures; no network, clock, or randomness
anti_cheat_rationale:
prevents:
- schema files diverging from what the validator enforces
- budget/provenance fields being omitted to skip enforcement
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
Co-Authored-By: Claude Fable 5 <[email protected]>
253 lines
8.1 KiB
JavaScript
253 lines
8.1 KiB
JavaScript
// Validates the TORCH v2 contract schemas (docs/v2/schemas/) and their fixtures.
|
|
//
|
|
// The schemas are JSON Schema 2020-12 documents, but the repo is dependency-free
|
|
// (nostr-tools + ws only), so this script interprets the deliberately small subset
|
|
// of keywords the v2 schemas are allowed to use. A schema using an unsupported
|
|
// keyword fails validation loudly rather than being silently ignored.
|
|
//
|
|
// Fixture convention: docs/v2/schemas/fixtures/valid/<schema-base>.<case>.json
|
|
// must pass its schema; fixtures/invalid/<schema-base>.<case>.json must fail.
|
|
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import { join, basename } from 'node:path';
|
|
|
|
export const SCHEMA_DIR = 'docs/v2/schemas';
|
|
export const FIXTURE_DIRS = {
|
|
valid: join(SCHEMA_DIR, 'fixtures', 'valid'),
|
|
invalid: join(SCHEMA_DIR, 'fixtures', 'invalid'),
|
|
};
|
|
|
|
const SUPPORTED_KEYWORDS = new Set([
|
|
'$schema',
|
|
'$id',
|
|
'title',
|
|
'description',
|
|
'type',
|
|
'required',
|
|
'properties',
|
|
'additionalProperties',
|
|
'enum',
|
|
'const',
|
|
'pattern',
|
|
'minLength',
|
|
'maxLength',
|
|
'minimum',
|
|
'maximum',
|
|
'minItems',
|
|
'maxItems',
|
|
'uniqueItems',
|
|
'items',
|
|
]);
|
|
|
|
function typeOf(value) {
|
|
if (value === null) return 'null';
|
|
if (Array.isArray(value)) return 'array';
|
|
if (typeof value === 'number') {
|
|
return Number.isInteger(value) ? 'integer' : 'number';
|
|
}
|
|
return typeof value;
|
|
}
|
|
|
|
function typeMatches(declared, value) {
|
|
const actual = typeOf(value);
|
|
if (declared === 'number') {
|
|
return actual === 'number' || actual === 'integer';
|
|
}
|
|
return declared === actual;
|
|
}
|
|
|
|
export function collectUnsupportedKeywords(schema, path = '#') {
|
|
const unsupported = [];
|
|
if (typeOf(schema) !== 'object') {
|
|
return unsupported;
|
|
}
|
|
for (const [key, value] of Object.entries(schema)) {
|
|
if (!SUPPORTED_KEYWORDS.has(key)) {
|
|
unsupported.push(`${path}/${key}`);
|
|
continue;
|
|
}
|
|
if (key === 'properties' && typeOf(value) === 'object') {
|
|
for (const [prop, sub] of Object.entries(value)) {
|
|
unsupported.push(...collectUnsupportedKeywords(sub, `${path}/properties/${prop}`));
|
|
}
|
|
}
|
|
if (key === 'items') {
|
|
unsupported.push(...collectUnsupportedKeywords(value, `${path}/items`));
|
|
}
|
|
}
|
|
return unsupported;
|
|
}
|
|
|
|
export function validateAgainstSchema(schema, instance, path = '$') {
|
|
const errors = [];
|
|
|
|
if ('const' in schema && instance !== schema.const) {
|
|
errors.push(`${path}: expected const ${JSON.stringify(schema.const)}`);
|
|
return errors;
|
|
}
|
|
|
|
if ('enum' in schema) {
|
|
if (!schema.enum.some((allowed) => allowed === instance)) {
|
|
errors.push(`${path}: value ${JSON.stringify(instance)} not in enum`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
if ('type' in schema && !typeMatches(schema.type, instance)) {
|
|
errors.push(`${path}: expected type ${schema.type}, got ${typeOf(instance)}`);
|
|
return errors;
|
|
}
|
|
|
|
if (typeof instance === 'string') {
|
|
if ('minLength' in schema && instance.length < schema.minLength) {
|
|
errors.push(`${path}: shorter than minLength ${schema.minLength}`);
|
|
}
|
|
if ('maxLength' in schema && instance.length > schema.maxLength) {
|
|
errors.push(`${path}: longer than maxLength ${schema.maxLength}`);
|
|
}
|
|
if ('pattern' in schema && !new RegExp(schema.pattern).test(instance)) {
|
|
errors.push(`${path}: does not match pattern ${schema.pattern}`);
|
|
}
|
|
}
|
|
|
|
if (typeof instance === 'number') {
|
|
if ('minimum' in schema && instance < schema.minimum) {
|
|
errors.push(`${path}: below minimum ${schema.minimum}`);
|
|
}
|
|
if ('maximum' in schema && instance > schema.maximum) {
|
|
errors.push(`${path}: above maximum ${schema.maximum}`);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(instance)) {
|
|
if ('minItems' in schema && instance.length < schema.minItems) {
|
|
errors.push(`${path}: fewer than minItems ${schema.minItems}`);
|
|
}
|
|
if ('maxItems' in schema && instance.length > schema.maxItems) {
|
|
errors.push(`${path}: more than maxItems ${schema.maxItems}`);
|
|
}
|
|
if (schema.uniqueItems) {
|
|
const seen = new Set(instance.map((item) => JSON.stringify(item)));
|
|
if (seen.size !== instance.length) {
|
|
errors.push(`${path}: items are not unique`);
|
|
}
|
|
}
|
|
if ('items' in schema) {
|
|
instance.forEach((item, index) => {
|
|
errors.push(...validateAgainstSchema(schema.items, item, `${path}[${index}]`));
|
|
});
|
|
}
|
|
}
|
|
|
|
if (typeOf(instance) === 'object') {
|
|
const properties = schema.properties ?? {};
|
|
for (const requiredKey of schema.required ?? []) {
|
|
if (!(requiredKey in instance)) {
|
|
errors.push(`${path}: missing required property "${requiredKey}"`);
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(instance)) {
|
|
if (key in properties) {
|
|
errors.push(...validateAgainstSchema(properties[key], value, `${path}.${key}`));
|
|
} else if (schema.additionalProperties === false) {
|
|
errors.push(`${path}: unexpected property "${key}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function checkSchemaDocument(schema, fileName) {
|
|
const problems = [];
|
|
if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') {
|
|
problems.push(`${fileName}: $schema must declare draft 2020-12`);
|
|
}
|
|
if (typeof schema.title !== 'string' || schema.title.length === 0) {
|
|
problems.push(`${fileName}: missing title`);
|
|
}
|
|
if (schema.type !== 'object') {
|
|
problems.push(`${fileName}: top-level type must be object`);
|
|
}
|
|
if (schema.additionalProperties !== false) {
|
|
problems.push(`${fileName}: top-level additionalProperties must be false`);
|
|
}
|
|
if (!Array.isArray(schema.required) || schema.required.length === 0) {
|
|
problems.push(`${fileName}: top-level required must be a non-empty array`);
|
|
}
|
|
for (const keywordPath of collectUnsupportedKeywords(schema)) {
|
|
problems.push(`${fileName}: unsupported schema keyword at ${keywordPath}`);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
export async function loadSchemas(rootDir = '.') {
|
|
const dir = join(rootDir, SCHEMA_DIR);
|
|
const schemas = new Map();
|
|
for (const entry of await readdir(dir)) {
|
|
if (!entry.endsWith('.schema.json')) {
|
|
continue;
|
|
}
|
|
const schema = JSON.parse(await readFile(join(dir, entry), 'utf8'));
|
|
schemas.set(entry.replace(/\.schema\.json$/, ''), { schema, fileName: entry });
|
|
}
|
|
return schemas;
|
|
}
|
|
|
|
function schemaBaseForFixture(fixtureName, schemaBases) {
|
|
const match = schemaBases.find((base) => fixtureName.startsWith(`${base}.`));
|
|
return match ?? null;
|
|
}
|
|
|
|
export async function runValidation(rootDir = '.') {
|
|
const failures = [];
|
|
const schemas = await loadSchemas(rootDir);
|
|
|
|
if (schemas.size === 0) {
|
|
failures.push(`No schemas found in ${SCHEMA_DIR}`);
|
|
return { failures, checked: 0 };
|
|
}
|
|
|
|
for (const { schema, fileName } of schemas.values()) {
|
|
failures.push(...checkSchemaDocument(schema, fileName));
|
|
}
|
|
|
|
const schemaBases = [...schemas.keys()];
|
|
let checked = 0;
|
|
|
|
for (const [kind, fixtureDir] of Object.entries(FIXTURE_DIRS)) {
|
|
for (const entry of await readdir(join(rootDir, fixtureDir))) {
|
|
if (!entry.endsWith('.json')) {
|
|
continue;
|
|
}
|
|
const base = schemaBaseForFixture(entry, schemaBases);
|
|
if (!base) {
|
|
failures.push(`${entry}: fixture name does not start with a known schema base`);
|
|
continue;
|
|
}
|
|
const instance = JSON.parse(await readFile(join(rootDir, fixtureDir, entry), 'utf8'));
|
|
const errors = validateAgainstSchema(schemas.get(base).schema, instance);
|
|
checked += 1;
|
|
if (kind === 'valid' && errors.length > 0) {
|
|
failures.push(`${entry}: expected valid, got errors:\n ${errors.join('\n ')}`);
|
|
}
|
|
if (kind === 'invalid' && errors.length === 0) {
|
|
failures.push(`${entry}: expected schema violations, but the fixture validated`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { failures, checked };
|
|
}
|
|
|
|
const isDirectRun = process.argv[1] && basename(process.argv[1]) === 'validate-v2-contracts.mjs';
|
|
|
|
if (isDirectRun) {
|
|
const { failures, checked } = await runValidation();
|
|
if (failures.length > 0) {
|
|
console.error(`v2 contract validation failed:\n${failures.join('\n')}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`v2 contract validation passed (${checked} fixtures across ${SCHEMA_DIR}).`);
|
|
}
|