@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
641 lines (531 loc) • 23.5 kB
JavaScript
/**
* Symbol-based analyzer for C013 - No Dead Code
* Purpose: Detect commented out code, unused variables/functions, and unreachable code using AST
*/
const { SyntaxKind } = require('ts-morph');
class C013SymbolBasedAnalyzer {
constructor(semanticEngine = null) {
this.ruleId = 'C013';
this.ruleName = 'No Dead Code (Symbol-Based)';
this.semanticEngine = semanticEngine;
this.verbose = false;
}
initialize(options = {}) {
if (options.semanticEngine) {
this.semanticEngine = options.semanticEngine;
}
this.verbose = options.verbose || false;
if (this.verbose) {
console.log(`[DEBUG] 🔧 C013 Symbol-Based: Analyzer initialized`);
}
}
async analyze(files, language, options = {}) {
const violations = [];
if (process.env.SUNLINT_DEBUG) {
console.log(`[C013 Symbol-Based] Starting analysis for ${files.length} files`);
console.log(`[C013 Symbol-Based] Semantic engine available: ${!!this.semanticEngine}`);
console.log(`[C013 Symbol-Based] Options semantic engine: ${!!options.semanticEngine}`);
}
// Use semantic engine from options if not already set
if (!this.semanticEngine && options.semanticEngine) {
this.semanticEngine = options.semanticEngine;
}
if (!this.semanticEngine?.project) {
if (this.verbose || process.env.SUNLINT_DEBUG) {
console.warn('[C013 Symbol-Based] No semantic engine available, skipping analysis');
}
return violations;
}
for (const filePath of files) {
try {
if (process.env.SUNLINT_DEBUG) {
console.log(`[C013 Symbol-Based] Analyzing file: ${filePath}`);
}
const sourceFile = this.semanticEngine.project.getSourceFile(filePath);
if (!sourceFile) {
if (process.env.SUNLINT_DEBUG) {
console.warn(`[C013 Symbol-Based] Could not load source file: ${filePath}`);
}
continue;
}
// 1. Check for commented out code
const commentedCodeViolations = this.detectCommentedOutCode(sourceFile, filePath);
violations.push(...commentedCodeViolations);
// 2. Check for unused variables
const unusedVariableViolations = this.detectUnusedVariables(sourceFile, filePath);
violations.push(...unusedVariableViolations);
// 3. Check for unused functions
const unusedFunctionViolations = this.detectUnusedFunctions(sourceFile, filePath);
violations.push(...unusedFunctionViolations);
// 4. Check for unreachable code
const unreachableCodeViolations = this.detectUnreachableCode(sourceFile, filePath);
violations.push(...unreachableCodeViolations);
} catch (error) {
if (process.env.SUNLINT_DEBUG) {
console.error(`[C013 Symbol-Based] Error analyzing ${filePath}:`, error);
}
}
}
return violations;
}
detectCommentedOutCode(sourceFile, filePath) {
const violations = [];
const text = sourceFile.getFullText();
const lines = text.split('\n');
const codePatterns = [
/function\s+\w+/,
/const\s+\w+\s*=/,
/let\s+\w+\s*=/,
/var\s+\w+\s*=/,
/if\s*\(/,
/for\s*\(/,
/while\s*\(/,
/return\s+/,
/console\./,
/import\s+/,
/export\s+/,
/class\s+\w+/,
/interface\s+\w+/,
/type\s+\w+\s*=/
];
const processedLines = new Set(); // Track lines we've already processed
for (let i = 0; i < lines.length; i++) {
// Skip if this line was already processed as part of a block
if (processedLines.has(i)) {
continue;
}
const line = lines[i];
const trimmedLine = line.trim();
// Check single line comments and group consecutive ones
if (trimmedLine.startsWith('//')) {
const startLine = i;
let endLine = i;
let blockLines = [];
// Collect consecutive comment lines with their content, including those separated by empty lines
for (let j = i; j < lines.length; j++) {
const currentLine = lines[j].trim();
// Stop if we hit a non-comment, non-empty line
if (!currentLine.startsWith('//') && currentLine !== '') {
break;
}
// Skip empty lines but continue processing
if (currentLine === '') {
continue;
}
const content = currentLine.substring(2).trim();
const isLikelyCode = content.length >= 10 && this.looksLikeCode(content, codePatterns) && !this.isDocumentationComment(content);
const isShortCodeLine = content.length >= 3 && (
/^[A-Z_]+:\s*['"][^'"]*['"],?$/.test(content) || // Object property like: JASPA: '0',
/^[})\]];?$/.test(content) || // Closing braces/brackets
/^[{(\[]$/.test(content) || // Opening braces/brackets
/^return\s+.*;?$/.test(content) || // return statements
/^default:\s*$/.test(content) || // switch default
/^case\s+.*:$/.test(content) // switch cases
);
blockLines.push({
lineIndex: j,
content: content,
fullLine: lines[j],
isCode: isLikelyCode,
isShortCodeLine: isShortCodeLine
});
// Debug log for mappingWorkPartsRow.ts
if (filePath.includes('mappingWorkPartsRow.ts') && process.env.SUNLINT_DEBUG) {
console.log(`Line ${j+1}: "${content}" -> isCode: ${content.length >= 10 && this.looksLikeCode(content, codePatterns) && !this.isDocumentationComment(content)}`);
}
endLine = j;
processedLines.add(j); // Mark as processed
}
// Find consecutive code sections within the block
// For function-like blocks, group the entire block together
const blockContent = blockLines.map(line => line.content).join('\n');
const hasFunction = /\bfunction\s+\w+\s*\(/.test(blockContent) ||
/\bconst\s+\w+.*=>\s*\{/s.test(blockContent) ||
/\blet\s+\w+.*=>\s*\{/s.test(blockContent) ||
/\bvar\s+\w+.*=>\s*\{/s.test(blockContent) ||
/\bclass\s+\w+/.test(blockContent) ||
/\bit\s*\(\s*['"`].*['"`]\s*,\s*async\s*\(\s*\)\s*=>/s.test(blockContent) || // Jest it() async
/\bit\s*\(\s*['"`].*['"`]\s*,\s*\(\s*\)\s*=>/s.test(blockContent) || // Jest it() sync
/\bdescribe\s*\(\s*['"`].*['"`]\s*,\s*\(\s*\)\s*=>/s.test(blockContent) || // Jest describe()
/\btest\s*\(\s*['"`].*['"`]\s*,\s*async\s*\(\s*\)\s*=>/s.test(blockContent) || // Jest test() async
/\btest\s*\(\s*['"`].*['"`]\s*,\s*\(\s*\)\s*=>/s.test(blockContent); // Jest test() sync
const hasAnyCode = blockLines.some(line => line.isCode);
if (filePath.includes('BillingList.test.tsx') && process.env.SUNLINT_DEBUG) {
console.log(`[DEBUG] Block analysis: hasFunction=${hasFunction}, hasAnyCode=${hasAnyCode}, blockSize=${blockLines.length}`);
console.log(`[DEBUG] Block content snippet: "${blockContent.substring(0, 100)}..."`);
console.log(`[DEBUG] Jest patterns test:
- it async: ${/\bit\s*\(\s*['"`].*['"`]\s*,\s*async\s*\(\s*\)\s*=>/s.test(blockContent)}
- it sync: ${/\bit\s*\(\s*['"`].*['"`]\s*,\s*\(\s*\)\s*=>/s.test(blockContent)}
- test async: ${/\btest\s*\(\s*['"`].*['"`]\s*,\s*async\s*\(\s*\)\s*=>/s.test(blockContent)}
- describe: ${/\bdescribe\s*\(\s*['"`].*['"`]\s*,\s*\(\s*\)\s*=>/s.test(blockContent)}`);
}
if (hasFunction && hasAnyCode) {
// For function blocks, group everything together
const firstCodeLineIndex = blockLines.findIndex(line => line.isCode);
const lastCodeLineIndex = blockLines.map((line, idx) => line.isCode ? idx : -1)
.filter(idx => idx !== -1)
.pop();
if (firstCodeLineIndex !== -1 && lastCodeLineIndex !== -1) {
// Use the very first line of the block instead of first code line for better accuracy
const startLineIndex = blockLines[0].lineIndex; // Start from beginning of comment block
const totalLines = lastCodeLineIndex - firstCodeLineIndex + 1;
if (filePath.includes('BillingList.test.tsx') && process.env.SUNLINT_DEBUG) {
console.log(`[DEBUG] Function block grouped: startLine=${startLineIndex + 1}, firstCodeLine=${blockLines[firstCodeLineIndex].lineIndex + 1}, totalLines=${totalLines}`);
}
violations.push(this.createViolation(
filePath,
startLineIndex + 1, // Use start of comment block, not first code line
blockLines[0].fullLine.indexOf('//') + 1, // Column of first comment line
`Commented out code block detected (${totalLines} lines). Remove dead code or use Git for version history.`,
'commented-code'
));
}
} else {
// Original logic for non-function blocks
let currentCodeStart = -1;
let currentCodeEnd = -1;
let hasCodeInBlock = false;
for (let k = 0; k < blockLines.length; k++) {
const lineInfo = blockLines[k];
if (lineInfo.isCode) {
hasCodeInBlock = true;
}
// A line is considered part of code if it's actual code OR short code line in a code context
const isPartOfCode = lineInfo.isCode || (lineInfo.isShortCodeLine && hasCodeInBlock);
if (isPartOfCode) {
if (currentCodeStart === -1) {
// Start new code section
currentCodeStart = k;
currentCodeEnd = k;
} else {
// Extend current code section
currentCodeEnd = k;
}
} else {
// Non-code line - if we have a code section, report it
if (currentCodeStart !== -1) {
const codeStartLine = blockLines[currentCodeStart].lineIndex;
const codeCount = currentCodeEnd - currentCodeStart + 1;
const message = codeCount > 1
? `Commented out code block detected (${codeCount} lines). Remove dead code or use Git for version history.`
: `Commented out code detected. Remove dead code or use Git for version history.`;
violations.push(this.createViolation(
filePath,
codeStartLine + 1, // Convert to 1-based line number
blockLines[currentCodeStart].fullLine.indexOf('//') + 1,
message,
'commented-code'
));
// Reset for next code section
currentCodeStart = -1;
currentCodeEnd = -1;
hasCodeInBlock = false;
}
}
}
// Report any remaining code section
if (currentCodeStart !== -1) {
const codeStartLine = blockLines[currentCodeStart].lineIndex;
const codeCount = currentCodeEnd - currentCodeStart + 1;
const message = codeCount > 1
? `Commented out code block detected (${codeCount} lines). Remove dead code or use Git for version history.`
: `Commented out code detected. Remove dead code or use Git for version history.`;
violations.push(this.createViolation(
filePath,
codeStartLine + 1, // Convert to 1-based line number
blockLines[currentCodeStart].fullLine.indexOf('//') + 1,
message,
'commented-code'
));
}
}
// Don't forget the last code section from the original logic (this should already be handled above)
// This section can be removed as it's redundant
}
// Check multi-line comments (but skip JSDoc)
if (trimmedLine.startsWith('/*') && !trimmedLine.startsWith('/**') && !trimmedLine.includes('*/')) {
let commentBlock = '';
let endLine = i;
// Collect the full comment block
for (let j = i; j < lines.length; j++) {
commentBlock += lines[j] + '\n';
processedLines.add(j); // Mark as processed
if (lines[j].includes('*/')) {
endLine = j;
break;
}
}
// Clean the comment block
const cleanedComment = commentBlock
.replace(/\/\*|\*\/|\*/g, '')
.trim();
// Skip if it's documentation
if (this.isDocumentationComment(cleanedComment)) {
continue;
}
if (cleanedComment.length >= 20 && this.looksLikeCode(cleanedComment, codePatterns)) {
violations.push(this.createViolation(
filePath,
i + 1,
line.indexOf('/*') + 1,
`Commented out code block detected. Remove dead code or use Git for version history.`,
'commented-code-block'
));
}
}
}
return violations;
}
isDocumentationComment(text) {
// Check for JSDoc tags and documentation patterns
const docPatterns = [
/@param\b/,
/@returns?\b/,
/@example\b/,
/@description\b/,
/@see\b/,
/@throws?\b/,
/@since\b/,
/@author\b/,
/@version\b/,
/\* Sort an array/,
/\* Items are sorted/,
/\* @/,
/Result:/,
/Note:/
];
// If it contains documentation patterns, it's likely documentation
if (docPatterns.some(pattern => pattern.test(text))) {
return true;
}
// Check for common explanatory phrases
const explanatoryPhrases = [
'explanation',
'description',
'example',
'usage',
'note that',
'this function',
'this method',
'basic usage',
'with duplicate',
'items not found'
];
const lowerText = text.toLowerCase();
return explanatoryPhrases.some(phrase => lowerText.includes(phrase));
}
looksLikeCode(text, patterns) {
// Check if text matches code patterns
const matchCount = patterns.filter(pattern => pattern.test(text)).length;
// If it matches multiple patterns or contains typical code structure
if (matchCount >= 1) {
// Additional checks for code-like characteristics
const hasCodeStructure = (
text.includes('{') ||
text.includes(';') ||
text.includes('()') ||
text.includes('[]') ||
/\w+\s*=\s*\w+/.test(text) ||
/\w+\.\w+/.test(text)
);
return hasCodeStructure;
}
return false;
}
detectUnusedVariables(sourceFile, filePath) {
const violations = [];
// Get all variable declarations
const variableDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
for (const declaration of variableDeclarations) {
const name = declaration.getName();
// Skip variables with underscore prefix (conventional ignore)
if (name.startsWith('_') || name.startsWith('$')) {
continue;
}
// Skip destructured variables for now (complex analysis)
if (declaration.getNameNode().getKind() !== SyntaxKind.Identifier) {
continue;
}
// Skip exported variables (they might be used externally)
const variableStatement = declaration.getParent()?.getParent();
if (variableStatement && variableStatement.getKind() === SyntaxKind.VariableStatement) {
if (variableStatement.isExported()) {
continue;
}
}
// Check if variable is used
const usages = declaration.getNameNode().findReferences();
const isUsed = usages.some(ref =>
ref.getReferences().length > 1 // More than just the declaration itself
);
if (!isUsed) {
const line = sourceFile.getLineAndColumnAtPos(declaration.getStart()).line;
const column = sourceFile.getLineAndColumnAtPos(declaration.getStart()).column;
violations.push(this.createViolation(
filePath,
line,
column,
`Unused variable '${name}'. Remove dead code to keep codebase clean.`,
'unused-variable'
));
}
}
return violations;
}
detectUnusedFunctions(sourceFile, filePath) {
const violations = [];
// Get all function declarations
const functionDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration);
for (const func of functionDeclarations) {
const name = func.getName();
if (!name) continue; // Anonymous functions
// Skip functions with underscore prefix
if (name.startsWith('_')) {
continue;
}
// Skip exported functions (they might be used externally)
if (func.isExported()) {
continue;
}
// Check if function is used
const nameNode = func.getNameNode();
if (nameNode) {
const usages = nameNode.findReferences();
const isUsed = usages.some(ref =>
ref.getReferences().length > 1 // More than just the declaration itself
);
if (!isUsed) {
const line = sourceFile.getLineAndColumnAtPos(func.getStart()).line;
const column = sourceFile.getLineAndColumnAtPos(func.getStart()).column;
violations.push(this.createViolation(
filePath,
line,
column,
`Unused function '${name}'. Remove dead code to keep codebase clean.`,
'unused-function'
));
}
}
}
return violations;
}
detectUnreachableCode(sourceFile, filePath) {
const violations = [];
// Find all return, throw, break, continue statements
const terminatingStatements = [
...sourceFile.getDescendantsOfKind(SyntaxKind.ReturnStatement),
...sourceFile.getDescendantsOfKind(SyntaxKind.ThrowStatement),
...sourceFile.getDescendantsOfKind(SyntaxKind.BreakStatement),
...sourceFile.getDescendantsOfKind(SyntaxKind.ContinueStatement)
];
for (const statement of terminatingStatements) {
// Find the statement that contains this terminating statement
let containingStatement = statement;
let parent = statement.getParent();
// Walk up to find the statement that's directly in a block
while (parent && parent.getKind() !== SyntaxKind.Block && parent.getKind() !== SyntaxKind.SourceFile) {
containingStatement = parent;
parent = parent.getParent();
}
// Get the parent block
const parentBlock = parent;
if (!parentBlock || parentBlock.getKind() === SyntaxKind.SourceFile) continue;
// Find all statements in the same block after this terminating statement
const allStatements = parentBlock.getStatements();
const currentIndex = allStatements.indexOf(containingStatement);
if (currentIndex >= 0 && currentIndex < allStatements.length - 1) {
// Check statements after the terminating statement
for (let i = currentIndex + 1; i < allStatements.length; i++) {
const nextStatement = allStatements[i];
// Skip comments and empty statements
if (nextStatement.getKind() === SyntaxKind.EmptyStatement) {
continue;
}
// Don't flag catch/finally blocks as unreachable
if (this.isInTryCatchFinally(nextStatement)) {
continue;
}
// Skip if this is within a conditional (if/else) or loop that might not execute
if (this.isConditionallyReachable(containingStatement, nextStatement)) {
continue;
}
const line = sourceFile.getLineAndColumnAtPos(nextStatement.getStart()).line;
const column = sourceFile.getLineAndColumnAtPos(nextStatement.getStart()).column;
violations.push(this.createViolation(
filePath,
line,
column,
`Unreachable code detected after ${statement.getKindName().toLowerCase()}. Remove dead code.`,
'unreachable-code'
));
break; // Only flag the first unreachable statement to avoid spam
}
}
}
return violations;
}
isInTryCatchFinally(node) {
// Check if the node is inside a try-catch-finally block
let parent = node.getParent();
while (parent) {
if (parent.getKind() === SyntaxKind.TryStatement) {
return true;
}
if (parent.getKind() === SyntaxKind.CatchClause) {
return true;
}
parent = parent.getParent();
}
return false;
}
isConditionallyReachable(terminatingStatement, nextStatement) {
// Check if the terminating statement is within an arrow function expression
// or other expression that shouldn't be considered as blocking execution
let current = terminatingStatement;
while (current) {
const kind = current.getKind();
// If the terminating statement is in an arrow function or function expression,
// it doesn't block the execution of subsequent statements in the parent scope
if (kind === SyntaxKind.ArrowFunction ||
kind === SyntaxKind.FunctionExpression ||
kind === SyntaxKind.ConditionalExpression) {
return true;
}
// If we're in an if statement, the return might be conditional
if (kind === SyntaxKind.IfStatement) {
// Check if this is a complete if-else that covers all paths
const ifStatement = current;
const elseStatement = ifStatement.getElseStatement();
// If there's no else, or else doesn't have a return, then subsequent code is reachable
if (!elseStatement || !this.hasUnconditionalReturn(elseStatement)) {
return true;
}
}
current = current.getParent();
}
return false;
}
hasUnconditionalReturn(node) {
// Check if a node has an unconditional return statement
if (node.getKind() === SyntaxKind.ReturnStatement) {
return true;
}
if (node.getKind() === SyntaxKind.Block) {
const statements = node.getStatements();
return statements.some(stmt => this.hasUnconditionalReturn(stmt));
}
return false;
}
createViolation(filePath, line, column, message, type) {
return {
file: filePath,
line: line,
column: column,
message: message,
severity: 'warning',
ruleId: this.ruleId,
type: type
};
}
}
module.exports = C013SymbolBasedAnalyzer;