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>
This commit is contained in:
znetsixe
2026-05-19 09:38:53 +02:00
parent d4e72f280e
commit 3ff75fcb09
6 changed files with 523 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
# @evolv/contract-verify
Verify that `nodes/<n>/CONTRACT.md`'s topic table matches the canonical
registry in `nodes/<n>/src/commands/index.js`.
## Usage
```bash
# verify every node with a CONTRACT.md
node tools/contract-verify/bin/contract-verify.js
# verify one node
node tools/contract-verify/bin/contract-verify.js nodes/rotatingMachine
# CI-friendly JSON output
node tools/contract-verify/bin/contract-verify.js --json
```
Exit code `1` on any drift; `0` if every checked node agrees.
## What it checks
1. Every `topic:` in the registry appears as a canonical row in the `## Inputs` table.
2. Every canonical row in the `## Inputs` table is registered.
3. The `aliases:` array on each descriptor matches the "Aliases" column.
## What it does NOT check
- Payload schemas (registry's `payloadSchema` vs. the "Payload" column).
- The "Effect" description column.
- Output ports — see `tools/output-manifest-verify/`.
Run after touching `src/commands/index.js` or `CONTRACT.md` in any node.

View File

@@ -0,0 +1,155 @@
#!/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 };

View File

@@ -0,0 +1,13 @@
{
"name": "@evolv/contract-verify",
"version": "0.1.0",
"private": true,
"description": "Verify nodes/<n>/CONTRACT.md topic table against nodes/<n>/src/commands/index.js",
"bin": {
"evolv-contract-verify": "bin/contract-verify.js"
},
"scripts": {
"test": "node --test test/*.test.js"
},
"license": "UNLICENSED"
}