- tools/output-manifest-verify/ — enforces .claude/rules/output-coverage.md
§3: every node ships test/_output-manifest.md and every declared key
is referenced by at least one test file. First run shows only
machineGroupControl has the manifest (16 keys covered); all other nodes
warn. --strict escalates "missing manifest" to an error for CI gating.
- flow-lint gains two rules from the same output-coverage rule:
* FN_OUTPUT_WIRES_MISMATCH — function declares outputs=N but wires has
M arrays (causes silent dropped or duplicate emissions).
* FN_PAYLOAD_NULL_LITERAL — function source contains `payload: null`
literal (the η-null ui-chart crash pattern from 2026-05-14).
First run found 1 instance in mgc/02-Dashboard.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
9.3 KiB
JavaScript
286 lines
9.3 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);
|
|
checkFunctionFanOut(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 checkFunctionFanOut(n, findings) {
|
|
if (n.type !== 'function') return;
|
|
const outputs = Number(n.outputs);
|
|
if (!Number.isFinite(outputs) || outputs <= 1) return;
|
|
if (!Array.isArray(n.wires) || n.wires.length !== outputs) {
|
|
findings.push({
|
|
rule: 'FN_OUTPUT_WIRES_MISMATCH',
|
|
severity: 'error',
|
|
node: n.id,
|
|
msg: `function "${n.name || n.id}" declares outputs=${outputs} but wires has ${Array.isArray(n.wires) ? n.wires.length : 'no'} arrays.`,
|
|
});
|
|
}
|
|
if (typeof n.func === 'string' && /payload\s*:\s*null\b/.test(n.func)) {
|
|
findings.push({
|
|
rule: 'FN_PAYLOAD_NULL_LITERAL',
|
|
severity: 'error',
|
|
node: n.id,
|
|
msg: `function "${n.name || n.id}" emits a literal { payload: null } — crashes ui-chart on first frame; return the whole msg as null instead so the port skips.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
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 };
|