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:
47
tools/flow-lint/README.md
Normal file
47
tools/flow-lint/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @evolv/flow-lint
|
||||
|
||||
Lint Node-RED flow JSON against the EVOLV flow-layout rule
|
||||
(`.claude/rules/node-red-flow-layout.md`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# every examples/*.flow.json across all nodes
|
||||
node tools/flow-lint/bin/flow-lint.js
|
||||
|
||||
# one flow
|
||||
node tools/flow-lint/bin/flow-lint.js nodes/rotatingMachine/examples/01-Basic.flow.json
|
||||
|
||||
# CI / JSON output (one line per flow)
|
||||
node tools/flow-lint/bin/flow-lint.js --json
|
||||
```
|
||||
|
||||
Exit code `1` on any `error`-severity finding; `0` otherwise (warnings do
|
||||
not fail).
|
||||
|
||||
## Rules
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|---|---|---|
|
||||
| `UI_XY` | error | `ui-*` nodes with both `x` and `y` at 0 — pile up at canvas origin. |
|
||||
| `UI_CHART_PROP` | error | `ui-chart` missing one of the required-or-it-renders-blank properties (interpolation, yAxisProperty, …). |
|
||||
| `UI_CHART_TYPE` | warn | `width` / `height` as string instead of number. |
|
||||
| `UI_CHART_PALETTE` | warn | `colors` array with fewer than 3 entries. |
|
||||
| `INJECT_JSON_PROPS` | error | `inject` with `payloadType:"json"` but missing `props[]` — silently treated as string. |
|
||||
| `LINK_CHANNEL_NAME` | warn | `link out` / `link in` without a `cmd:` / `evt:` / `setup:` prefix. |
|
||||
| `LINK_BROKEN` | error | `link out` / `link in` referencing a non-existent node id. |
|
||||
| `LINK_PAIR_TYPE` | error | `link out` paired with anything other than `link in`, or vice versa. |
|
||||
| `LINK_PAIR_ASYMMETRIC` | error | One side references the other but the partner does not link back. |
|
||||
| `DEBUG_LOG_IN_FLOW` | warn | `enableLog:"debug"` shipped in a flow — fills container log. |
|
||||
| `SELF_LOOP` | error | Any node wired to itself. |
|
||||
| `GROUP_WIDTH_SUM` | warn | `ui-group` widths on a `ui-page` summing to non-multiple of 12 — leaves grid gaps. |
|
||||
|
||||
## Why use it
|
||||
|
||||
Every rule corresponds to a bug we've actually hit:
|
||||
- `UI_XY` and `UI_CHART_PROP` — the dashboard pile-up + blank-chart bugs.
|
||||
- `INJECT_JSON_PROPS` — the silent-string inject bug.
|
||||
- `LINK_PAIR_ASYMMETRIC` — drift between named channels.
|
||||
- `SELF_LOOP` — the 250k-msg/s `replace_all` incident.
|
||||
|
||||
Run before committing any flow change.
|
||||
262
tools/flow-lint/bin/flow-lint.js
Normal file
262
tools/flow-lint/bin/flow-lint.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REQUIRED_CHART_PROPS = [
|
||||
'chartType', 'interpolation', 'category', 'categoryType',
|
||||
'xAxisType', 'xAxisPropertyType', 'yAxisProperty', 'yAxisPropertyType',
|
||||
'action', 'width', 'height', 'colors',
|
||||
];
|
||||
|
||||
const CHANNEL_PREFIXES = ['cmd:', 'evt:', 'setup:'];
|
||||
|
||||
function lintFlow(flowPath) {
|
||||
const findings = [];
|
||||
let nodes;
|
||||
try {
|
||||
const raw = fs.readFileSync(flowPath, 'utf8');
|
||||
nodes = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
findings.push({ rule: 'PARSE', severity: 'error', msg: `JSON parse failed: ${err.message}` });
|
||||
return findings;
|
||||
}
|
||||
if (!Array.isArray(nodes)) {
|
||||
findings.push({ rule: 'SHAPE', severity: 'error', msg: 'Top-level must be an array of nodes.' });
|
||||
return findings;
|
||||
}
|
||||
const byId = new Map(nodes.filter((n) => n && n.id).map((n) => [n.id, n]));
|
||||
for (const n of nodes) {
|
||||
if (!n || typeof n.type !== 'string') continue;
|
||||
checkUiNodeXY(n, findings);
|
||||
checkUiChart(n, findings);
|
||||
checkInject(n, findings);
|
||||
checkLinkPair(n, byId, findings);
|
||||
checkDebugLog(n, findings);
|
||||
checkBackwardWires(n, findings);
|
||||
}
|
||||
checkGroupWidths(nodes, findings);
|
||||
return findings;
|
||||
}
|
||||
|
||||
const UI_CONFIG_TYPES = new Set(['ui-base', 'ui-theme', 'ui-page', 'ui-group', 'ui-spacer', 'ui-control']);
|
||||
|
||||
function checkUiNodeXY(n, findings) {
|
||||
if (!n.type.startsWith('ui-')) return;
|
||||
if (UI_CONFIG_TYPES.has(n.type)) return;
|
||||
if ((!n.x && !n.y) || (n.x === 0 && n.y === 0)) {
|
||||
findings.push({
|
||||
rule: 'UI_XY',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name || n.id}" has no editor position (x/y both 0 or missing) — will pile up at canvas origin.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkUiChart(n, findings) {
|
||||
if (n.type !== 'ui-chart') return;
|
||||
const missing = REQUIRED_CHART_PROPS.filter((p) => n[p] === undefined || n[p] === null || n[p] === '');
|
||||
for (const m of missing) {
|
||||
if (m === 'xAxisProperty' || m === 'xAxisFormat') continue;
|
||||
findings.push({
|
||||
rule: 'UI_CHART_PROP',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `ui-chart "${n.name || n.id}" missing required property: ${m}`,
|
||||
});
|
||||
}
|
||||
if (typeof n.width === 'string') {
|
||||
findings.push({ rule: 'UI_CHART_TYPE', severity: 'warn', node: n.id, msg: `ui-chart width should be number, got string "${n.width}"` });
|
||||
}
|
||||
if (typeof n.height === 'string') {
|
||||
findings.push({ rule: 'UI_CHART_TYPE', severity: 'warn', node: n.id, msg: `ui-chart height should be number, got string "${n.height}"` });
|
||||
}
|
||||
if (Array.isArray(n.colors) && n.colors.length < 3) {
|
||||
findings.push({ rule: 'UI_CHART_PALETTE', severity: 'warn', node: n.id, msg: 'ui-chart palette has <3 colors; series may collide.' });
|
||||
}
|
||||
}
|
||||
|
||||
function checkInject(n, findings) {
|
||||
if (n.type !== 'inject') return;
|
||||
if (n.payloadType !== 'json') return;
|
||||
if (!Array.isArray(n.props) || n.props.length === 0) {
|
||||
findings.push({
|
||||
rule: 'INJECT_JSON_PROPS',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `inject "${n.name || n.id}" has payloadType=json but no props[] — Node-RED will silently treat payload as string.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payloadProp = n.props.find((p) => p && p.p === 'payload');
|
||||
if (payloadProp && payloadProp.vt && payloadProp.vt !== 'json') {
|
||||
findings.push({
|
||||
rule: 'INJECT_JSON_PROPS',
|
||||
severity: 'warn',
|
||||
node: n.id,
|
||||
msg: `inject "${n.name || n.id}" has payloadType=json but props.payload.vt=${payloadProp.vt}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkLinkPair(n, byId, findings) {
|
||||
if (n.type !== 'link out' && n.type !== 'link in') return;
|
||||
if (n.name && !CHANNEL_PREFIXES.some((p) => n.name.startsWith(p))) {
|
||||
findings.push({
|
||||
rule: 'LINK_CHANNEL_NAME',
|
||||
severity: 'warn',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name}" should start with cmd: / evt: / setup: (channel naming convention).`,
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(n.links)) return;
|
||||
for (const linkedId of n.links) {
|
||||
const other = byId.get(linkedId);
|
||||
if (!other) {
|
||||
findings.push({
|
||||
rule: 'LINK_BROKEN',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name || n.id}" points at non-existent node id "${linkedId}".`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const otherType = n.type === 'link out' ? 'link in' : 'link out';
|
||||
if (other.type !== otherType) {
|
||||
findings.push({
|
||||
rule: 'LINK_PAIR_TYPE',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name || n.id}" pairs with non-${otherType} "${other.type}".`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(other.links) && !other.links.includes(n.id)) {
|
||||
findings.push({
|
||||
rule: 'LINK_PAIR_ASYMMETRIC',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name || n.id}" → ${otherType} "${other.name || other.id}" — partner does not link back.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkDebugLog(n, findings) {
|
||||
if (n.enableLog === 'debug') {
|
||||
findings.push({
|
||||
rule: 'DEBUG_LOG_IN_FLOW',
|
||||
severity: 'warn',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name || n.id}" has enableLog:"debug" — fills container log in seconds; remove before commit.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkBackwardWires(n, findings) {
|
||||
if (!Array.isArray(n.wires) || typeof n.x !== 'number') return;
|
||||
for (const portWires of n.wires) {
|
||||
if (!Array.isArray(portWires)) continue;
|
||||
for (const targetId of portWires) {
|
||||
if (targetId === n.id) {
|
||||
findings.push({
|
||||
rule: 'SELF_LOOP',
|
||||
severity: 'error',
|
||||
node: n.id,
|
||||
msg: `${n.type} "${n.name || n.id}" wires to itself — will fire >250k msg/s.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkGroupWidths(nodes, findings) {
|
||||
const pages = new Map();
|
||||
for (const n of nodes) {
|
||||
if (!n || n.type !== 'ui-group') continue;
|
||||
if (typeof n.width !== 'number' || !n.page) continue;
|
||||
const arr = pages.get(n.page) || [];
|
||||
arr.push({ id: n.id, name: n.name, width: n.width });
|
||||
pages.set(n.page, arr);
|
||||
}
|
||||
for (const [pageId, groups] of pages) {
|
||||
const total = groups.reduce((s, g) => s + g.width, 0);
|
||||
if (total % 12 !== 0) {
|
||||
findings.push({
|
||||
rule: 'GROUP_WIDTH_SUM',
|
||||
severity: 'warn',
|
||||
msg: `ui-page "${pageId}" has groups summing to width=${total}; should be a multiple of 12 (12-column grid).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findFlows(repoRoot) {
|
||||
const out = [];
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
walk(full);
|
||||
} else if (entry.isFile() && /\.flow\.json$|\d+.*\.json$/.test(entry.name) && /examples?/.test(full)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(path.join(repoRoot, 'nodes'));
|
||||
walk(path.join(repoRoot, 'examples'));
|
||||
return out;
|
||||
}
|
||||
|
||||
function report(flowPath, findings, json) {
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify({ flow: flowPath, findings }) + '\n');
|
||||
return findings.some((f) => f.severity === 'error');
|
||||
}
|
||||
if (findings.length === 0) {
|
||||
process.stdout.write(`OK ${flowPath}\n`);
|
||||
return false;
|
||||
}
|
||||
const errs = findings.filter((f) => f.severity === 'error').length;
|
||||
const warns = findings.filter((f) => f.severity === 'warn').length;
|
||||
process.stdout.write(`\n${errs ? 'FAIL' : 'WARN'} ${flowPath} (${errs} err, ${warns} warn)\n`);
|
||||
for (const f of findings) {
|
||||
const tag = f.severity === 'error' ? 'ERR ' : 'WARN';
|
||||
const nodeRef = f.node ? `[${f.node}] ` : '';
|
||||
process.stdout.write(` ${tag} ${f.rule}: ${nodeRef}${f.msg}\n`);
|
||||
}
|
||||
return errs > 0;
|
||||
}
|
||||
|
||||
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
|
||||
? findFlows(repoRoot)
|
||||
: positional.flatMap((p) => {
|
||||
const full = path.resolve(p);
|
||||
if (fs.existsSync(full) && fs.statSync(full).isDirectory()) return findFlows(full);
|
||||
return [full];
|
||||
});
|
||||
if (targets.length === 0) {
|
||||
process.stderr.write('No flow files found.\n');
|
||||
process.exit(2);
|
||||
}
|
||||
let fail = false;
|
||||
for (const flowPath of targets) {
|
||||
const findings = lintFlow(flowPath);
|
||||
const flowFail = report(flowPath, findings, json);
|
||||
if (flowFail) fail = true;
|
||||
}
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { lintFlow };
|
||||
13
tools/flow-lint/package.json
Normal file
13
tools/flow-lint/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@evolv/flow-lint",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Lint Node-RED flows (examples/*.flow.json) against EVOLV flow-layout rules",
|
||||
"bin": {
|
||||
"evolv-flow-lint": "bin/flow-lint.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
Reference in New Issue
Block a user