Last active
March 5, 2025 17:39
-
-
Save tomastrajan/678a027a926a2c6aad1a5783e1d8d2b2 to your computer and use it in GitHub Desktop.
no-unused-component-methods.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const fs = require('fs'); | |
const path = require('path'); | |
const EXCLUDED_METHODS = [ | |
'constructor', | |
'writeValue', | |
'registerOnChange', | |
'registerOnTouched', | |
'setDisabledState', | |
]; | |
module.exports = { | |
meta: { | |
type: 'problem', | |
docs: { | |
description: | |
'Detect unused Angular component methods that are not public, not used in the template, not called inside the class, or not referenced in the host:{} property.', | |
category: 'Best Practices', | |
recommended: false, | |
}, | |
schema: [], // No options needed | |
messages: { | |
unusedMethod: | |
"Method '{{ methodName }}' is not used in the template, other methods, host bindings, or explicitly marked as public.", | |
}, | |
}, | |
create(context) { | |
return { | |
ClassDeclaration(node) { | |
const filename = context.getFilename(); | |
if (!filename.endsWith('.component.ts')) return; | |
let methods = new Map(); // Stores method name -> method node | |
let calledMethods = new Set(); | |
let templateContent = ''; | |
let hostBindings = ''; | |
// Step 1: Extract methods from the component class | |
node.body.body.forEach((member) => { | |
if ( | |
member.type === 'MethodDefinition' && | |
member.key.type === 'Identifier' && | |
!EXCLUDED_METHODS.includes(member.key.name) | |
) { | |
const methodName = member.key.name; | |
const isPublic = member.accessibility === 'public'; | |
methods.set(methodName, { | |
node: member, | |
isPublic, | |
}); | |
} | |
}); | |
// Step 2: Find method calls inside class methods using controlled DFS traversal | |
node.body.body.forEach((member) => { | |
if ( | |
member.type === 'MethodDefinition' && | |
(member.value.type === 'FunctionExpression' || | |
member.value.type === 'ArrowFunctionExpression') | |
) { | |
findMethodCallsInClass(member.value, calledMethods, methods, 5); | |
} | |
}); | |
// Step 3: Find the @Component decorator | |
const decorators = node.decorators || []; | |
const componentDecorator = decorators.find((decorator) => { | |
return ( | |
decorator.expression && | |
decorator.expression.callee && | |
decorator.expression.callee.name === 'Component' | |
); | |
}); | |
if (!componentDecorator) return; | |
// Step 4: Extract template file, inline template, and host property | |
const componentArg = componentDecorator.expression.arguments[0]; | |
if (!componentArg || componentArg.type !== 'ObjectExpression') return; | |
componentArg.properties.forEach((property) => { | |
if (property.key.name === 'templateUrl') { | |
let templatePathRaw = ''; | |
if (property.value.type === 'Literal') { | |
templatePathRaw = property.value.value; // Simple case | |
} else if (property.value.type === 'TemplateLiteral') { | |
// Extract template parts from quasis | |
templatePathRaw = property.value.quasis | |
.map((quasi) => quasi.value.cooked || quasi.value.raw) | |
.join(''); // Join all static parts | |
} | |
// Read external template | |
const templatePath = path.resolve( | |
path.dirname(filename), | |
templatePathRaw, | |
); | |
if (fs.existsSync(templatePath)) { | |
templateContent = fs.readFileSync(templatePath, 'utf-8'); | |
} | |
} else if (property.key.name === 'template') { | |
// Handle both Literal and TemplateLiteral | |
if (property.value.type === 'Literal') { | |
templateContent = property.value.value; // Simple case | |
} else if (property.value.type === 'TemplateLiteral') { | |
// Extract template parts from quasis | |
templateContent = property.value.quasis | |
.map((quasi) => quasi.value.cooked || quasi.value.raw) | |
.join(''); // Join all static parts | |
} | |
} | |
if ( | |
property.key.name === 'host' && | |
property.value.type === 'ObjectExpression' | |
) { | |
// Extract host bindings | |
property.value.properties.forEach((hostProp) => { | |
if (hostProp.value.type === 'Literal') { | |
hostBindings += hostProp.value.value + ' '; | |
} | |
}); | |
} | |
}); | |
// Step 5: Check which methods are used in the template | |
methods.forEach((methodData, methodName) => { | |
const methodRegex = new RegExp(`\\b${methodName}\\b`, 'g'); | |
if (methodRegex.test(templateContent)) { | |
calledMethods.add(methodName); | |
} | |
}); | |
// Step 6: Check which methods are used in host bindings | |
methods.forEach((methodData, methodName) => { | |
const methodRegex = new RegExp(`\\b${methodName}\\b`, 'g'); | |
if (methodRegex.test(hostBindings)) { | |
calledMethods.add(methodName); | |
} | |
}); | |
// Step 7: Report unused methods that are neither public nor used | |
methods.forEach((methodData, methodName) => { | |
if (!methodData.isPublic && !calledMethods.has(methodName)) { | |
context.report({ | |
node: methodData.node, | |
messageId: 'unusedMethod', | |
data: { methodName }, | |
}); | |
} | |
}); | |
}, | |
}; | |
}, | |
}; | |
/** | |
* Traverses an AST method node (DFS) to find method calls inside a class. | |
* Uses an explicit stack to prevent deep recursion. | |
*/ | |
function findMethodCallsInClass( | |
methodNode, | |
calledMethods, | |
methods, | |
maxDepth = 5, | |
) { | |
if (!methodNode || !methodNode.body || !methodNode.body.body) return; | |
const stack = methodNode.body.body.map((node) => ({ node, depth: 0 })); // Initialize stack with top-level nodes | |
while (stack.length > 0) { | |
const { node, depth } = stack.pop(); | |
if (depth > maxDepth) continue; // Stop deep traversal | |
if ( | |
node.type === 'ExpressionStatement' && | |
node.expression.type === 'CallExpression' | |
) { | |
const callExpression = node.expression; | |
// Check if this is a `this.someMethod()` call | |
if ( | |
callExpression.callee.type === 'MemberExpression' && | |
callExpression.callee.object.type === 'ThisExpression' && | |
callExpression.callee.property.type === 'Identifier' | |
) { | |
const methodName = callExpression.callee.property.name; | |
if (methods.has(methodName)) { | |
calledMethods.add(methodName); | |
} | |
} | |
// Process all arguments of the call expression | |
callExpression.arguments.forEach((arg) => { | |
if ( | |
arg.type === 'CallExpression' && // If an argument is another function call | |
arg.callee.type === 'MemberExpression' && | |
arg.callee.object.type === 'ThisExpression' && | |
arg.callee.property.type === 'Identifier' | |
) { | |
const methodName = arg.callee.property.name; | |
if (methods.has(methodName)) { | |
calledMethods.add(methodName); | |
} | |
} | |
}); | |
} | |
// Push child nodes onto stack with increased depth | |
for (const key in node) { | |
if (node[key] && typeof node[key] === 'object') { | |
if (Array.isArray(node[key])) { | |
node[key].forEach((childNode) => | |
stack.push({ node: childNode, depth: depth + 1 }), | |
); | |
} else { | |
stack.push({ node: node[key], depth: depth + 1 }); | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment