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