Files
EVOLV/tools/contract-verify/bin/contract-verify.js
znetsixe 3ff75fcb09 tools: add contract-verify and flow-lint (JS native, repo-rule-aware)
- 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>
2026-05-19 09:38:53 +02:00

156 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 };