Files
EVOLV/tools/flow-lint/bin/flow-lint.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

263 lines
8.4 KiB
JavaScript

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