#!/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 };