UNPKG

@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
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;