- tools/contract-verify/ — diffs CONTRACT.md ## Inputs table vs src/commands/index.js registry. First run found 3 real drifts: MGC has `set.scaling` in CONTRACT (not in registry); monster + settler registry has `child.register` (not in CONTRACT); pumpingStation registry has `set.outflow` (not in CONTRACT). - tools/flow-lint/ — lints examples/*.flow.json against the rules in .claude/rules/node-red-flow-layout.md. First run flagged the monster/basic flow (4 ui-* at 0,0 + ui-chart missing interpolation property) and rotatingMachine/edge.flow.json (6 ui-* at 0,0). - Both tools are read-only, single-binary npm packages with a `--json` output mode for CI, exit code 1 on drift. Encode the rules so we don't have to re-discover the bugs that motivated them. Per CLAUDE.md tooling doctrine: prefer these over ad-hoc grep/jq. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 };
|