perf-lens
Version:
AI-powered frontend performance optimizer
472 lines (464 loc) • 19.8 kB
JavaScript
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import { createModel } from './ai.js';
import { PromptManager } from '../prompts/promptManager.js';
import { PROMPT_KEYS } from '../prompts/promptConfig.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
/**
* 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;
}
/**
* Checks if a file should be ignored based on provided ignore patterns
* @param {string} filePath - The path to the file
* @param {string[]} ignorePatterns - Array of glob patterns to ignore
* @returns {boolean} True if the file should be ignored
*/
function shouldIgnoreFile(filePath, ignorePatterns) {
const relativePath = path.relative(process.cwd(), filePath);
return ignorePatterns.some(pattern => {
const regex = new RegExp(pattern.replace(/\\/g, '\\\\').replace(/\*/g, '.*').replace(/\//g, '\\/'));
return regex.test(relativePath);
});
}
/**
* Checks if a file should be included based on provided include patterns
* @param {string} filePath - The path to the file
* @param {string[]} includePatterns - Array of glob patterns to include
* @returns {boolean} True if the file should be included
*/
function shouldIncludeFile(filePath, includePatterns) {
const relativePath = path.relative(process.cwd(), filePath);
return includePatterns.some(pattern => {
const regex = new RegExp(pattern.replace(/\\/g, '\\\\').replace(/\*/g, '.*').replace(/\//g, '\\/'));
return regex.test(relativePath);
});
}
/**
* Recursively finds all files in a directory that match the given patterns
* @param {string} dir - The directory to search in
* @param {AnalysisConfig} config - Configuration for file analysis
* @returns {string[]} Array of file paths that match the patterns
*/
function findFiles(dir, config) {
if (!fs.existsSync(dir)) {
return [];
}
let results = [];
const list = fs.readdirSync(dir);
const ignorePatterns = config.ignore || [];
const includePatterns = config.include || DEFAULT_INCLUDE_PATTERNS;
for (const file of list) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
// Skip ignored directories and files
if (shouldIgnoreFile(filePath, ignorePatterns)) {
continue;
}
if (stat.isDirectory()) {
results = results.concat(findFiles(filePath, config));
}
else if (shouldIncludeFile(filePath, includePatterns) &&
stat.size <= (config.maxFileSize || MAX_FILE_SIZE)) {
results.push(filePath);
}
}
return results;
}
/**
* 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;
}
/**
* Analyzes a group of files for performance issues
* @param {string} groupName - The name of the file group (e.g., 'react', 'vue')
* @param {string[]} files - Array of file paths to analyze
* @param {AIModel} model - The AI model instance for analysis
* @param {CodeAnalysisConfig} config - Configuration for analysis
* @param {{ metrics: string, analysis: string }} [lighthouseContext] - Optional Lighthouse analysis context
* @returns {Promise<{ critical: string[], warnings: string[], suggestions: string[], fileIssues: Record<string, { critical: string[], warnings: string[], suggestions: string[] }> }>} Analysis results
*/
async function analyzeFileGroup(groupName, files, model, config, lighthouseContext) {
if (files.length === 0) {
return { critical: [], warnings: [], suggestions: [], fileIssues: {} };
}
const spinner = config.verbose ? ora(`Analyzing ${groupName} files...`).start() : null;
// Prepare file contents for analysis
const fileContents = {};
let totalSize = 0;
// Sort files by size (smallest first) to maximize the number of files we can analyze
const sortedFiles = [...files].sort((a, b) => {
return fs.statSync(a).size - fs.statSync(b).size;
});
// Only take files up to the configured limits
const filesToAnalyze = [];
for (const file of sortedFiles) {
const content = readFileContent(file);
const size = content.length;
// Check if adding this file would exceed our limits
if (totalSize + size <= (model.getConfig().maxTokens || 100000) && // Token limit
filesToAnalyze.length < (config.batchSize || 20) && // File count limit
size <= (config.maxFileSize || MAX_FILE_SIZE)) {
// Individual file size limit
fileContents[file] = content;
filesToAnalyze.push(file);
totalSize += size;
}
else if (size > (config.maxFileSize || MAX_FILE_SIZE)) {
if (spinner)
spinner.stop();
console.log(chalk.yellow(`\nSkipping ${path.relative(process.cwd(), file)} (${Math.round(size / 1024)}KB) - exceeds size limit of ${Math.round((config.maxFileSize || 100 * 1024) / 1024)}KB`));
if (spinner)
spinner.start();
}
}
if (spinner) {
spinner.text = `Analyzing ${filesToAnalyze.length} ${groupName} files (${Math.round(totalSize / 1024)}KB)...`;
}
// Print out the specific files being sent to the AI
if (config.verbose) {
if (spinner)
spinner.stop();
console.log(chalk.blue.bold(`\nAnalyzing ${groupName} files:`));
filesToAnalyze.forEach((file, index) => {
const relativePath = path.relative(process.cwd(), file);
const fileSize = Math.round(fileContents[file].length / 1024);
console.log(` ${index + 1}. ${chalk.cyan(relativePath)} (${fileSize}KB)`);
});
if (spinner)
spinner.start();
}
// Create analysis prompt
const promptManager = PromptManager.getInstance();
const basePrompt = promptManager.getPrompt(PROMPT_KEYS.CODE_ANALYSIS);
const prompt = `${basePrompt}
Here are the ONLY files you can reference in your analysis:
${filesToAnalyze
.map(file => {
const relativePath = path.relative(process.cwd(), file);
const content = fileContents[file];
const lineCount = content.split('\n').length;
return `${relativePath} (${lineCount} lines)`;
})
.join('\n')}
For each file, here is its content:
${filesToAnalyze
.map(file => {
const relativePath = path.relative(process.cwd(), file);
const content = fileContents[file];
const lines = content.split('\n');
return `=== ${relativePath} ===
${lines.map((line, index) => `${index + 1}: ${line}`).join('\n')}
`;
})
.join('\n')}
${lighthouseContext
? `
LIGHTHOUSE CONTEXT:
Performance Metrics:
${lighthouseContext.metrics}
Analysis:
${lighthouseContext.analysis}
Use the above Lighthouse insights to guide your code analysis. Look for specific code patterns that might be causing the performance issues identified by Lighthouse.
`
: ''}`;
try {
const systemPrompt = promptManager.getPrompt(PROMPT_KEYS.PERFORMANCE_EXPERT);
const analysisText = await model.generateSuggestions(prompt, { systemPrompt });
// Parse issues with improved regex patterns that validate line numbers
const parseIssues = (text, symbol) => {
const pattern = new RegExp(`${symbol}\\s+([^\\n]+?):(\\d+)-(\\d+)\\s*\\n` + // Filepath and line range
`Description:\\s*([^\\n]+)\\s*\\n` + // Description
`Impact:\\s*([^\\n]+)\\s*\\n` + // Impact
`Code Context:\\s*\`\`\`[^\\n]*\\n([\\s\\S]+?)\`\`\`\\s*\\n` + // Code context
`Solution:\\s*([\\s\\S]+?)\\n` + // Solution (can be multiline)
`Expected Improvement:\\s*([^\\n]+)`, // Expected Improvement
'g');
const issues = [];
let match;
while ((match = pattern.exec(text)) !== null) {
const [, filepath, startLine, endLine, description, impact, codeContext, solution, improvement,] = match;
// Validate the file exists and line numbers are valid
const absolutePath = path.resolve(process.cwd(), filepath);
if (!fileContents[absolutePath]) {
console.log(chalk.yellow(`Warning: Skipping issue for non-existent file: ${filepath}`));
continue;
}
const lineCount = fileContents[absolutePath].split('\n').length;
if (parseInt(startLine) > lineCount || parseInt(endLine) > lineCount) {
console.log(chalk.yellow(`Warning: Skipping issue with invalid line numbers in ${filepath}: ${startLine}-${endLine} (file has ${lineCount} lines)`));
continue;
}
const formattedIssue = `${symbol} ${filepath}:${startLine}-${endLine}\n` +
`**Description:** ${description.trim()}\n` +
`**Impact:** ${impact.trim()}\n` +
`**Code Context:**\n\`\`\`\n${codeContext.trim()}\n\`\`\`\n` +
`**Solution:** ${solution.trim()}\n` +
`**Expected Improvement:** ${improvement.trim()}`;
issues.push(formattedIssue);
}
return issues;
};
const critical = parseIssues(analysisText, '🚨');
const warnings = parseIssues(analysisText, '⚠️');
const suggestions = parseIssues(analysisText, '💡');
// Track issues by file
const fileIssues = {};
// Initialize fileIssues for each file
filesToAnalyze.forEach(file => {
fileIssues[file] = {
critical: [],
warnings: [],
suggestions: [],
};
});
// Helper function to associate issues with files
const associateIssuesWithFiles = (issues, type) => {
issues.forEach(issue => {
// eslint-disable-next-line no-misleading-character-class
const fileMatch = issue.match(/^[🚨⚠️💡]\s+([^:]+):/u);
if (fileMatch) {
const relativePath = fileMatch[1];
const absolutePath = path.resolve(process.cwd(), relativePath);
if (fileIssues[absolutePath]) {
fileIssues[absolutePath][type].push(issue);
}
}
});
};
// Associate issues with files
associateIssuesWithFiles(critical, 'critical');
associateIssuesWithFiles(warnings, 'warnings');
associateIssuesWithFiles(suggestions, 'suggestions');
if (spinner)
spinner.succeed(`Analyzed ${filesToAnalyze.length} ${groupName} files`);
return {
critical,
warnings,
suggestions,
fileIssues,
};
}
catch (error) {
if (spinner)
spinner.fail(`Error analyzing ${groupName} files`);
console.error(error);
return { critical: [], warnings: [], suggestions: [], fileIssues: {} };
}
}
/**
* Processes files in batches for analysis
* @param {string[]} files - Array of file paths to analyze
* @param {CodeAnalysisConfig} config - Configuration for analysis
* @param {AIModel} model - The AI model instance for analysis
* @param {{ metrics: string, analysis: string }} [lighthouseContext] - Optional Lighthouse analysis context
* @returns {Promise<CodeAnalysisResult>} Combined analysis results
*/
async function processBatchedFiles(files, config, model, lighthouseContext) {
const result = {
critical: [],
warnings: [],
suggestions: [],
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, lighthouseContext);
// Merge results
result.critical.push(...batchResults.critical);
result.warnings.push(...batchResults.warnings);
result.suggestions.push(...batchResults.suggestions);
// 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 entire 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) {
const spinner = config.verbose ? ora('Finding files to analyze...').start() : null;
// Determine the directory to scan
const baseDir = config.targetDir ? path.resolve(process.cwd(), config.targetDir) : process.cwd();
// Verify the directory exists
if (!fs.existsSync(baseDir)) {
if (spinner)
spinner.fail(`Target directory does not exist: ${baseDir}`);
throw new Error(`Target directory does not exist: ${baseDir}`);
}
// Find all files to analyze
const allFiles = findFiles(baseDir, config);
if (spinner) {
spinner.succeed(`Found ${allFiles.length} files to analyze in ${path.relative(process.cwd(), baseDir) || '.'}`);
}
// Print summary of files found
if (config.verbose) {
console.log(chalk.blue.bold('\nFiles found by type:'));
const filesByExt = allFiles.reduce((acc, file) => {
const ext = path.extname(file);
acc[ext] = (acc[ext] || 0) + 1;
return acc;
}, {});
Object.entries(filesByExt).forEach(([ext, count]) => {
console.log(`${chalk.yellow(ext)}: ${count} files`);
});
if (allFiles.length > (config.maxFiles || 200)) {
console.log(chalk.yellow(`\nNote: Found ${allFiles.length} files, but will only analyze the ${config.maxFiles || 200} highest priority files.`));
}
}
// Create AI model instance
const model = createModel(config.ai || { provider: 'openai', model: 'o3-mini' });
// Process files in batches with prioritization
return processBatchedFiles(allFiles, config, model, config.lighthouseContext);
}