Files
EVOLV/tools/contract-verify/bin/contract-verify.js

156 lines
5.4 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function loadRegistry(nodeDir) {
const registryPath = path.join(nodeDir, 'src/commands/index.js');
if (!fs.existsSync(registryPath)) {
return { topics: [], aliases: new Map(), missing: true };
}
delete require.cache[require.resolve(registryPath)];
const descriptors = require(registryPath);
if (!Array.isArray(descriptors)) {
throw new Error(`commands/index.js must export an array; got ${typeof descriptors}`);
}
const topics = [];
const aliases = new Map();
for (const d of descriptors) {
if (!d || typeof d.topic !== 'string') continue;
topics.push(d.topic);
aliases.set(d.topic, Array.isArray(d.aliases) ? d.aliases.slice() : []);
}
return { topics, aliases, missing: false };
}
function parseContractTable(contractPath) {
if (!fs.existsSync(contractPath)) return { topics: [], aliases: new Map(), missing: true };
const src = fs.readFileSync(contractPath, 'utf8');
const lines = src.split(/\r?\n/);
const topics = [];
const aliases = new Map();
let inTable = false;
for (const line of lines) {
if (/^\|\s*Canonical\s*\|/i.test(line)) { inTable = true; continue; }
if (inTable && /^\|[\s-]+\|/.test(line)) continue;
if (inTable && !line.trim().startsWith('|')) {
if (line.trim() === '' || /^#/.test(line.trim())) { inTable = false; continue; }
continue;
}
if (!inTable) continue;
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
if (cells.length < 2) continue;
const canonical = stripBackticks(cells[0]);
if (!canonical) continue;
topics.push(canonical);
aliases.set(canonical, parseAliases(cells[1]));
}
return { topics, aliases, missing: false };
}
function stripBackticks(s) {
const m = s.match(/`([^`]+)`/);
return m ? m[1] : '';
}
function parseAliases(cell) {
if (!cell) return [];
if (/^[—\-]$/.test(cell.trim())) return [];
if (/^\(legacy/i.test(cell.trim())) return [];
return [...cell.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
}
function diff(registry, contract) {
const inRegOnly = registry.topics.filter((t) => !contract.topics.includes(t));
const inContractOnly = contract.topics.filter((t) => !registry.topics.includes(t));
const aliasMismatch = [];
for (const topic of registry.topics) {
if (!contract.topics.includes(topic)) continue;
const ra = (registry.aliases.get(topic) || []).slice().sort();
const ca = (contract.aliases.get(topic) || []).slice().sort();
if (ra.join(',') !== ca.join(',')) {
aliasMismatch.push({ topic, registry: ra, contract: ca });
}
}
return { inRegOnly, inContractOnly, aliasMismatch };
}
function report(nodeName, result, json) {
if (json) {
process.stdout.write(JSON.stringify({ node: nodeName, ...result }, null, 2) + '\n');
return;
}
const lines = [`\n[${nodeName}]`];
if (result.inRegOnly.length === 0 && result.inContractOnly.length === 0 && result.aliasMismatch.length === 0) {
lines.push(' OK — registry and CONTRACT.md agree.');
} else {
if (result.inRegOnly.length) {
lines.push(' Registry has, CONTRACT.md missing:');
for (const t of result.inRegOnly) lines.push(` - ${t}`);
}
if (result.inContractOnly.length) {
lines.push(' CONTRACT.md has, registry missing:');
for (const t of result.inContractOnly) lines.push(` - ${t}`);
}
if (result.aliasMismatch.length) {
lines.push(' Alias drift:');
for (const m of result.aliasMismatch) {
lines.push(` - ${m.topic}: registry=[${m.registry.join(',')}] vs contract=[${m.contract.join(',')}]`);
}
}
}
process.stdout.write(lines.join('\n') + '\n');
}
function findNodes(repoRoot) {
const nodesDir = path.join(repoRoot, 'nodes');
return fs.readdirSync(nodesDir)
.filter((n) => fs.existsSync(path.join(nodesDir, n, 'CONTRACT.md')))
.filter((n) => n !== 'generalFunctions')
.map((n) => path.join(nodesDir, n));
}
function main() {
const args = process.argv.slice(2);
const json = args.includes('--json');
const positional = args.filter((a) => !a.startsWith('--'));
const repoRoot = path.resolve(__dirname, '../../..');
const targets = positional.length === 0
? findNodes(repoRoot)
: positional.map((p) => path.resolve(p));
let fail = false;
for (const nodeDir of targets) {
const nodeName = path.basename(nodeDir);
let registry, contract;
try { registry = loadRegistry(nodeDir); }
catch (err) {
process.stderr.write(`\n[${nodeName}] ERROR loading registry: ${err.message}\n`);
fail = true;
continue;
}
contract = parseContractTable(path.join(nodeDir, 'CONTRACT.md'));
if (registry.missing && contract.missing) {
process.stderr.write(`\n[${nodeName}] skipped — no commands/index.js and no CONTRACT.md\n`);
continue;
}
if (registry.missing) {
process.stderr.write(`\n[${nodeName}] NOTE: CONTRACT.md exists but no commands/index.js\n`);
continue;
}
if (contract.missing) {
process.stderr.write(`\n[${nodeName}] NOTE: commands/index.js exists but no CONTRACT.md\n`);
fail = true;
continue;
}
const result = diff(registry, contract);
if (result.inRegOnly.length || result.inContractOnly.length || result.aliasMismatch.length) fail = true;
report(nodeName, result, json);
}
process.exit(fail ? 1 : 0);
}
if (require.main === module) main();
module.exports = { loadRegistry, parseContractTable, diff };