@digitalnodecom/node-red-contrib-analyzer
Version:
A Node-RED global service that monitors function nodes for debugging artifacts and performance issues. Features real-time quality metrics, Vue.js dashboard, and comprehensive code analysis.
814 lines (707 loc) • 33.3 kB
JavaScript
const { parseScript } = require('meriyah');
// Parse ignore directives from code lines (reused from regex implementation)
function parseIgnoreDirectives(lines) {
const ignoreRegions = [];
const ignoreLines = new Set();
const ignoreNextLines = new Set();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Check for ignore-start directive
if (trimmed.match(/\/\/\s*@nr-analyzer-ignore-start/i)) {
// Find the corresponding end directive
for (let j = i + 1; j < lines.length; j++) {
const endLine = lines[j].trim();
if (endLine.match(/\/\/\s*@nr-analyzer-ignore-end/i)) {
ignoreRegions.push({ start: i + 1, end: j + 1 }); // 1-based line numbers
break;
}
}
}
// Check for single line ignore
if (trimmed.match(/\/\/\s*@nr-analyzer-ignore-line/i)) {
ignoreLines.add(i + 1); // 1-based line numbers
}
// Check for ignore-next directive
if (trimmed.match(/\/\/\s*@nr-analyzer-ignore-next/i)) {
if (i + 1 < lines.length) {
ignoreNextLines.add(i + 2); // 1-based line numbers (next line)
}
}
}
return { ignoreRegions, ignoreLines, ignoreNextLines };
}
// Check if a line should be ignored based on ignore directives
function shouldIgnoreLine(lineNumber, ignoreRegions, ignoreLines, ignoreNextLines) {
// Check if line is in ignore regions
for (const region of ignoreRegions) {
if (lineNumber >= region.start && lineNumber <= region.end) {
return true;
}
}
// Check if line is explicitly ignored
if (ignoreLines.has(lineNumber)) {
return true;
}
// Check if line is marked as ignore-next
if (ignoreNextLines.has(lineNumber)) {
return true;
}
return false;
}
// Adjust line numbers in AST after wrapping code
function adjustLineNumbers(node, offset) {
if (!node || typeof node !== 'object') return;
if (node.loc && node.loc.start) {
node.loc.start.line += offset;
node.loc.end.line += offset;
}
for (let key in node) {
if (Object.prototype.hasOwnProperty.call(node, key)) {
let child = node[key];
if (Array.isArray(child)) {
child.forEach(item => adjustLineNumbers(item, offset));
} else if (child && typeof child === 'object') {
adjustLineNumbers(child, offset);
}
}
}
}
// Find unused variables in the AST
function findUnusedVariables(ast) {
const issues = [];
const scopes = [];
const globalScope = new Map(); // variable name -> { declared: Set, used: Set }
scopes.push(globalScope);
function getCurrentScope() {
return scopes[scopes.length - 1];
}
function enterScope() {
scopes.push(new Map());
}
function exitScope() {
const currentScope = scopes.pop();
// Check for unused variables in this scope
for (const [varName, info] of currentScope.entries()) {
for (const declNode of info.declared) {
// Skip if variable is used anywhere in this scope
if (info.used.size > 0) continue;
// Skip function parameters and certain variable names
if (isExemptVariable(varName, declNode)) continue;
// Create issue for unused variable with precise highlighting
const issue = createUnusedVariableIssue(varName, declNode);
if (issue) {
issues.push(issue);
}
}
}
}
function isExemptVariable(varName, declNode) {
// Skip common exempt patterns
if (varName.startsWith('_')) return true; // Underscore prefix indicates intentionally unused
if (['msg', 'node', 'context', 'flow', 'global', 'env', 'RED'].includes(varName)) return true; // Node-RED globals
// Skip function parameters - check if this is a parameter by looking at parent context
if (declNode.type === 'Identifier') {
let currentNode = declNode;
while (currentNode && currentNode.parent) {
const parentNode = currentNode.parent;
if ((parentNode.type === 'FunctionDeclaration' ||
parentNode.type === 'ArrowFunctionExpression' ||
parentNode.type === 'FunctionExpression') &&
parentNode.params && parentNode.params.includes(currentNode)) {
return true;
}
currentNode = parentNode;
}
}
// Skip function declarations that are never called (they might be intended for external use)
if (declNode.type === 'FunctionDeclaration') {
return true;
}
// Skip variable declarations assigned to functions (like arrow functions)
if (declNode.type === 'VariableDeclarator' &&
declNode.init &&
(declNode.init.type === 'FunctionExpression' ||
declNode.init.type === 'ArrowFunctionExpression')) {
return true;
}
return false;
}
function createUnusedVariableIssue(varName, declNode) {
if (!declNode.loc) return null;
let startColumn = declNode.loc.start.column + 1;
let endColumn = declNode.loc.end.column + 1;
// For variable declarations, highlight only the variable name, not the whole declaration
if (declNode.type === 'VariableDeclarator' && declNode.id) {
startColumn = declNode.id.loc.start.column + 1;
endColumn = declNode.id.loc.end.column + 1;
}
return {
type: 'unused-variable',
message: `Variable '${varName}' is declared but never used`,
line: declNode.loc.start.line,
column: startColumn,
endColumn: endColumn,
severity: 'info'
};
}
function addVariableDeclaration(varName, node) {
const currentScope = getCurrentScope();
if (!currentScope.has(varName)) {
currentScope.set(varName, { declared: new Set(), used: new Set() });
}
currentScope.get(varName).declared.add(node);
}
function addVariableUsage(varName, node) {
// Look for variable in current scope chain (from innermost to outermost)
for (let i = scopes.length - 1; i >= 0; i--) {
const scope = scopes[i];
if (scope.has(varName)) {
scope.get(varName).used.add(node);
return;
}
}
}
function analyzeNode(node, parent = null) {
if (!node || typeof node !== 'object' || !node.type) return;
node.parent = parent;
// Handle scope creation
if (node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression' ||
node.type === 'BlockStatement' ||
node.type === 'Program') {
enterScope();
// Add function parameters as declarations
if (node.params) {
for (const param of node.params) {
if (param.type === 'Identifier') {
addVariableDeclaration(param.name, param);
}
}
}
}
// Handle variable declarations
if (node.type === 'VariableDeclarator' && node.id && node.id.type === 'Identifier') {
addVariableDeclaration(node.id.name, node);
}
// Handle function declarations
if (node.type === 'FunctionDeclaration' && node.id && node.id.type === 'Identifier') {
addVariableDeclaration(node.id.name, node);
}
// Handle variable usage (identifier references)
if (node.type === 'Identifier' && parent) {
// Skip if this identifier is a declaration context
const isDeclaration = (
(parent.type === 'VariableDeclarator' && parent.id === node) ||
(parent.type === 'FunctionDeclaration' && parent.id === node) ||
(parent.type === 'Property' && parent.key === node && !parent.computed) ||
(parent.type === 'MemberExpression' && parent.property === node && !parent.computed)
);
if (!isDeclaration) {
addVariableUsage(node.name, node);
}
}
// Recursively analyze child nodes
for (let key in node) {
if (Object.prototype.hasOwnProperty.call(node, key) && key !== 'parent' && key !== 'loc' && key !== 'range') {
let child = node[key];
if (Array.isArray(child)) {
child.forEach(item => {
if (item && typeof item === 'object' && item.type) {
analyzeNode(item, node);
}
});
} else if (child && typeof child === 'object' && child.type) {
analyzeNode(child, node);
}
}
}
// Handle scope exit
if (node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression' ||
node.type === 'BlockStatement' ||
node.type === 'Program') {
exitScope();
}
}
analyzeNode(ast);
return issues;
}
// Find flow variable usage in the AST
function findFlowVariables(ast) {
const flowVariables = [];
function traverse(node) {
if (!node || typeof node !== 'object' || !node.type) return;
// Detect flow.get('variableName') calls
if (node.type === 'CallExpression' &&
node.callee && node.callee.type === 'MemberExpression' &&
node.callee.object && node.callee.object.name === 'flow' &&
node.callee.property && node.callee.property.name === 'get' &&
node.arguments && node.arguments.length > 0 &&
node.arguments[0].type === 'Literal' &&
typeof node.arguments[0].value === 'string') {
const variableName = node.arguments[0].value;
const lineNumber = node.loc ? node.loc.start.line : null;
if (lineNumber) {
flowVariables.push({
type: 'flow-get',
variableName: variableName,
line: lineNumber,
column: node.arguments[0].loc.start.column + 1,
endColumn: node.arguments[0].loc.end.column + 1,
fullCallStart: node.loc.start.column + 1,
fullCallEnd: node.loc.end.column + 1
});
}
}
// Detect flow.set('variableName', value) calls
if (node.type === 'CallExpression' &&
node.callee && node.callee.type === 'MemberExpression' &&
node.callee.object && node.callee.object.name === 'flow' &&
node.callee.property && node.callee.property.name === 'set' &&
node.arguments && node.arguments.length >= 2 &&
node.arguments[0].type === 'Literal' &&
typeof node.arguments[0].value === 'string') {
const variableName = node.arguments[0].value;
const lineNumber = node.loc ? node.loc.start.line : null;
if (lineNumber) {
flowVariables.push({
type: 'flow-set',
variableName: variableName,
line: lineNumber,
column: node.arguments[0].loc.start.column + 1,
endColumn: node.arguments[0].loc.end.column + 1,
fullCallStart: node.loc.start.column + 1,
fullCallEnd: node.loc.end.column + 1
});
}
}
// Recursively traverse child nodes
for (let key in node) {
if (Object.prototype.hasOwnProperty.call(node, key) && key !== 'parent' && key !== 'loc' && key !== 'range') {
let child = node[key];
if (Array.isArray(child)) {
child.forEach(item => {
if (item && typeof item === 'object' && item.type) {
traverse(item, node);
}
});
} else if (child && typeof child === 'object' && child.type) {
traverse(child, node);
}
}
}
}
traverse(ast);
return flowVariables;
}
// AST-based debugging traits detector
function detectDebuggingTraitsAST(code, level = 1, options = {}) {
const issues = [];
// Handle null, undefined, or empty input
if (!code || typeof code !== 'string') {
return issues;
}
const lines = code.split('\n');
// Parse ignore directives
const { ignoreRegions, ignoreLines, ignoreNextLines } = parseIgnoreDirectives(lines);
let ast;
try {
// Try parsing as a script first (allows top-level returns in Node-RED context)
try {
ast = parseScript(code, {
loc: true,
ranges: true,
module: false,
webcompat: true
});
} catch (scriptError) {
// If script parsing fails due to top-level return, wrap in function
if (scriptError.message.includes('Illegal return statement')) {
const wrappedCode = `function nodeRedWrapper() {\n${code}\n}`;
ast = parseScript(wrappedCode, {
loc: true,
ranges: true,
module: false,
webcompat: true
});
// Adjust line numbers for wrapped code
adjustLineNumbers(ast, -1);
} else {
throw scriptError;
}
}
} catch (error) {
// If AST parsing fails, return empty array
if (options.verbose) {
// eslint-disable-next-line no-console
console.warn('AST parsing failed:', error.message);
}
return [];
}
// Traverse the AST to find issues
function traverse(node, parent = null, depth = 0) {
if (!node || typeof node !== 'object' || !node.type) return;
const nodeLineNumber = node.loc ? node.loc.start.line : null;
// Skip if this line should be ignored
if (nodeLineNumber && shouldIgnoreLine(nodeLineNumber, ignoreRegions, ignoreLines, ignoreNextLines)) {
return;
}
// Level 1: Detect top-level return statements (ONLY empty returns)
if (level >= 1 && node.type === 'ReturnStatement') {
// IMPORTANT: Only flag empty returns (debugging artifacts), not returns with values
const isEmptyReturn = !node.argument || node.argument === null;
if (isEmptyReturn) {
// Check if this return is TRULY at the top level (not inside any control structures)
let currentNode = node;
let isInNestedFunction = false;
let isInControlStructure = false;
let isAtTopLevel = false;
while (currentNode && currentNode.parent) {
const parentNode = currentNode.parent;
// If we find a function declaration/expression that's not our wrapper, we're nested
if ((parentNode.type === 'FunctionDeclaration' ||
parentNode.type === 'FunctionExpression' ||
parentNode.type === 'ArrowFunctionExpression') &&
!(parentNode.type === 'FunctionDeclaration' &&
parentNode.id && parentNode.id.name === 'nodeRedWrapper')) {
isInNestedFunction = true;
break;
}
// Check if we're inside any control structure
if (parentNode.type === 'IfStatement' ||
parentNode.type === 'ForStatement' ||
parentNode.type === 'WhileStatement' ||
parentNode.type === 'DoWhileStatement' ||
parentNode.type === 'ForInStatement' ||
parentNode.type === 'ForOfStatement' ||
parentNode.type === 'SwitchStatement' ||
parentNode.type === 'TryStatement' ||
parentNode.type === 'CatchClause' ||
parentNode.type === 'WithStatement') {
isInControlStructure = true;
}
// If we reach the Program or our wrapper function, we're at top level
if (parentNode.type === 'Program' ||
(parentNode.type === 'FunctionDeclaration' &&
parentNode.id && parentNode.id.name === 'nodeRedWrapper')) {
isAtTopLevel = true;
break;
}
currentNode = parentNode;
}
// Debug logging (if enabled)
if (options && options.debug) {
// eslint-disable-next-line no-console
console.log('Return statement found:', {
line: nodeLineNumber,
parentType: parent ? parent.type : 'no parent',
isEmptyReturn,
hasArgument: !!node.argument,
isInNestedFunction,
isInControlStructure,
isAtTopLevel,
parentParentType: parent && parent.parent ? parent.parent.type : 'no grandparent'
});
}
// Only flag empty returns that are at top level AND not in control structures AND not in nested functions
if (isAtTopLevel && !isInControlStructure && !isInNestedFunction) {
if (nodeLineNumber) {
issues.push({
type: 'top-level-return',
message: 'Remove this top-level return statement',
line: nodeLineNumber,
column: node.loc.start.column + 1,
endColumn: node.loc.end.column + 1,
severity: 'warning'
});
}
}
}
}
// Level 2: Detect console.log, node.warn, debugger statements, and unused variables
if (level >= 2) {
// Detect console.log and console.* calls
if (node.type === 'CallExpression' &&
node.callee && node.callee.type === 'MemberExpression' &&
node.callee.object && node.callee.object.name === 'console') {
if (nodeLineNumber) {
issues.push({
type: 'console-log',
message: `Remove this console.${node.callee.property.name}() debugging statement`,
line: nodeLineNumber,
column: node.loc.start.column + 1,
endColumn: node.loc.end.column + 1,
severity: 'info'
});
}
}
// Detect node.warn calls
if (node.type === 'CallExpression' &&
node.callee && node.callee.type === 'MemberExpression' &&
node.callee.object && node.callee.object.name === 'node' &&
node.callee.property && node.callee.property.name === 'warn') {
if (nodeLineNumber) {
issues.push({
type: 'node-warn',
message: 'Remove this node.warn() debugging statement',
line: nodeLineNumber,
column: node.loc.start.column + 1,
endColumn: node.loc.end.column + 1,
severity: 'info'
});
}
}
// Detect debugger statements
if (node.type === 'DebuggerStatement') {
if (nodeLineNumber) {
issues.push({
type: 'debugger-statement',
message: 'Remove this debugger statement',
line: nodeLineNumber,
column: node.loc.start.column + 1,
endColumn: node.loc.end.column + 1,
severity: 'warning'
});
}
}
}
// Level 3: Detect hardcoded test values
if (level >= 3) {
// Detect hardcoded string assignments
if (node.type === 'AssignmentExpression' &&
node.right && node.right.type === 'Literal' &&
typeof node.right.value === 'string') {
const value = node.right.value.toLowerCase();
if (value === 'test' || value === 'debug' || value === 'temp') {
if (nodeLineNumber) {
issues.push({
type: `hardcoded-${value}`,
message: `Remove hardcoded ${value} value`,
line: nodeLineNumber,
column: node.right.loc.start.column + 1,
endColumn: node.right.loc.end.column + 1,
severity: 'warning'
});
}
}
}
// Detect hardcoded variable declarations
if (node.type === 'VariableDeclarator' &&
node.init && node.init.type === 'Literal') {
if (typeof node.init.value === 'string') {
const value = node.init.value.toLowerCase();
if (value === 'test' || value === 'debug' || value === 'temp') {
if (nodeLineNumber) {
issues.push({
type: `hardcoded-${value}`,
message: `Remove hardcoded ${value} value`,
line: nodeLineNumber,
column: node.init.loc.start.column + 1,
endColumn: node.init.loc.end.column + 1,
severity: 'warning'
});
}
}
} else if (typeof node.init.value === 'number' && node.init.value === 123) {
if (nodeLineNumber) {
issues.push({
type: 'hardcoded-number',
message: 'Remove hardcoded test number',
line: nodeLineNumber,
column: node.init.loc.start.column + 1,
endColumn: node.init.loc.end.column + 1,
severity: 'warning'
});
}
}
}
}
// Recursively traverse child nodes (avoid infinite recursion)
const visited = new Set();
for (let key in node) {
if (Object.prototype.hasOwnProperty.call(node, key) && key !== 'parent' && key !== 'loc' && key !== 'range') {
let child = node[key];
if (Array.isArray(child)) {
child.forEach(item => {
if (item && typeof item === 'object' && item.type && !visited.has(item)) {
visited.add(item);
item.parent = node; // Set parent reference
traverse(item, node, depth + 1);
}
});
} else if (child && typeof child === 'object' && child.type && !visited.has(child)) {
visited.add(child);
child.parent = node; // Set parent reference
traverse(child, node, depth + 1);
}
}
}
}
traverse(ast);
// Level 2: Detect unused variables after AST traversal
if (level >= 2) {
const unusedVars = findUnusedVariables(ast);
issues.push(...unusedVars.filter(issue =>
!shouldIgnoreLine(issue.line, ignoreRegions, ignoreLines, ignoreNextLines)
));
}
// Level 3: Check for multiple empty lines (still needs line-by-line analysis)
if (level >= 3) {
let consecutiveEmptyLines = 0;
let emptyLineStart = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') {
if (consecutiveEmptyLines === 0) {
emptyLineStart = i;
}
consecutiveEmptyLines++;
} else {
if (consecutiveEmptyLines >= 2) {
// Check if the empty line region overlaps with any ignore regions
const regionStart = emptyLineStart + 1;
const regionEnd = emptyLineStart + consecutiveEmptyLines;
let shouldIgnoreRegion = false;
for (let lineNum = regionStart; lineNum <= regionEnd; lineNum++) {
if (shouldIgnoreLine(lineNum, ignoreRegions, ignoreLines, ignoreNextLines)) {
shouldIgnoreRegion = true;
break;
}
}
if (!shouldIgnoreRegion) {
issues.push({
type: 'multiple-empty-lines',
message: `Remove excessive empty lines (${consecutiveEmptyLines} consecutive empty lines)`,
line: regionStart,
endLine: regionEnd,
column: 1,
endColumn: 1,
severity: 'info'
});
}
}
consecutiveEmptyLines = 0;
}
}
if (consecutiveEmptyLines >= 2) {
// Check if the empty line region overlaps with any ignore regions
const regionStart = emptyLineStart + 1;
const regionEnd = emptyLineStart + consecutiveEmptyLines;
let shouldIgnoreRegion = false;
for (let lineNum = regionStart; lineNum <= regionEnd; lineNum++) {
if (shouldIgnoreLine(lineNum, ignoreRegions, ignoreLines, ignoreNextLines)) {
shouldIgnoreRegion = true;
break;
}
}
if (!shouldIgnoreRegion) {
issues.push({
type: 'multiple-empty-lines',
message: `Remove excessive empty lines (${consecutiveEmptyLines} consecutive empty lines)`,
line: regionStart,
endLine: regionEnd,
column: 1,
endColumn: 1,
severity: 'info'
});
}
}
}
// Level 2: Check for TODO/FIXME comments and consecutive inline comments
if (level >= 2) {
let consecutiveInlineComments = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Check for TODO/FIXME comments
const todoMatch = line.match(/(TODO|FIXME):/i);
if (todoMatch && !shouldIgnoreLine(lineNumber, ignoreRegions, ignoreLines, ignoreNextLines)) {
issues.push({
type: 'todo-comment',
message: `${todoMatch[1].toUpperCase()} comment found - consider resolving`,
line: lineNumber,
column: todoMatch.index + 1,
endColumn: line.length + 1,
severity: 'info'
});
}
// Check for consecutive inline comments (// style only, not /* */ style)
const trimmedLine = line.trim();
const inlineCommentMatch = trimmedLine.match(/^\/\/(.*)$/);
if (inlineCommentMatch) {
// This is an inline comment, add it to our consecutive tracker
consecutiveInlineComments.push({
line: lineNumber,
content: inlineCommentMatch[1].trim(),
originalLine: line
});
} else if (trimmedLine === '' && consecutiveInlineComments.length > 0) {
// Empty line - continue tracking but don't reset (allows for spacing)
continue;
} else {
// Non-comment, non-empty line - check if we have consecutive comments to report
if (consecutiveInlineComments.length >= 2) {
// Check if any of these lines should be ignored
let shouldIgnoreGroup = false;
for (const comment of consecutiveInlineComments) {
if (shouldIgnoreLine(comment.line, ignoreRegions, ignoreLines, ignoreNextLines)) {
shouldIgnoreGroup = true;
break;
}
}
if (!shouldIgnoreGroup) {
const firstComment = consecutiveInlineComments[0];
const lastComment = consecutiveInlineComments[consecutiveInlineComments.length - 1];
issues.push({
type: 'consecutive-inline-comments',
message: `Consider removing this commented-out code if no longer needed (${consecutiveInlineComments.length} lines)`,
line: firstComment.line,
endLine: lastComment.line,
column: 1,
endColumn: lastComment.originalLine.length + 1,
severity: 'info',
count: consecutiveInlineComments.length
});
}
}
// Reset the consecutive comments tracker
consecutiveInlineComments = [];
}
}
// Handle case where file ends with consecutive inline comments
if (consecutiveInlineComments.length >= 2) {
let shouldIgnoreGroup = false;
for (const comment of consecutiveInlineComments) {
if (shouldIgnoreLine(comment.line, ignoreRegions, ignoreLines, ignoreNextLines)) {
shouldIgnoreGroup = true;
break;
}
}
if (!shouldIgnoreGroup) {
const firstComment = consecutiveInlineComments[0];
const lastComment = consecutiveInlineComments[consecutiveInlineComments.length - 1];
issues.push({
type: 'consecutive-inline-comments',
message: `Consider removing this commented-out code if no longer needed (${consecutiveInlineComments.length} lines)`,
line: firstComment.line,
endLine: lastComment.line,
column: 1,
endColumn: lastComment.originalLine.length + 1,
severity: 'info',
count: consecutiveInlineComments.length
});
}
}
}
return issues;
}
module.exports = {
detectDebuggingTraitsAST,
parseIgnoreDirectives,
shouldIgnoreLine,
findFlowVariables,
adjustLineNumbers
};