perf-lens
Version:
AI-powered frontend performance optimizer
288 lines (285 loc) • 12.7 kB
JavaScript
import fs from 'fs';
import path from 'path';
import vm from 'vm';
import chalk from 'chalk';
import fg from 'fast-glob';
import { z } from 'zod';
import { FindingsSchema } from './schema.js';
import { recordUsage } from './cost.js';
import { thinkingFor } from './client.js';
const MAX_TURNS = 25;
const MAX_LIST_RESULTS = 200;
const MAX_GREP_RESULTS = 100;
const DEFAULT_MAX_FILE_SIZE = 100 * 1024;
const AGENT_MAX_TOKENS = 16000;
const REGEX_TIMEOUT_MS = 200;
const AGENT_INSTRUCTIONS = `You are investigating a frontend codebase for performance issues.
You have tools to explore the project: list_files, read_file, and grep. Use them to
form and verify hypotheses — start from the Lighthouse results below, locate the code
responsible, and read enough context to be certain before reporting an issue.
Guidance:
- Investigate before concluding. Read the actual code; never report an issue in code you have not read.
- Prioritize entry points, large components, data fetching, and rendering hot paths.
- Precision over volume: only report issues you are confident are real, with exact file and line locations.
- When you have finished investigating, call report_findings exactly once with every finding.`;
/** Resolves a model-supplied path and rejects anything that escapes the target dir. */
function resolveWithin(baseDir, relativePath) {
const resolved = path.resolve(baseDir, relativePath);
return resolved === baseDir || resolved.startsWith(baseDir + path.sep) ? resolved : null;
}
/**
* fast-glob does not sandbox `..` segments in patterns — a pattern like
* "../../../etc/*" happily matches outside cwd. Reject anything that isn't a
* plain relative pattern before it reaches fg.sync.
*/
function isSafeGlobPattern(pattern) {
return !path.isAbsolute(pattern) && !pattern.split(/[\\/]/).includes('..');
}
/**
* Runs a model-supplied regex against a line with a hard wall-clock timeout,
* so a catastrophic-backtracking pattern can't hang the process (ReDoS).
*/
function safeRegexTest(regex, line) {
try {
return vm.runInNewContext('regex.test(line)', { regex, line }, { timeout: REGEX_TIMEOUT_MS });
}
catch {
return false; // timed out or errored — skip this line rather than hang
}
}
function buildTools() {
return [
{
name: 'list_files',
description: 'List source files in the project, optionally filtered by a glob pattern (e.g. "src/**/*.tsx"). Returns relative paths.',
strict: true,
input_schema: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Glob pattern relative to the project root. Defaults to all source files.',
},
},
required: [],
additionalProperties: false,
},
},
{
name: 'read_file',
description: 'Read a file from the project. Returns content with 1-based line numbers. Optionally restrict to a line range.',
strict: true,
input_schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path relative to the project root' },
startLine: { type: 'integer', description: 'First line to read (1-based)' },
endLine: { type: 'integer', description: 'Last line to read (1-based, inclusive)' },
},
required: ['path'],
additionalProperties: false,
},
},
{
name: 'grep',
description: 'Search file contents with a JavaScript regular expression. Returns "file:line: text" matches.',
strict: true,
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'JavaScript regex to search for' },
glob: {
type: 'string',
description: 'Restrict the search to files matching this glob (relative to project root)',
},
},
required: ['pattern'],
additionalProperties: false,
},
},
{
name: 'report_findings',
description: 'Submit the final list of performance findings. Call this exactly once, when your investigation is complete. Calling it ends the analysis.',
input_schema: z.toJSONSchema(FindingsSchema),
},
];
}
/** Executes one investigation tool call; returns the tool_result content string. */
function executeTool(name, input, options) {
const ignore = options.ignore ?? [];
const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
try {
switch (name) {
case 'list_files': {
const pattern = input.pattern ||
'**/*.{js,jsx,ts,tsx,vue,svelte,astro,css,scss,less,sass,html}';
if (!isSafeGlobPattern(pattern)) {
return { content: `Pattern escapes the project root: ${pattern}`, isError: true };
}
const files = fg.sync(pattern, {
cwd: options.targetDir,
ignore,
onlyFiles: true,
dot: false,
});
const shown = files.slice(0, MAX_LIST_RESULTS);
const suffix = files.length > shown.length
? `\n… ${files.length - shown.length} more (narrow the pattern)`
: '';
return { content: shown.join('\n') + suffix || 'No files matched.', isError: false };
}
case 'read_file': {
const resolved = resolveWithin(options.targetDir, input.path);
if (!resolved) {
return { content: `Path escapes the project root: ${input.path}`, isError: true };
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
return { content: `File not found: ${input.path}`, isError: true };
}
if (fs.statSync(resolved).size > maxFileSize) {
return {
content: `File exceeds the ${Math.round(maxFileSize / 1024)}KB read limit — use startLine/endLine to read a range`,
isError: true,
};
}
const lines = fs.readFileSync(resolved, 'utf-8').split('\n');
const start = Math.max(1, input.startLine || 1);
const end = Math.min(lines.length, input.endLine || lines.length);
const numbered = lines
.slice(start - 1, end)
.map((line, i) => `${start + i}: ${line}`)
.join('\n');
return { content: numbered || '(empty file)', isError: false };
}
case 'grep': {
const glob = input.glob || '**/*';
if (!isSafeGlobPattern(glob)) {
return { content: `Glob escapes the project root: ${glob}`, isError: true };
}
const regex = new RegExp(input.pattern);
const files = fg.sync(glob, {
cwd: options.targetDir,
ignore,
onlyFiles: true,
dot: false,
});
const matches = [];
for (const file of files) {
const absolute = path.join(options.targetDir, file);
if (fs.statSync(absolute).size > maxFileSize)
continue;
const lines = fs.readFileSync(absolute, 'utf-8').split('\n');
for (let i = 0; i < lines.length && matches.length < MAX_GREP_RESULTS; i++) {
if (safeRegexTest(regex, lines[i])) {
matches.push(`${file}:${i + 1}: ${lines[i].trim()}`);
}
}
if (matches.length >= MAX_GREP_RESULTS)
break;
}
return {
content: matches.length > 0 ? matches.join('\n') : 'No matches.',
isError: false,
};
}
default:
return { content: `Unknown tool: ${name}`, isError: true };
}
}
catch (error) {
return {
content: `Tool error: ${error instanceof Error ? error.message : error}`,
isError: true,
};
}
}
/**
* Agent-mode analysis: the model investigates the codebase through tool use
* across multiple turns, then submits schema-validated findings via the
* report_findings tool. Hand-rolled loop — no framework.
*/
export async function runAgentAnalysis(client, options) {
const anthropic = client.anthropic;
const model = client.getConfig().model;
const maxTurns = options.maxTurns ?? MAX_TURNS;
const tools = buildTools();
const system = [
{
type: 'text',
text: `${options.systemPrompt}\n\n${AGENT_INSTRUCTIONS}`,
cache_control: { type: 'ephemeral' },
},
];
const messages = [
{
role: 'user',
content: options.lighthouseContext
? `${options.lighthouseContext}\n\nInvestigate this project and report your performance findings.`
: 'Investigate this project and report your performance findings.',
},
];
for (let turn = 0; turn < maxTurns; turn++) {
const finalTurn = turn === maxTurns - 1;
const stream = anthropic.messages.stream({
model,
max_tokens: AGENT_MAX_TOKENS,
...thinkingFor(model),
system,
tools,
// Out of turns: force the report so the run always ends with findings
...(finalTurn ? { tool_choice: { type: 'tool', name: 'report_findings' } } : {}),
messages,
});
const response = await stream.finalMessage();
recordUsage(model, response.usage);
messages.push({ role: 'assistant', content: response.content });
if (options.verbose) {
for (const block of response.content) {
if (block.type === 'text' && block.text.trim()) {
console.log(chalk.gray(block.text.trim()));
}
}
}
const toolUses = response.content.filter((block) => block.type === 'tool_use');
if (toolUses.length === 0) {
// Model stopped talking instead of reporting — nudge it once per occurrence.
messages.push({
role: 'user',
content: 'Use the report_findings tool to submit your findings.',
});
continue;
}
const toolResults = [];
for (const toolUse of toolUses) {
const input = toolUse.input;
if (toolUse.name === 'report_findings') {
const parsed = FindingsSchema.safeParse(input);
if (parsed.success) {
console.log(chalk.gray(` 📋 report_findings (${parsed.data.findings.length} findings)`));
return parsed.data.findings;
}
toolResults.push({
type: 'tool_result',
tool_use_id: toolUse.id,
content: `Invalid findings: ${parsed.error.message}. Fix the schema errors and call report_findings again.`,
is_error: true,
});
continue;
}
const summary = toolUse.name === 'read_file'
? `${input.path}${input.startLine ? `:${input.startLine}-${input.endLine ?? ''}` : ''}`
: (input.pattern ?? '');
console.log(chalk.gray(` 🔍 ${toolUse.name} ${summary}`));
const result = executeTool(toolUse.name, input, options);
toolResults.push({
type: 'tool_result',
tool_use_id: toolUse.id,
content: result.content,
...(result.isError ? { is_error: true } : {}),
});
}
// All tool results for a turn go back in ONE user message
messages.push({ role: 'user', content: toolResults });
}
throw new Error('Agent did not produce findings within the turn limit');
}