2026-05-19 09:38:53 +02:00
#!/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 ) ;
2026-05-19 10:13:49 +02:00
checkFunctionFanOut ( n , findings ) ;
2026-05-19 09:38:53 +02:00
}
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. ` ,
} ) ;
}
}
}
}
2026-05-19 10:13:49 +02:00
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. ` ,
} ) ;
}
}
2026-05-19 09:38:53 +02:00
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 } ;