#!/usr/bin/env node // conformance.mjs: runs the conformance vectors against ANY implementation, given as a command in any language. // // node conformance.mjs --impl "" [--online] [--only ] [--artifacts ] [--vectors ] [--json] // // The contract of an implementation (three calls, JSON on stdout): // --capabilities { artifacts: [...], rules: [...], online: bool, batch: bool } what it claims to judge // [--online] the verdict of verdict.mjs for the file's { artifact, input }: // { verdict: VALID|INVALID|PARTIAL, failed: [rule ids], levels: [{ level, state }, ...] } // --batch (only if batch) list = [{ file, online }]; one verdict per line, in order // Without --online the implementation must not open a connection; vectors that need the testnet endpoint are run only with --online. // // A vector is scored only if the implementation claims its artifact and every rule the vector exercises; otherwise it is reported as // NOT COVERED, never as passed. A scored vector PASSES when the verdict, the exact set of failed rules and the state of each of the // four levels are the expected ones; it FAILS otherwise; it is BROKEN when the implementation gave no verdict at all (a crash is not // a refusal). Exit: 0 every scored vector passed (and at least one was scored); 1 a vector failed or broke; 2 nothing was scored, or // the vector set itself is not whole. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const AICI = path.dirname(fileURLToPath(import.meta.url)); const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex'); const desparte = (s) => (s.match(/"[^"]*"|\S+/g) || []).map((x) => x.replace(/^"|"$/g, '')); const peDisc = (d, pre = '') => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? peDisc(path.join(d, e.name), pre + e.name + '/') : [pre + e.name])); /** The vector set, whole: every file of the index present with its digest, and no file the index does not name. */ export function incarcaVectorii(dir = path.join(AICI, 'vectori')) { const ip = path.join(dir, 'INDEX.json'); if (!fs.existsSync(ip)) return { eroare: 'no INDEX.json in ' + dir }; const index = JSON.parse(fs.readFileSync(ip, 'utf8')); const vectori = []; for (const e of index.vectors) { const f = path.join(dir, e.id + '.json'); if (!fs.existsSync(f)) return { eroare: 'the index names ' + e.id + ', which is not on disk' }; // the digest is over the text with LF line ends: a checkout that wrote CRLF (git on Windows) holds the same vector const b = fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n'); if (sha256(Buffer.from(b, 'utf8')) !== e.sha256) return { eroare: 'vector ' + e.id + ' does not hash to its index entry' }; vectori.push({ ...JSON.parse(b), file: f }); } const straine = peDisc(dir).filter((f) => f !== 'INDEX.json' && !index.vectors.some((e) => e.id + '.json' === f)); if (straine.length) return { eroare: 'files the index does not name: ' + straine.join(', ') }; if (index.count !== vectori.length) return { eroare: 'the index counts ' + index.count + ' vectors and lists ' + vectori.length }; return { index, vectori }; } /** Compares a verdict with the expectation of a vector; returns the list of differences (empty: the vector passed). */ export function compara(v, r) { const d = []; const e = v.expected; if (r.verdict !== e.verdict) d.push(`verdict ${r.verdict}, expected ${e.verdict}`); const f = Array.isArray(r.failed) ? [...r.failed].sort() : null; if (!f || f.join() !== e.failed.join()) d.push(`failed rules [${f ? f.join(', ') : 'none given'}], expected [${e.failed.join(', ')}]`); for (const [l, s] of Object.entries(e.levels)) { const x = (r.levels || []).find((y) => y.level === l); if (!x || x.state !== s) d.push(`${l} ${x ? x.state : 'missing'}, expected ${s}`); } return d; } /** * Runs the suite. @returns {{ caps, rezultate: [{ id, stare: 'PASS'|'FAIL'|'BROKEN'|'NOT_COVERED'|'SKIPPED_ONLINE', detaliu }], eroare? }} * `env` is added to the environment of the implementation (the planting control points it at planted copies through it). */ export function ruleazaSuita({ impl, online = false, dir, doar = null, artefacte = null, env = {}, vectori: dati = null }) { const cmd = desparte(impl); if (!cmd.length) return { eroare: 'no implementation command' }; if (cmd[0] === 'node') cmd[0] = process.execPath; const mediu = { ...process.env, ...env }; const exec = (args, timeout) => spawnSync(cmd[0], [...cmd.slice(1), ...args], { encoding: 'utf8', env: mediu, timeout, maxBuffer: 64 << 20, stdio: ['ignore', 'pipe', 'pipe'] }); const c = exec(['--capabilities'], 60000); let caps; try { caps = JSON.parse(c.stdout); } catch { return { eroare: 'the implementation gave no capabilities (exit ' + c.status + '): ' + String(c.stderr || c.error || '').trim().split('\n').slice(-1)[0].slice(0, 200) }; } if (!Array.isArray(caps.artifacts) || !Array.isArray(caps.rules)) return { eroare: 'capabilities need artifacts and rules' }; let vectori = dati; if (!vectori) { const s = incarcaVectorii(dir); if (s.eroare) return { eroare: s.eroare }; vectori = s.vectori; } if (doar) vectori = vectori.filter((v) => v.id.startsWith(doar)); if (artefacte) vectori = vectori.filter((v) => artefacte.includes(v.artifact)); const rezultate = []; const deRulat = []; for (const v of vectori) { const lipsa = !caps.artifacts.includes(v.artifact) ? ['artifact ' + v.artifact] : v.exercises.filter((r) => !caps.rules.includes(r)); if (v.requires.length && !caps.online) lipsa.push('online'); if (lipsa.length) rezultate.push({ id: v.id, stare: 'NOT_COVERED', detaliu: 'the implementation does not claim: ' + lipsa.join(', ') }); else if (v.requires.length && !online) rezultate.push({ id: v.id, stare: 'SKIPPED_ONLINE', detaliu: 'needs ' + v.requires.join(', ') + ' (run with --online)' }); else deRulat.push(v); } const raspunsuri = new Map(); if (caps.batch && deRulat.length) { const lf = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aere-conf-run-')), 'list.json'); fs.writeFileSync(lf, JSON.stringify(deRulat.map((v) => ({ file: v.file, online: v.requires.length > 0 })))); const r = exec(['--batch', lf], 1800000); fs.rmSync(path.dirname(lf), { recursive: true, force: true }); const linii = String(r.stdout || '').split('\n').filter((l) => l.trim()); deRulat.forEach((v, k) => { try { raspunsuri.set(v.id, JSON.parse(linii[k])); } catch { raspunsuri.set(v.id, { error: 'no verdict line (exit ' + r.status + '): ' + String(r.stderr || r.error || '').trim().split('\n').slice(-1)[0].slice(0, 160) }); } }); } else for (const v of deRulat) { const r = exec([v.file, ...(v.requires.length ? ['--online'] : [])], 600000); try { raspunsuri.set(v.id, JSON.parse(r.stdout)); } catch { raspunsuri.set(v.id, { error: 'no verdict (exit ' + r.status + '): ' + String(r.stderr || r.error || '').trim().split('\n').slice(-1)[0].slice(0, 160) }); } } for (const v of deRulat) { const r = raspunsuri.get(v.id); if (!r || r.error || !r.verdict) { rezultate.push({ id: v.id, stare: 'BROKEN', detaliu: (r && r.error) || 'no verdict' }); continue; } const d = compara(v, r); rezultate.push({ id: v.id, stare: d.length ? 'FAIL' : 'PASS', detaliu: d.join('; '), primit: { verdict: r.verdict, failed: r.failed } }); } const ordine = new Map(vectori.map((v, k) => [v.id, k])); rezultate.sort((x, y) => ordine.get(x.id) - ordine.get(y.id)); return { caps, rezultate }; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const a = process.argv.slice(2); const opt = (n) => { const i = a.indexOf(n); return i >= 0 ? a[i + 1] : null; }; const impl = opt('--impl'); if (!impl) { console.error('usage: node conformance.mjs --impl "" [--online] [--only ] [--vectors ] [--json]'); process.exit(2); } const s = ruleazaSuita({ impl, online: a.includes('--online'), dir: opt('--vectors') || undefined, doar: opt('--only'), artefacte: opt('--artifacts') ? opt('--artifacts').split(',') : null }); if (s.eroare) { console.log('NOT MEASURED: ' + s.eroare); process.exit(2); } const n = (st) => s.rezultate.filter((r) => r.stare === st).length; if (a.includes('--json')) console.log(JSON.stringify({ implementation: s.caps.name || impl, results: s.rezultate }, null, 1)); else { console.log('implementation: ' + (s.caps.name || impl) + ' claims ' + s.caps.artifacts.length + ' artifact kind(s), ' + s.caps.rules.length + ' rule(s)' + (s.caps.online ? ', online' : '')); for (const r of s.rezultate) if (r.stare === 'FAIL' || r.stare === 'BROKEN') console.log(` [${r.stare.padEnd(6)}] ${r.id}: ${r.detaliu}`); const nc = s.rezultate.filter((r) => r.stare === 'NOT_COVERED'); const peMotiv = {}; for (const r of nc) peMotiv[r.detaliu] = (peMotiv[r.detaliu] || 0) + 1; for (const [m, k] of Object.entries(peMotiv)) console.log(` [NOT COVERED] ${k} vector(s): ${m}`); console.log(`\nconformance: ${n('PASS')} passed, ${n('FAIL')} failed, ${n('BROKEN')} broken, ${n('NOT_COVERED')} not covered, ${n('SKIPPED_ONLINE')} skipped (online) of ${s.rezultate.length} vectors`); } process.exitCode = n('FAIL') || n('BROKEN') ? 1 : n('PASS') ? 0 : 2; }