@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
851 lines (713 loc) • 27.5 kB
JavaScript
const fs = require('fs');
const path = require('path');
/**
* C065: One Behavior per Test (AAA Pattern)
*
* Detects:
* 1. Multiple independent assertions in single test (Level 1 heuristic)
* 2. Multiple "Act" operations in single test
* 3. Control flow statements (if/for/switch) in test methods
* 4. Unrelated expectations for different SUTs (Level 2 AST)
*
* Uses hybrid approach: Heuristic patterns + AST context analysis for accuracy
*/
class C065OneBehaviorPerTestAnalyzer {
constructor(config = null) {
this.ruleId = 'C065';
this.loadConfig(config);
// Compile regex patterns for performance
this.compiledPatterns = this.compilePatterns();
this.verbose = false;
}
loadConfig(config) {
try {
if (config && config.options) {
this.config = config;
this.assertApis = config.options.assertApis || {};
this.actHeuristics = config.options.actHeuristics || {};
this.controlFlow = config.options.controlFlow || [];
this.testPatterns = config.options.testPatterns || {};
this.parameterizedHints = config.options.parameterizedHints || [];
this.thresholds = config.options.thresholds || {};
this.flags = config.options.flags || {};
this.whitelist = config.options.whitelist || {};
this.allowlist = config.options.allowlist || { paths: [] };
} else {
// Load from config file
const configPath = path.join(__dirname, 'config.json');
const configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
this.loadConfig(configData);
}
} catch (error) {
console.warn(`[C065] Failed to load config: ${error.message}`);
this.initializeDefaultConfig();
}
}
initializeDefaultConfig() {
this.assertApis = {
javascript: ['expect\\(', 'assert\\.', 'should\\.'],
typescript: ['expect\\(', 'assert\\.', 'should\\.'],
java: ['assertThat\\(', 'assertEquals\\(', 'assertTrue\\(']
};
this.actHeuristics = {
common: ['sut\\.', '\\.execute\\(', '\\.handle\\(', '\\.run\\(', '\\.call\\(']
};
this.controlFlow = ['\\bif\\b', '\\bswitch\\b', '\\bfor\\b', '\\bwhile\\b'];
this.testPatterns = {
javascript: ['\\bit\\s*\\(', '\\btest\\s*\\('],
java: ['@Test']
};
this.thresholds = {
maxActsPerTest: 1,
maxUnrelatedExpects: 2,
maxControlFlowStatements: 0
};
this.flags = {
flagControlFlowInTest: true,
treatSnapshotAsSingleAssert: true,
allowMultipleAssertsForSameObject: true
};
this.allowlist = {
paths: ['test/', 'tests/', '__tests__/', 'spec/', 'specs/'],
filePatterns: ['\\.test\\.', '\\.spec\\.']
};
}
compilePatterns() {
const patterns = {
assertApis: {},
actHeuristics: {},
controlFlow: [],
testPatterns: {},
testFiles: []
};
// Compile assert API patterns by language
for (const [lang, regexes] of Object.entries(this.assertApis)) {
patterns.assertApis[lang] = regexes.map(regex => new RegExp(regex, 'gi'));
}
// Compile act heuristic patterns
for (const [lang, regexes] of Object.entries(this.actHeuristics)) {
patterns.actHeuristics[lang] = regexes.map(regex => new RegExp(regex, 'gi'));
}
// Compile control flow patterns
patterns.controlFlow = this.controlFlow.map(regex => new RegExp(regex, 'gi'));
// Compile test method patterns
for (const [lang, regexes] of Object.entries(this.testPatterns)) {
patterns.testPatterns[lang] = regexes.map(regex => new RegExp(regex, 'gi'));
}
// Compile test file patterns
if (this.allowlist.filePatterns) {
patterns.testFiles = this.allowlist.filePatterns.map(regex => new RegExp(regex, 'i'));
}
return patterns;
}
analyze(files, language, options = {}) {
this.verbose = options.verbose || false;
const violations = [];
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065 ANALYZE: Starting test behavior analysis`);
}
if (!Array.isArray(files)) {
files = [files];
}
for (const filePath of files) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Analyzing ${filePath.split('/').pop()}`);
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const fileExtension = path.extname(filePath);
const fileName = path.basename(filePath);
// Only analyze test files
if (this.isTestFile(filePath, fileName)) {
const fileViolations = this.analyzeFile(filePath, content, fileExtension, fileName);
violations.push(...fileViolations);
} else if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Skipping non-test file: ${fileName}`);
}
} catch (error) {
console.warn(`[C065] Error analyzing ${filePath}: ${error.message}`);
}
}
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Found ${violations.length} test behavior violations`);
}
return violations;
}
// Alias methods for different engines
run(filePath, content, options = {}) {
this.verbose = options.verbose || false;
const fileExtension = path.extname(filePath);
const fileName = path.basename(filePath);
return this.analyzeFile(filePath, content, fileExtension, fileName);
}
runAnalysis(filePath, content, options = {}) {
return this.run(filePath, content, options);
}
runEnhancedAnalysis(filePath, content, language, options = {}) {
return this.run(filePath, content, options);
}
analyzeFile(filePath, content, fileExtension, fileName) {
const language = this.detectLanguage(fileExtension, fileName);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Detected language: ${language}`);
}
// Only analyze test files
if (!this.isTestFile(filePath, fileName)) {
return [];
}
return this.analyzeTestBehaviors(filePath, content, language);
}
detectLanguage(fileExtension, fileName) {
const extensions = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.java': 'java',
'.cs': 'csharp',
'.swift': 'swift',
'.kt': 'kotlin',
'.py': 'python'
};
return extensions[fileExtension] || 'generic';
}
isTestFile(filePath, fileName) {
// Check file path for test directories
const allowedPaths = this.allowlist.paths || [];
const pathMatch = allowedPaths.some(path => filePath.includes(path));
// Check file name patterns
const filePatternMatch = this.compiledPatterns.testFiles.some(pattern =>
pattern.test(fileName) || pattern.test(filePath)
);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065 isTestFile: ${fileName}`);
console.log(`[DEBUG] 🧪 allowedPaths: ${JSON.stringify(allowedPaths)}`);
console.log(`[DEBUG] 🧪 pathMatch: ${pathMatch}`);
console.log(`[DEBUG] 🧪 filePatternMatch: ${filePatternMatch}`);
console.log(`[DEBUG] 🧪 final result: ${pathMatch || filePatternMatch}`);
}
return pathMatch || filePatternMatch;
}
analyzeTestBehaviors(filePath, content, language) {
const violations = [];
const lines = content.split('\n');
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Starting test behavior analysis of ${lines.length} lines`);
console.log(`[DEBUG] 🧪 C065: Language detected: ${language}`);
}
// Step 1: Extract test methods
const testMethods = this.extractTestMethods(content, lines, language);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Found ${testMethods.length} test methods`);
}
// Step 2: Analyze each test method
for (const testMethod of testMethods) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Analyzing test method: ${testMethod.name} (lines ${testMethod.startLine}-${testMethod.endLine})`);
}
const methodViolations = this.analyzeTestMethod(testMethod, filePath, language);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Found ${methodViolations.length} violations in ${testMethod.name}`);
}
violations.push(...methodViolations);
}
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Found ${violations.length} behavior violations total`);
}
return violations;
}
extractTestMethods(content, lines, language) {
const testMethods = [];
const patterns = this.compiledPatterns.testPatterns[language] || [];
for (const pattern of patterns) {
const matches = [...content.matchAll(pattern)];
for (const match of matches) {
const startLine = this.getLineNumber(content, match.index);
const matchText = match[0];
// Skip describe blocks - we only want individual test methods
if (matchText.includes('describe')) {
continue;
}
const methodContent = this.extractMethodBody(lines, startLine, language);
if (methodContent) {
testMethods.push({
name: matchText,
startLine: startLine,
endLine: methodContent.endLine,
content: methodContent.content,
lines: methodContent.lines
});
}
}
}
return testMethods;
}
extractMethodBody(lines, startLine, language) {
// TODO: Implement method body extraction logic
// For now, return a simple implementation
const methodLines = [];
let braceCount = 0;
let inMethod = false;
let endLine = startLine;
for (let i = startLine - 1; i < lines.length; i++) {
const line = lines[i];
if (line.includes('{')) {
braceCount += (line.match(/\{/g) || []).length;
inMethod = true;
}
if (inMethod) {
methodLines.push(line);
}
if (line.includes('}')) {
braceCount -= (line.match(/\}/g) || []).length;
if (braceCount <= 0 && inMethod) {
endLine = i + 1;
break;
}
}
}
return {
content: methodLines.join('\n'),
lines: methodLines,
endLine: endLine
};
}
analyzeTestMethod(testMethod, filePath, language) {
const violations = [];
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Analyzing test method: ${testMethod.name}`);
}
// Level 1: Heuristic analysis
const heuristicViolations = this.analyzeHeuristics(testMethod, filePath, language);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Heuristic violations: ${heuristicViolations.length}`);
}
violations.push(...heuristicViolations);
// Level 2: Context analysis (integrated into countAssertions)
// const contextViolations = this.analyzeContext(testMethod, filePath, language);
// console.log(`[DEBUG] 🧪 C065: Context violations: ${contextViolations.length}`);
// violations.push(...contextViolations);
return violations;
}
analyzeHeuristics(testMethod, filePath, language) {
const violations = [];
const content = testMethod.content;
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: analyzeHeuristics called for ${testMethod.name}`);
}
// Check 1: Count assertions
const assertCount = this.countAssertions(content, language);
// Check 2: Count acts
const actCount = this.countActs(content, language);
// Check 3: Check control flow
const controlFlowCount = this.countControlFlow(content);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Method stats - Asserts: ${assertCount}, Acts: ${actCount}, ControlFlow: ${controlFlowCount}`);
}
// Violation: Multiple unrelated assertions (context-based)
if (assertCount > this.thresholds.maxUnrelatedExpects) {
violations.push({
ruleId: this.ruleId,
message: `Test has ${assertCount} assertions spanning multiple contexts - tests should verify a single behavior`,
severity: 'warning',
line: testMethod.startLine,
column: 1,
filePath: filePath,
context: {
violationType: 'multiple_behaviors',
evidence: testMethod.name,
currentCount: assertCount,
maxAllowed: this.thresholds.maxUnrelatedExpects,
recommendation: 'Split this test into separate tests, each focusing on a single behavior/context'
}
});
}
// Violation: Too many acts (with special handling for UI tests)
const allowedActs = this.isUITestMethod(content) ? 3 : this.thresholds.maxActsPerTest;
if (actCount > allowedActs) {
violations.push({
ruleId: this.ruleId,
message: `Test has ${actCount} actions but should have max ${allowedActs} (Single Action principle)`,
severity: 'warning',
line: testMethod.startLine,
column: 1,
filePath: filePath,
context: {
violationType: 'multiple_acts',
evidence: testMethod.name,
currentCount: actCount,
maxAllowed: allowedActs,
recommendation: 'Split this test into multiple tests, each testing a single action/behavior'
}
});
}
// Violation: Control flow in test
if (this.flags.flagControlFlowInTest && controlFlowCount > this.thresholds.maxControlFlowStatements) {
violations.push({
ruleId: this.ruleId,
message: `Test contains ${controlFlowCount} control flow statements - tests should be linear and predictable`,
severity: 'warning',
line: testMethod.startLine,
column: 1,
filePath: filePath,
context: {
violationType: 'control_flow_in_test',
evidence: testMethod.name,
currentCount: controlFlowCount,
maxAllowed: this.thresholds.maxControlFlowStatements,
recommendation: 'Remove if/for/switch statements and create separate parameterized tests instead'
}
});
}
return violations;
}
countAssertions(content, language) {
const patterns = this.compiledPatterns.assertApis[language] || [];
let count = 0;
const assertions = [];
const lines = content.split('\n');
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Counting assertions in ${language}, patterns: ${patterns.length}`);
}
// Extract all assertion lines with context
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
for (const pattern of patterns) {
pattern.lastIndex = 0;
if (pattern.test(line)) {
assertions.push({
line: line,
lineNumber: i + 1,
subject: this.extractAssertionSubject(line)
});
count++;
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Found assertion ${count}: ${line.substring(0, 50)}...`);
}
break;
}
}
}
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Total assertions found: ${assertions.length}, threshold: ${this.thresholds.maxUnrelatedExpects}`);
}
// Analyze context similarity if multiple assertions
if (assertions.length > this.thresholds.maxUnrelatedExpects) {
const contextGroups = this.groupAssertionsByContext(assertions);
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Found ${assertions.length} assertions in ${contextGroups.length} context groups`);
}
// If assertions span multiple unrelated contexts, it's a violation
if (contextGroups.length > 1) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Multiple contexts detected - returning high count`);
}
return assertions.length; // Return high count to trigger violation
}
}
return count;
} countActs(content, language) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: countActs called for ${language}`);
}
const commonPatterns = this.compiledPatterns.actHeuristics.common || [];
const langPatterns = this.compiledPatterns.actHeuristics[language] || [];
const allPatterns = [...commonPatterns, ...langPatterns];
let count = 0;
const foundActions = new Set(); // To avoid counting same line multiple times
// Check if this is a form interaction sequence (allowlist)
if (this.isFormInteractionSequence(content)) {
return 1; // Treat form interaction as single behavior
}
// Check if this is a UI interaction workflow (allowlist)
if (this.isUIInteractionWorkflow(content)) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: UI workflow detected - treating as single behavior`);
}
return 1; // Treat UI workflow as single behavior
}
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: No UI workflow detected, proceeding with normal counting`);
}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip comments and empty lines
if (line.startsWith('//') || line.startsWith('*') || line.length === 0) {
continue;
}
// Skip lines that are clearly assertions
if (line.includes('expect(') || line.includes('assert') || line.includes('should')) {
continue;
}
// Skip setup actions (allowlist)
if (this.isSetupAction(line)) {
continue;
}
// Skip UI setup patterns (getting elements)
if (this.isUISetupAction(line)) {
continue;
}
// Skip UI setup patterns (getting elements)
if (this.isUISetupAction(line)) {
continue;
}
// Look for service/method calls that are actual actions
for (const pattern of allPatterns) {
pattern.lastIndex = 0; // Reset regex
if (pattern.test(line) && !foundActions.has(i)) {
// Additional validation: must be an assignment or standalone call
if (line.includes('=') || line.includes('await') || line.endsWith(');')) {
foundActions.add(i);
count++;
break; // Only count once per line
}
}
}
}
return count;
}
countControlFlow(content) {
let count = 0;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip comments, empty lines, and assertion lines
if (line.startsWith('//') || line.startsWith('*') || line.length === 0) {
continue;
}
// Skip assertion lines that contain expect, assert, should
if (line.includes('expect(') || line.includes('assert') || line.includes('should') ||
line.includes('.rejects.') || line.includes('.resolves.') || line.includes('toThrow')) {
continue;
}
// Skip UI interaction loops (legitimate testing pattern)
if (this.isUIInteractionLoop(line, lines, i)) {
continue;
}
// Check for actual control flow statements
for (const pattern of this.compiledPatterns.controlFlow) {
pattern.lastIndex = 0; // Reset regex
if (pattern.test(line)) {
count++;
break; // Only count once per line
}
}
}
return count;
}
getLineNumber(content, index) {
return content.substring(0, index).split('\n').length;
}
getColumnNumber(content, index) {
const beforeIndex = content.substring(0, index);
const lastNewlineIndex = beforeIndex.lastIndexOf('\n');
return index - lastNewlineIndex;
}
/**
* Extract the main subject/object being asserted from an assertion line
*/
extractAssertionSubject(line) {
// Remove expect( wrapper and get the core subject
const expectMatch = line.match(/expect\(([^)]+)\)/);
if (!expectMatch) return null;
const subject = expectMatch[1].trim();
// Handle method chains: include first method for more specific grouping
// e.g., "accountRepository.createQueryBuilder().where" → "accountRepository.createQueryBuilder"
const methodChainMatch = subject.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$][a-zA-Z0-9_$]*)/);
if (methodChainMatch) {
return methodChainMatch[1];
}
// Handle simple method calls: "accountRepository.createQueryBuilder"
const methodCallMatch = subject.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$][a-zA-Z0-9_$]*)/);
if (methodCallMatch) {
return methodCallMatch[1];
}
// Extract base object/variable name (before dots/brackets)
const baseMatch = subject.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
if (baseMatch) {
return baseMatch[1];
}
return subject;
}
/**
* Group assertions by their context/subject to detect unrelated expectations
*/
groupAssertionsByContext(assertions) {
const contextGroups = {};
for (const assertion of assertions) {
if (!assertion.subject) continue;
// Group by base subject name
const baseSubject = assertion.subject;
if (!contextGroups[baseSubject]) {
contextGroups[baseSubject] = [];
}
contextGroups[baseSubject].push(assertion);
}
// Convert to array and filter out single assertion groups (they're fine)
const groups = Object.values(contextGroups);
// Merge related contexts (e.g., screen, mockSignIn, mockPush are often related in UI tests)
return this.mergeRelatedContexts(groups);
}
/**
* Merge context groups that are conceptually related
*/
mergeRelatedContexts(groups) {
const relatedPatterns = [
['screen', 'component', 'element'], // UI testing
['spy', 'stub'], // Mocking (removed 'mock' - too broad)
['response', 'data'], // Data validation (removed 'result' - too broad)
['state', 'props', 'context'] // State management
];
const mergedGroups = [];
const processedGroups = new Set();
for (let i = 0; i < groups.length; i++) {
if (processedGroups.has(i)) continue;
let currentGroup = [...groups[i]];
processedGroups.add(i);
// Find related groups to merge
for (let j = i + 1; j < groups.length; j++) {
if (processedGroups.has(j)) continue;
const group1Subjects = currentGroup.map(a => a.subject.toLowerCase());
const group2Subjects = groups[j].map(a => a.subject.toLowerCase());
// Check if groups are related based on patterns
const areRelated = relatedPatterns.some(pattern => {
const group1HasPattern = group1Subjects.some(s => pattern.some(p => s.includes(p)));
const group2HasPattern = group2Subjects.some(s => pattern.some(p => s.includes(p)));
return group1HasPattern && group2HasPattern;
});
if (areRelated) {
currentGroup = currentGroup.concat(groups[j]);
processedGroups.add(j);
}
}
mergedGroups.push(currentGroup);
}
return mergedGroups;
}
/**
* Check if the test content represents a form interaction sequence
* that should be treated as a single behavior
*/
isFormInteractionSequence(content) {
if (!this.allowlist.formInteractionSequences) {
return false;
}
for (const pattern of this.allowlist.formInteractionSequences) {
const regex = new RegExp(pattern, 'gs'); // global, dotAll
if (regex.test(content)) {
return true;
}
}
return false;
}
/**
* Check if a line represents a setup action that shouldn't count toward action limit
*/
isSetupAction(line) {
if (!this.allowlist.setupActionPatterns) {
return false;
}
for (const pattern of this.allowlist.setupActionPatterns) {
const regex = new RegExp(pattern, 'i');
if (regex.test(line)) {
return true;
}
}
return false;
}
/**
* Check if content contains UI interaction workflow patterns
*/
isUIInteractionWorkflow(content) {
if (!this.config.options.allowlist?.uiInteractionWorkflows) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: No UI interaction workflows config found`);
}
return false;
}
const patterns = this.config.options.allowlist.uiInteractionWorkflows;
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: Checking ${patterns.length} UI workflow patterns`);
}
const isWorkflow = patterns.some(pattern => {
const regex = new RegExp(pattern, 'gs'); // global, dotAll
const matches = regex.test(content);
if (this.verbose && matches) {
console.log(`[DEBUG] 🧪 C065: UI workflow pattern matched: ${pattern}`);
}
return matches;
});
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: UI workflow detected: ${isWorkflow}`);
}
return isWorkflow;
}
/**
* Check if a line is a UI setup action (getting DOM elements)
*/
isUISetupAction(line) {
if (!this.config.options.allowlist?.uiSetupPatterns) {
return false;
}
const patterns = this.config.options.allowlist.uiSetupPatterns;
return patterns.some(pattern => {
const regex = new RegExp(pattern, 'i');
return regex.test(line);
});
}
/**
* Check if test method contains UI interactions (more lenient action counting)
*/
isUITestMethod(content) {
const uiPatterns = [
'fireEvent\\.',
'render\\(',
'getByRole\\(',
'queryByText\\(',
'getByText\\(',
'findByRole\\(',
'user\\.',
'screen\\.'
];
return uiPatterns.some(pattern => {
const regex = new RegExp(pattern, 'i');
return regex.test(content);
});
}
/**
* Check if a control flow statement is a UI interaction loop (legitimate testing pattern)
*/
isUIInteractionLoop(line, lines, lineIndex) {
// Check if it's a for loop
if (!/\bfor\s*\(/.test(line)) {
return false;
}
// Look for UI element iteration patterns
const uiIterationPatterns = [
/for\s*\(.*\bcheckbox\b/, // for (const checkbox of listCheckbox)
/for\s*\(.*\bbutton\b/, // for (const button of buttons)
/for\s*\(.*\belement\b/, // for (const element of elements)
/for\s*\(.*\bitem\b/, // for (const item of items)
/for\s*\(.*\bnode\b/, // for (const node of nodes)
/for\s*\(.*getAll.*\)/, // for (const x of getAllByRole(...))
/for\s*\(.*queryAll.*\)/, // for (const x of queryAllByText(...))
];
// Check if loop variable matches UI element patterns
if (uiIterationPatterns.some(pattern => pattern.test(line))) {
// Check if loop body contains UI interactions (next few lines)
const nextLines = lines.slice(lineIndex + 1, lineIndex + 5);
const hasUIInteraction = nextLines.some(nextLine =>
/fireEvent\.|user\.|click\(|type\(|change\(/i.test(nextLine)
);
if (hasUIInteraction) {
if (this.verbose) {
console.log(`[DEBUG] 🧪 C065: UI interaction loop detected, skipping control flow violation`);
}
return true;
}
}
return false;
}
}
module.exports = C065OneBehaviorPerTestAnalyzer;