156 lines
5.4 KiB
JavaScript
156 lines
5.4 KiB
JavaScript
|
|
#!/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 };
|