UNPKG

perf-lens

Version:

AI-powered frontend performance optimizer

446 lines (437 loc) • 18 kB
import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import fg from 'fast-glob'; import ora from 'ora'; import Anthropic from '@anthropic-ai/sdk'; import { createModel } from './ai.js'; import { PromptManager } from '../prompts/promptManager.js'; import { PROMPT_KEYS } from '../prompts/promptConfig.js'; import { validateFindings, findingToMarkdown } from '../ai/schema.js'; import { AnalysisCache } from '../ai/cache.js'; import { runAgentAnalysis } from '../ai/agent.js'; // Default file patterns to include if none specified const DEFAULT_INCLUDE_PATTERNS = [ '**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx', '**/*.vue', '**/*.svelte', '**/*.astro', '**/*.css', '**/*.scss', '**/*.less', '**/*.sass', '**/*.html', ]; // Maximum file size to analyze (in bytes) const MAX_FILE_SIZE = 100 * 1024; // 100KB // Maximum combined content size per AI request (in characters) const MAX_BATCH_CHARS = 100000; /** * Calculates a priority score for a file based on various factors like file type, location, and size * @param {string} filePath - The path to the file * @param {number} size - The size of the file in bytes * @returns {number} A priority score (higher is better) */ function calculateFilePriority(filePath, size) { let score = 0; // Prioritize entry points and important files if (filePath.includes('index.') || filePath.includes('main.')) score += 10; if (filePath.includes('app.') || filePath.includes('App.')) score += 8; // Prioritize component files if (filePath.includes('/components/')) score += 5; if (filePath.includes('/pages/')) score += 5; // Prioritize smaller files as they're more likely to fit in batches score += Math.max(0, 10 - Math.floor(size / 10240)); // Higher score for files under 10KB // Prioritize certain file types if (filePath.endsWith('.tsx') || filePath.endsWith('.jsx')) score += 3; if (filePath.endsWith('.ts') || filePath.endsWith('.js')) score += 2; if (filePath.endsWith('.css') || filePath.endsWith('.scss')) score += 1; return score; } /** * Finds all files in a directory that match the include patterns and none of * the ignore patterns. * @param {string} dir - The directory to search in * @param {AnalysisConfig} config - Configuration for file analysis * @returns {string[]} Array of absolute file paths */ function findFiles(dir, config) { if (!fs.existsSync(dir)) { return []; } return fg .sync(config.include || DEFAULT_INCLUDE_PATTERNS, { cwd: dir, ignore: config.ignore || [], onlyFiles: true, dot: false, absolute: true, }) .filter(file => fs.statSync(file).size <= (config.maxFileSize || MAX_FILE_SIZE)); } /** * Reads and returns the contents of a file * @param {string} filePath - The path to the file * @returns {string} The contents of the file */ function readFileContent(filePath) { try { return fs.readFileSync(filePath, 'utf-8'); } catch (error) { console.error(`Error reading file ${filePath}:`, error); return ''; } } /** * Groups files by their type for more focused analysis * @param {string[]} files - Array of file paths * @returns {Record<string, string[]>} Object mapping file types to arrays of file paths */ function groupFilesByType(files) { const groups = { react: [], vue: [], svelte: [], astro: [], javascript: [], typescript: [], styles: [], html: [], }; for (const file of files) { const ext = path.extname(file); if (file.includes('.jsx') || file.includes('.tsx') || file.includes('react')) { groups.react.push(file); } else if (ext === '.vue') { groups.vue.push(file); } else if (ext === '.svelte') { groups.svelte.push(file); } else if (ext === '.astro') { groups.astro.push(file); } else if (ext === '.ts' || ext === '.tsx') { groups.typescript.push(file); } else if (ext === '.js' || ext === '.jsx') { groups.javascript.push(file); } else if (['.css', '.scss', '.less', '.sass'].includes(ext)) { groups.styles.push(file); } else if (ext === '.html') { groups.html.push(file); } } return groups; } /** * Builds the stable Lighthouse context string shared by every batch prompt. * It sits in the cached system prefix, so keep it byte-identical across calls. */ export function buildLighthouseContext(lighthouseContext) { if (!lighthouseContext) return undefined; return `LIGHTHOUSE CONTEXT Performance Metrics: ${lighthouseContext.metrics} Core Web Vitals: ${lighthouseContext.analysis.coreWebVitals} Performance Opportunities: ${lighthouseContext.analysis.performanceOpportunities} Diagnostics: ${lighthouseContext.analysis.diagnostics} Use these Lighthouse insights to guide your code analysis. Look for the specific code patterns causing the performance issues identified above.`; } /** * Turns validated findings into the report's string buckets, grouped by severity * and by file. */ function bucketFindings(findings, relativeToAbsolute) { var _a; const buckets = { findings, critical: [], warnings: [], suggestions: [], fileIssues: {}, }; const severityToBucket = { critical: 'critical', warning: 'warnings', suggestion: 'suggestions', }; for (const finding of findings) { const markdown = findingToMarkdown(finding); const bucket = severityToBucket[finding.severity]; buckets[bucket].push(markdown); const absolutePath = relativeToAbsolute[finding.file]; if (absolutePath) { (_a = buckets.fileIssues)[absolutePath] ?? (_a[absolutePath] = { critical: [], warnings: [], suggestions: [] }); buckets.fileIssues[absolutePath][bucket].push(markdown); } } return buckets; } /** * Analyzes a group of files for performance issues using structured outputs * @param {string} groupName - The name of the file group (e.g., 'react', 'vue') * @param {string[]} files - Array of file paths to analyze * @param {AIClient} model - The AI client for analysis * @param {CodeAnalysisConfig} config - Configuration for analysis * @param {AnalysisCache} cache - Result cache keyed by batch content * @returns Analysis results bucketed by severity and by file */ async function analyzeFileGroup(groupName, files, model, config, cache) { if (files.length === 0) { return { critical: [], warnings: [], suggestions: [], findings: [], fileIssues: {} }; } const spinner = config.verbose ? ora(`Analyzing ${groupName} files...`).start() : null; // Prepare file contents for analysis, keyed by relative path const fileContents = {}; const relativeToAbsolute = {}; let totalSize = 0; // Sort files by size (smallest first) to maximize the number of files we can analyze const sortedFiles = [...files].sort((a, b) => fs.statSync(a).size - fs.statSync(b).size); for (const file of sortedFiles) { const content = readFileContent(file); const size = content.length; const relativePath = path.relative(process.cwd(), file); if (totalSize + size <= MAX_BATCH_CHARS && Object.keys(fileContents).length < (config.batchSize || 20) && size <= (config.maxFileSize || MAX_FILE_SIZE)) { fileContents[relativePath] = content; relativeToAbsolute[relativePath] = file; totalSize += size; } else if (size > (config.maxFileSize || MAX_FILE_SIZE)) { if (spinner) spinner.stop(); console.log(chalk.yellow(`\nSkipping ${relativePath} (${Math.round(size / 1024)}KB) - exceeds size limit of ${Math.round((config.maxFileSize || MAX_FILE_SIZE) / 1024)}KB`)); if (spinner) spinner.start(); } } const relativePaths = Object.keys(fileContents); if (relativePaths.length === 0) { if (spinner) spinner.stop(); return { critical: [], warnings: [], suggestions: [], findings: [], fileIssues: {} }; } if (spinner) { spinner.text = `Analyzing ${relativePaths.length} ${groupName} files (${Math.round(totalSize / 1024)}KB)...`; } const promptManager = PromptManager.getInstance(); const prompt = `${promptManager.getPrompt(PROMPT_KEYS.CODE_ANALYSIS)} Here are the ONLY files you can reference in your analysis: ${relativePaths.map(file => `${file} (${fileContents[file].split('\n').length} lines)`).join('\n')} For each file, here is its content: ${relativePaths .map(file => { const lines = fileContents[file].split('\n'); return `=== ${file} ===\n${lines.map((line, index) => `${index + 1}: ${line}`).join('\n')}\n`; }) .join('\n')}`; try { const cacheKey = cache.key(model.getConfig().model || '', fileContents); let findings = cache.get(cacheKey); const fromCache = findings !== undefined; if (!findings) { findings = await model.analyzeCode(prompt, { systemPrompt: promptManager.getPrompt(PROMPT_KEYS.PERFORMANCE_EXPERT), sharedContext: buildLighthouseContext(config.lighthouseContext), }); } const fileLineCounts = Object.fromEntries(relativePaths.map(file => [file, fileContents[file].split('\n').length])); const { valid, dropped } = validateFindings(findings, fileLineCounts); if (dropped.length > 0 && config.verbose) { if (spinner) spinner.stop(); console.log(chalk.yellow(`Dropped ${dropped.length} finding(s) referencing non-existent files or lines`)); if (spinner) spinner.start(); } if (!fromCache) { cache.set(cacheKey, valid); } if (spinner) { spinner.succeed(`Analyzed ${relativePaths.length} ${groupName} files${fromCache ? ' (cached)' : ''}`); } return bucketFindings(valid, relativeToAbsolute); } catch (error) { if (spinner) spinner.fail(`Error analyzing ${groupName} files`); // Auth/billing/request errors won't succeed on retry — stop the scan instead // of failing the same way on every remaining batch. if (error instanceof Anthropic.APIError && [400, 401, 403].includes(error.status ?? 0)) { throw new Error(`Anthropic API error: ${error.message}`); } console.error(error instanceof Error ? error.message : error); return { critical: [], warnings: [], suggestions: [], findings: [], fileIssues: {} }; } } /** * Processes files in batches for analysis * @param {string[]} files - Array of file paths to analyze * @param {CodeAnalysisConfig} config - Configuration for analysis * @param {AIClient} model - The AI client for analysis * @param {AnalysisCache} cache - Result cache * @returns {Promise<CodeAnalysisResult>} Combined analysis results */ async function processBatchedFiles(files, config, model, cache) { const result = { critical: [], warnings: [], suggestions: [], findings: [], fileSpecificIssues: {}, }; // Calculate priority for each file const filePriorities = files.map(file => ({ path: file, size: fs.statSync(file).size, score: calculateFilePriority(file, fs.statSync(file).size), })); // Sort by priority score (highest first) filePriorities.sort((a, b) => b.score - a.score); // Take only the maximum allowed files const filesToProcess = filePriorities.slice(0, config.maxFiles || 200).map(fp => fp.path); // Group files by type const fileGroups = groupFilesByType(filesToProcess); // Process each group in batches for (const [groupName, groupFiles] of Object.entries(fileGroups)) { if (groupFiles.length === 0) continue; const batches = []; for (let i = 0; i < groupFiles.length; i += config.batchSize || 20) { batches.push(groupFiles.slice(i, i + (config.batchSize || 20))); } const spinner = config.verbose ? ora(`Processing ${groupName} files in ${batches.length} batches...`).start() : null; for (let i = 0; i < batches.length; i++) { if (spinner) { spinner.text = `Processing ${groupName} batch ${i + 1}/${batches.length}...`; } // Print files being analyzed in this batch if (config.verbose) { if (spinner) spinner.stop(); console.log(chalk.blue.bold(`\nAnalyzing ${groupName} batch ${i + 1}:`)); batches[i].forEach((file, index) => { const relativePath = path.relative(process.cwd(), file); const fileSize = Math.round(fs.statSync(file).size / 1024); const priority = filePriorities.find(fp => fp.path === file)?.score || 0; console.log(` ${index + 1}. ${chalk.cyan(relativePath)} (${fileSize}KB) - Priority: ${priority}`); }); if (spinner) spinner.start(); } const batchResults = await analyzeFileGroup(groupName, batches[i], model, config, cache); // Merge results result.critical.push(...batchResults.critical); result.warnings.push(...batchResults.warnings); result.suggestions.push(...batchResults.suggestions); result.findings.push(...batchResults.findings); // Merge file-specific issues for (const [filePath, issues] of Object.entries(batchResults.fileIssues)) { result.fileSpecificIssues[filePath] = issues; } // Add delay between batches if (i < batches.length - 1) { if (spinner) { spinner.text = `Waiting ${config.batchDelay || 1000}ms before next batch...`; } await new Promise(resolve => setTimeout(resolve, config.batchDelay || 1000)); } } if (spinner) spinner.succeed(`Completed analysis of ${groupName} files`); } return result; } /** * Analyzes the codebase for performance issues * @param {CodeAnalysisConfig} config - Configuration for codebase analysis * @returns {Promise<CodeAnalysisResult>} Complete analysis results including critical issues, warnings, and suggestions */ export async function analyzeCodebase(config) { // Determine the directory to scan const baseDir = config.targetDir ? path.resolve(process.cwd(), config.targetDir) : process.cwd(); if (!fs.existsSync(baseDir)) { throw new Error(`Target directory does not exist: ${baseDir}`); } const files = findFiles(baseDir, config); if (files.length === 0) { console.log(chalk.yellow('No files found to analyze.')); return { critical: [], warnings: [], suggestions: [], findings: [], fileSpecificIssues: {} }; } if (config.verbose) { console.log(chalk.blue(`Found ${files.length} files to analyze`)); } const model = createModel(config.ai); const cache = new AnalysisCache(process.cwd(), !config.noCache); return processBatchedFiles(files, config, model, cache); } /** * Agent-mode analysis: instead of batching every file into prompts, the model * investigates the codebase itself through tool use and reports findings. * @param {CodeAnalysisConfig} config - Same configuration as analyzeCodebase * @returns {Promise<CodeAnalysisResult>} Analysis results in the same report shape */ export async function analyzeCodebaseWithAgent(config) { const baseDir = config.targetDir ? path.resolve(process.cwd(), config.targetDir) : process.cwd(); if (!fs.existsSync(baseDir)) { throw new Error(`Target directory does not exist: ${baseDir}`); } const model = createModel(config.ai); const promptManager = PromptManager.getInstance(); console.log(chalk.blue.bold('\nšŸ¤– Agent investigation')); const findings = await runAgentAnalysis(model, { targetDir: baseDir, ignore: config.ignore, maxFileSize: config.maxFileSize, systemPrompt: promptManager.getPrompt(PROMPT_KEYS.PERFORMANCE_EXPERT), lighthouseContext: buildLighthouseContext(config.lighthouseContext), verbose: config.verbose, }); // Validate reported locations against the actual files on disk const fileLineCounts = {}; const relativeToAbsolute = {}; for (const finding of findings) { if (fileLineCounts[finding.file] !== undefined) continue; const absolutePath = path.resolve(baseDir, finding.file); const withinBase = absolutePath === baseDir || absolutePath.startsWith(baseDir + path.sep); if (withinBase && fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) { fileLineCounts[finding.file] = readFileContent(absolutePath).split('\n').length; relativeToAbsolute[finding.file] = absolutePath; } } const { valid, dropped } = validateFindings(findings, fileLineCounts); if (dropped.length > 0) { console.log(chalk.yellow(`Dropped ${dropped.length} finding(s) referencing non-existent files or lines`)); } const buckets = bucketFindings(valid, relativeToAbsolute); return { critical: buckets.critical, warnings: buckets.warnings, suggestions: buckets.suggestions, findings: buckets.findings, fileSpecificIssues: buckets.fileIssues, }; }