@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
288 lines (245 loc) • 8.31 kB
JavaScript
/**
* C024 Symbol-based Analyzer - Advanced Do not scatter hardcoded constants throughout the logic
* Purpose: The rule prevents scattering hardcoded constants throughout the logic. Instead, constants should be defined in a single place to improve maintainability and readability.
*/
const { SyntaxKind } = require('ts-morph');
class C024SymbolBasedAnalyzer {
constructor(semanticEngine = null) {
this.ruleId = 'C024';
this.ruleName = 'Error Scatter hardcoded constants throughout the logic (Symbol-Based)';
this.semanticEngine = semanticEngine;
this.verbose = false;
this.safeStrings = ["UNKNOWN", "N/A"]; // allowlist of special fallback values
}
async initialize(semanticEngine = null) {
if (semanticEngine) {
this.semanticEngine = semanticEngine;
}
this.verbose = semanticEngine?.verbose || false;
if (process.env.SUNLINT_DEBUG) {
console.log(`🔧 [C024 Symbol-Based] Analyzer initialized, verbose: ${this.verbose}`);
}
}
async analyzeFileBasic(filePath, options = {}) {
// This is the main entry point called by the hybrid analyzer
return await this.analyzeFileWithSymbols(filePath, options);
}
async analyzeFileWithSymbols(filePath, options = {}) {
const violations = [];
// Enable verbose mode if requested
const verbose = options.verbose || this.verbose;
if (!this.semanticEngine?.project) {
if (verbose) {
console.warn('[C024 Symbol-Based] No semantic engine available, skipping analysis');
}
return violations;
}
if (verbose) {
console.log(`🔍 [C024 Symbol-Based] Starting analysis for ${filePath}`);
}
try {
// skip ignored files
if (this.isIgnoredFile(filePath)) {
if (verbose) {
console.log(`🔍 [C024 Symbol-Based] Skipping ignored file: ${filePath}`);
}
return violations;
}
const sourceFile = this.semanticEngine.project.getSourceFile(filePath);
if (!sourceFile) {
return violations;
}
// skip constants files
if (this.isConstantsFile(filePath)) return violations;
// Detect hardcoded constants
sourceFile.forEachDescendant((node) => {
this.checkLiterals(node, sourceFile, violations);
this.checkConstDeclaration(node, sourceFile, violations);
this.checkStaticReadonly(node, sourceFile, violations);
});
if (verbose) {
console.log(`🔍 [C024 Symbol-Based] Total violations found: ${violations.length}`);
}
return violations;
} catch (error) {
if (verbose) {
console.warn(`[C024 Symbol-Based] Analysis failed for ${filePath}:`, error.message);
}
return violations;
}
}
// --- push violation object ---
pushViolation(violations, node, filePath, text, message) {
violations.push({
ruleId: this.ruleId,
severity: "warning",
message: message || `Hardcoded constant found: "${text}"`,
source: this.ruleId,
file: filePath,
line: node.getStartLineNumber(),
column: node.getStart() - node.getStartLinePos(),
description:
"[SYMBOL-BASED] Hardcoded constants should be defined in a single place to improve maintainability.",
suggestion: "Define constants in a dedicated file or section",
category: "constants",
});
}
// --- check literals like "ADMIN", 123, true ---
checkLiterals(node, sourceFile, violations) {
const kind = node.getKind();
if (
kind === SyntaxKind.StringLiteral ||
kind === SyntaxKind.NumericLiteral
) {
const text = node.getText().replace(/['"`]/g, ""); // strip quotes
if (this.isAllowedLiteral(node, text)) return;
this.pushViolation(
violations,
node,
sourceFile.getFilePath(),
node.getText()
);
}
}
// --- check const declarations outside constants.ts ---
checkConstDeclaration(node, sourceFile, violations) {
const kind = node.getKind();
if (kind === SyntaxKind.VariableDeclaration) {
const parentKind = node.getParent()?.getKind();
// Skip detection for `for ... of` loop variable
const loopAncestor = node.getFirstAncestor((ancestor) => {
const kind = ancestor.getKind?.();
return (
kind === SyntaxKind.ForOfStatement ||
kind === SyntaxKind.ForInStatement ||
kind === SyntaxKind.ForStatement ||
kind === SyntaxKind.WhileStatement ||
kind === SyntaxKind.DoStatement ||
kind === SyntaxKind.SwitchStatement
);
});
if (loopAncestor) {
return; // skip for all loop/switch contexts, no matter how nested
}
if (
parentKind === SyntaxKind.VariableDeclarationList &&
node.getParent().getDeclarationKind() === "const" &&
!node.getInitializer()
) {
this.pushViolation(
violations,
node,
sourceFile.getFilePath(),
node.getName(),
`Const declaration "${node.getName()}" should be moved into constants file`
);
}
}
}
// --- check static readonly properties inside classes ---
checkStaticReadonly(node, sourceFile, violations) {
const kind = node.getKind();
if (kind === SyntaxKind.PropertyDeclaration) {
const modifiers = node.getModifiers().map((m) => m.getText());
if (modifiers.includes("static") && modifiers.includes("readonly")) {
this.pushViolation(
violations,
node,
sourceFile.getFilePath(),
node.getName(),
`Static readonly property "${node.getName()}" should be moved into constants file`
);
}
}
}
// --- helper: allow safe literals ---
isAllowedLiteral(node, text) {
const parent = node.getParent();
// 1 Skip imports/exports
if (parent?.getKind() === SyntaxKind.ImportDeclaration) return true;
if (parent?.getKind() === SyntaxKind.ExportDeclaration) return true;
// 2 Skip literals that are inside call expressions (direct or nested)
if (
parent?.getKind() === SyntaxKind.CallExpression ||
parent?.getFirstAncestorByKind(SyntaxKind.CallExpression)
) {
return true;
}
if (
parent?.getKind() === SyntaxKind.ElementAccessExpression &&
parent.getArgumentExpression?.() === node
) {
return true; // skip array/object key
}
// 3 Allow short strings
if (typeof text === "string" && text.length <= 1) return true;
// 4 Allow sentinel numbers
if (text === "0" || text === "1" || text === "-1") return true;
// 5 Allow known safe strings (like "UNKNOWN")
if (this.safeStrings.includes(text)) return true;
// 6 Allow SQL-style placeholders (:variable) inside string/template
if (typeof text === "string" && /:\w+/.test(text)) {
return true;
}
return false;
}
// helper to check if file is a constants file
isConstantsFile(filePath) {
const lower = filePath.toLowerCase();
// common suffixes/patterns for utility or structural files
const ignoredSuffixes = [
".constants.ts",
".const.ts",
".enum.ts",
".interface.ts",
".response.ts",
".request.ts",
".res.ts",
".req.ts",
];
// 1 direct suffix match
if (ignoredSuffixes.some(suffix => lower.endsWith(suffix))) {
return true;
}
// 2 matches dto.xxx.ts (multi-dot dto files)
if (/\.dto\.[^.]+\.ts$/.test(lower)) {
return true;
}
// 3 matches folder-based conventions
if (
lower.includes("/constants/") ||
lower.includes("/enums/") ||
lower.includes("/interfaces/")
) {
return true;
}
return false;
}
isIgnoredFile(filePath) {
const ignoredPatterns = [
/\.test\./i,
/\.tests\./i,
/\.spec\./i,
/\.mock\./i,
/\.css$/i,
/\.scss$/i,
/\.html$/i,
/\.json$/i,
/\.md$/i,
/\.svg$/i,
/\.png$/i,
/\.jpg$/i,
/\.jpeg$/i,
/\.gif$/i,
/\.bmp$/i,
/\.ico$/i,
/\.lock$/i,
/\.log$/i,
/\/test\//i,
/\/tests\//i,
/\/spec\//i
];
return ignoredPatterns.some((regex) => regex.test(filePath));
}
}
module.exports = C024SymbolBasedAnalyzer;