Files
vercel__vercel-plugin/scripts/benchmark-analyze.ts

715 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
/**
* Benchmark analyzer: walks ~/.claude/projects/ JSONL files, matches sessions
* to benchmark test directories, extracts token usage, tool calls, and skill
* injection data, then compares against expected skills to produce scorecards.
*
* Usage: bun run scripts/benchmark-analyze.ts [options]
* --base <path> Override base directory (default: ~/dev/vercel-plugin-testing)
* --help Print usage and exit
*/
import { readdir, readFile, stat, mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { homedir } from "node:os";
import type { BenchmarkRunManifest } from "./benchmark-runner";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Scorecard {
slug: string;
sessionId: string | null;
expectedSkills: string[];
actualSkills: string[];
matchedSkills: string[];
missingSkills: string[];
unexpectedSkills: string[];
matchScore: number; // 01
tokens: { input: number; output: number; cacheRead: number; cacheCreation: number };
toolCalls: Record<string, number>;
totalToolCalls: number;
errors: string[];
traceInjections: TraceInjection[];
}
interface TraceInjection {
toolName: string;
matchedSkills: string[];
injectedSkills: string[];
droppedByCap: string[];
droppedByBudget: string[];
}
interface RunMeta {
slug: string;
expectedSkills: string[];
success: boolean;
timedOut: boolean;
durationMs: number;
timestamp?: string;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const DEFAULT_BASE = join(homedir(), "dev", "vercel-plugin-testing");
const { values: flags } = parseArgs({
options: {
base: { type: "string", default: DEFAULT_BASE },
help: { type: "boolean", default: false },
},
strict: true,
});
if (flags.help) {
console.log(`Usage: bun run scripts/benchmark-analyze.ts [options]
--base <path> Override base directory (default: ~/dev/vercel-plugin-testing)
--help Print usage and exit`);
process.exit(0);
}
const BASE_DIR = resolve(flags.base!);
const RESULTS_DIR = join(BASE_DIR, "results");
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
const BENCHMARK_AGENT_SLUGS = [
"01-doc-qa-agent",
"02-customer-support-agent",
"03-deploy-monitor",
"04-multi-model-router",
"05-slack-pr-reviewer",
"06-content-pipeline",
"07-feature-rollout",
"08-event-driven-crm",
"09-code-sandbox-tutor",
"10-multi-agent-research",
"11-discord-game-master",
"12-compliance-auditor",
] as const;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Convert an absolute path to the Claude projects directory name format.
* Claude Code replaces "/" with "-" and keeps the leading "-".
* e.g. "/Users/john/dev/app" → "-Users-john-dev-app"
*/
export function cwdToProjectDirName(cwd: string): string {
return cwd.replace(/\//g, "-");
}
async function safeReadJson<T>(path: string): Promise<T | null> {
try {
const text = await readFile(path, "utf-8");
return JSON.parse(text) as T;
} catch {
return null;
}
}
async function safeReadText(path: string): Promise<string> {
try {
return await readFile(path, "utf-8");
} catch {
return "";
}
}
async function dirExists(path: string): Promise<boolean> {
try {
const s = await stat(path);
return s.isDirectory();
} catch {
return false;
}
}
async function fileExists(path: string): Promise<boolean> {
try {
const s = await stat(path);
return s.isFile();
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// JSONL Session Analysis
// ---------------------------------------------------------------------------
export interface SessionStats {
sessionId: string;
tokens: { input: number; output: number; cacheRead: number; cacheCreation: number };
toolCalls: Record<string, number>;
totalToolCalls: number;
errors: string[];
}
export async function analyzeSessionJsonl(jsonlPath: string): Promise<SessionStats> {
const text = await readFile(jsonlPath, "utf-8");
const lines = text.split("\n").filter((l) => l.trim());
let sessionId = "";
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
const toolCalls: Record<string, number> = {};
let totalToolCalls = 0;
const errors: string[] = [];
for (const line of lines) {
let entry: Record<string, unknown>;
try {
entry = JSON.parse(line);
} catch {
continue;
}
// Extract session ID from first entry that has one
if (!sessionId && typeof entry.sessionId === "string") {
sessionId = entry.sessionId;
}
const msg = entry.message as Record<string, unknown> | undefined;
if (!msg) continue;
// Accumulate token usage from assistant messages
const usage = msg.usage as Record<string, number> | undefined;
if (usage) {
tokens.input += usage.input_tokens || 0;
tokens.output += usage.output_tokens || 0;
tokens.cacheRead += usage.cache_read_input_tokens || 0;
tokens.cacheCreation += usage.cache_creation_input_tokens || 0;
}
// Extract tool calls from content blocks
const content = msg.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "tool_use" && typeof block.name === "string") {
toolCalls[block.name] = (toolCalls[block.name] || 0) + 1;
totalToolCalls++;
}
// Capture errors from tool results
if (block.type === "tool_result" && block.is_error === true) {
const errContent = block.content;
if (typeof errContent === "string" && errContent.length < 500) {
errors.push(errContent.slice(0, 200));
} else if (Array.isArray(errContent)) {
for (const c of errContent) {
if (typeof c === "object" && c && (c as Record<string, unknown>).type === "text") {
const t = (c as Record<string, string>).text || "";
if (t.length < 500) errors.push(t.slice(0, 200));
}
}
}
}
}
}
}
return { sessionId, tokens, toolCalls, totalToolCalls, errors: errors.slice(0, 20) };
}
/**
* Find JSONL files in ~/.claude/projects/ that match a given cwd.
* Returns paths sorted by modification time (newest first).
*/
export async function findSessionJsonls(cwd: string): Promise<string[]> {
const dirName = cwdToProjectDirName(cwd);
const projectDir = join(CLAUDE_PROJECTS_DIR, dirName);
if (!(await dirExists(projectDir))) return [];
let entries: string[];
try {
entries = await readdir(projectDir);
} catch {
return [];
}
const jsonls = entries.filter((e) => e.endsWith(".jsonl"));
if (jsonls.length === 0) return [];
// Sort by mtime descending (newest first)
const withMtime = (
await Promise.all(
jsonls.map(async (f) => {
const fp = join(projectDir, f);
try {
const s = await stat(fp);
return { path: fp, mtime: s.mtimeMs };
} catch {
return null;
}
}),
)
).filter((w): w is { path: string; mtime: number } => w !== null);
withMtime.sort((a, b) => b.mtime - a.mtime);
return withMtime.map((w) => w.path);
}
// ---------------------------------------------------------------------------
// Trace Log Parsing
// ---------------------------------------------------------------------------
export function parseTraceLog(traceText: string): TraceInjection[] {
const injections: TraceInjection[] = [];
const lines = traceText.split("\n").filter((l) => l.trim());
for (const line of lines) {
let entry: Record<string, unknown>;
try {
entry = JSON.parse(line);
} catch {
continue;
}
// Look for "complete" events that have injection counts,
// or direct skill-injection audit log entries
if (entry.event === "skill-injection") {
injections.push({
toolName: (entry.toolName as string) || "",
matchedSkills: (entry.matchedSkills as string[]) || [],
injectedSkills: (entry.injectedSkills as string[]) || [],
droppedByCap: (entry.droppedByCap as string[]) || [],
droppedByBudget: (entry.droppedByBudget as string[]) || [],
});
}
}
return injections;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((v): v is string => typeof v === "string");
}
function hookNameToToolName(hookName: string): string {
const parts = hookName.split(":");
return parts.length > 1 ? parts[parts.length - 1] : hookName;
}
function makeTraceInjectionFromRecord(
record: Record<string, unknown>,
fallbackToolName: string,
): TraceInjection | null {
const matchedSkills = asStringArray(record.matchedSkills);
const injectedSkills = asStringArray(record.injectedSkills);
const droppedByCap = asStringArray(record.droppedByCap);
const droppedByBudget = asStringArray(record.droppedByBudget);
if (
matchedSkills.length === 0
&& injectedSkills.length === 0
&& droppedByCap.length === 0
&& droppedByBudget.length === 0
) {
return null;
}
const toolName =
typeof record.toolName === "string" && record.toolName.length > 0
? record.toolName
: fallbackToolName;
return {
toolName,
matchedSkills,
injectedSkills,
droppedByCap,
droppedByBudget,
};
}
function extractTraceInjectionsFromUnknown(
value: unknown,
fallbackToolName: string,
): TraceInjection[] {
const injections: TraceInjection[] = [];
const queue: unknown[] = [value];
while (queue.length > 0) {
const current = queue.shift();
if (typeof current === "string") {
const trimmed = current.trim();
if (!trimmed) continue;
try {
queue.push(JSON.parse(trimmed));
} catch {
// ignore non-JSON strings
}
continue;
}
if (!current || typeof current !== "object") continue;
const record = current as Record<string, unknown>;
const maybeInjection = makeTraceInjectionFromRecord(record, fallbackToolName);
if (maybeInjection) injections.push(maybeInjection);
for (const key of ["skillInjection", "metadata", "result", "output", "payload", "data"]) {
if (key in record) queue.push(record[key]);
}
}
return injections;
}
async function parseHookProgressInjectionsFromSessionJsonl(jsonlPath: string): Promise<TraceInjection[]> {
const text = await safeReadText(jsonlPath);
if (!text) return [];
const lines = text.split("\n").filter((l) => l.trim());
const dedup = new Map<string, TraceInjection>();
for (const line of lines) {
let entry: Record<string, unknown>;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type !== "progress") continue;
const data = entry.data as Record<string, unknown> | undefined;
if (!data || data.type !== "hook_progress") continue;
const command = typeof data.command === "string" ? data.command : "";
if (!command.includes("pretooluse-skill-inject")) continue;
const hookName = typeof data.hookName === "string" ? data.hookName : "";
const fallbackToolName = hookName ? hookNameToToolName(hookName) : "";
const extracted = extractTraceInjectionsFromUnknown(data, fallbackToolName);
const candidates =
extracted.length > 0
? extracted
: [
{
toolName: fallbackToolName,
matchedSkills: [],
injectedSkills: [],
droppedByCap: [],
droppedByBudget: [],
} satisfies TraceInjection,
];
for (const injection of candidates) {
const key = JSON.stringify(injection);
if (!dedup.has(key)) dedup.set(key, injection);
}
}
return [...dedup.values()];
}
interface AuditLogParseResult {
found: boolean;
injections: TraceInjection[];
}
/**
* Parse the skill-injections.jsonl audit log for skill injection data.
* The audit log lives under ~/.claude/projects/<project-slug>/vercel-plugin/.
*/
async function parseAuditLog(projectDir: string): Promise<AuditLogParseResult> {
const projectSlug = resolve(projectDir).replaceAll("/", "-");
const auditPath = join(homedir(), ".claude", "projects", projectSlug, "vercel-plugin", "skill-injections.jsonl");
const found = await fileExists(auditPath);
if (!found) return { found: false, injections: [] };
const text = await safeReadText(auditPath);
if (!text) return { found: true, injections: [] };
return { found: true, injections: parseTraceLog(text) };
}
async function discoverBenchmarkAgentSlugEntries(baseDir: string): Promise<Array<{ slug: string; cwd: string }>> {
const discovered: Array<{ slug: string; cwd: string }> = [];
for (const slug of BENCHMARK_AGENT_SLUGS) {
const cwd = join(baseDir, slug);
const jsonls = await findSessionJsonls(cwd);
if (jsonls.length > 0) discovered.push({ slug, cwd });
}
return discovered;
}
// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------
function computeScorecard(
slug: string,
expectedSkills: string[],
sessionStats: SessionStats | null,
traceInjections: TraceInjection[],
): Scorecard {
// Collect all unique injected skills from trace
const actualSkillSet = new Set<string>();
for (const inj of traceInjections) {
for (const s of inj.injectedSkills) actualSkillSet.add(s);
}
const actualSkills = [...actualSkillSet].sort();
const expectedSet = new Set(expectedSkills);
const matchedSkills = actualSkills.filter((s) => expectedSet.has(s));
const missingSkills = expectedSkills.filter((s) => !actualSkillSet.has(s));
const unexpectedSkills = actualSkills.filter((s) => !expectedSet.has(s));
// Score: fraction of expected skills that were injected
const matchScore =
expectedSkills.length > 0 ? matchedSkills.length / expectedSkills.length : 1;
return {
slug,
sessionId: sessionStats?.sessionId ?? null,
expectedSkills,
actualSkills,
matchedSkills,
missingSkills,
unexpectedSkills,
matchScore,
tokens: sessionStats?.tokens ?? { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 },
toolCalls: sessionStats?.toolCalls ?? {},
totalToolCalls: sessionStats?.totalToolCalls ?? 0,
errors: sessionStats?.errors ?? [],
traceInjections,
};
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log(`\n=== Benchmark Analyzer ===`);
console.log(`Base directory: ${BASE_DIR}`);
console.log(`Results directory: ${RESULTS_DIR}\n`);
// Load run manifest for exact project discovery and cwd correlation
const manifestPath = join(RESULTS_DIR, "run-manifest.json");
let manifest: BenchmarkRunManifest | null = null;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf-8")) as BenchmarkRunManifest;
console.log(`Loaded run manifest: ${manifest.runId}`);
} catch {
console.log(`No run manifest found, falling back to directory discovery`);
}
// Discover project slugs from manifest or results directory
let slugEntries: Array<{ slug: string; cwd: string }>;
if (manifest) {
slugEntries = manifest.projects.map((p) => ({ slug: p.slug, cwd: p.cwd }));
} else {
const slugDirs: string[] = [];
if (await dirExists(RESULTS_DIR)) {
const resultEntries = await readdir(RESULTS_DIR);
const discoveredFromResults = (
await Promise.all(
resultEntries.map(async (e) => {
const p = join(RESULTS_DIR, e);
return (await dirExists(p)) ? e : null;
}),
)
).filter((e): e is string => e !== null);
slugDirs.push(...discoveredFromResults);
}
if (slugDirs.length > 0) {
slugEntries = slugDirs.map((s) => ({ slug: s, cwd: join(BASE_DIR, s) }));
} else {
const discoveredFromSessions = await discoverBenchmarkAgentSlugEntries(BASE_DIR);
if (discoveredFromSessions.length > 0) {
slugEntries = discoveredFromSessions;
await mkdir(RESULTS_DIR, { recursive: true });
console.log(
`No result subdirectories found; discovered ${slugEntries.length} benchmark-agent project(s) from ~/.claude/projects`,
);
} else {
console.log(`No benchmark project sessions found in ${CLAUDE_PROJECTS_DIR}`);
console.log(`Nothing to analyze.`);
await mkdir(RESULTS_DIR, { recursive: true });
await writeFile(
join(RESULTS_DIR, "analysis-summary.json"),
JSON.stringify({
timestamp: new Date().toISOString(),
runId: null,
projectCount: 0,
avgMatchScore: 0,
totalTokensIn: 0,
totalTokensOut: 0,
totalToolCalls: 0,
allMissingSkills: [],
allUnexpectedSkills: [],
scorecards: [],
}, null, 2),
);
process.exit(0);
}
}
}
if (slugEntries.length === 0) {
console.log(`No project result directories found in ${RESULTS_DIR}`);
console.log(`Nothing to analyze.`);
process.exit(0);
}
console.log(`Found ${slugEntries.length} project(s) to analyze\n`);
const scorecards: Scorecard[] = [];
for (const { slug, cwd: projectDir } of slugEntries.sort((a, b) => a.slug.localeCompare(b.slug))) {
const projectResultsDir = join(RESULTS_DIR, slug);
console.log(`--- ${slug} ---`);
// 1. Read run-meta.json for expected skills
const meta = await safeReadJson<RunMeta>(join(projectResultsDir, "run-meta.json"));
const expectedSkills = meta?.expectedSkills ?? [];
// 2. Parse trace log from runner output
const traceText = await safeReadText(join(projectResultsDir, "trace.log"));
let traceInjections = parseTraceLog(traceText);
// 3. Also check audit log in project directory
const auditLog = await parseAuditLog(projectDir);
if (auditLog.injections.length > 0 && traceInjections.length === 0) {
traceInjections = auditLog.injections;
} else if (auditLog.injections.length > traceInjections.length) {
// Prefer the richer source
traceInjections = auditLog.injections;
}
// 4. Find and analyze JSONL conversation logs
let sessionStats: SessionStats | null = null;
const jsonls = await findSessionJsonls(projectDir);
if (jsonls.length > 0) {
// Use the most recent session
console.log(` Found ${jsonls.length} session log(s), analyzing newest`);
sessionStats = await analyzeSessionJsonl(jsonls[0]);
console.log(
` Tokens: ${sessionStats.tokens.input} in / ${sessionStats.tokens.output} out | Tools: ${sessionStats.totalToolCalls}`,
);
if (!auditLog.found && traceInjections.length === 0) {
const hookProgressInjections = await parseHookProgressInjectionsFromSessionJsonl(jsonls[0]);
if (hookProgressInjections.length > 0) {
traceInjections = hookProgressInjections;
console.log(
` Using hook_progress fallback from session JSONL (${hookProgressInjections.length} event(s))`,
);
}
}
} else {
console.log(` No session logs found`);
}
console.log(` Trace injections: ${traceInjections.length}`);
// 5. Compute scorecard
const scorecard = computeScorecard(slug, expectedSkills, sessionStats, traceInjections);
scorecards.push(scorecard);
// 6. Write per-slug scorecard
await mkdir(projectResultsDir, { recursive: true });
await writeFile(
join(projectResultsDir, "scorecard.json"),
JSON.stringify(scorecard, null, 2),
);
const score = (scorecard.matchScore * 100).toFixed(0);
const status =
scorecard.missingSkills.length === 0
? "PASS"
: scorecard.matchScore >= 0.5
? "PARTIAL"
: "MISS";
console.log(
` Score: ${score}% (${status}) | Expected: [${expectedSkills.join(", ")}] | Actual: [${scorecard.actualSkills.join(", ")}]`,
);
if (scorecard.missingSkills.length > 0) {
console.log(` Missing: [${scorecard.missingSkills.join(", ")}]`);
}
if (scorecard.unexpectedSkills.length > 0) {
console.log(` Unexpected: [${scorecard.unexpectedSkills.join(", ")}]`);
}
console.log();
}
// Write aggregate summary
const aggregatePath = join(RESULTS_DIR, "analysis-summary.json");
const aggregate = {
timestamp: new Date().toISOString(),
runId: manifest?.runId ?? null,
projectCount: scorecards.length,
avgMatchScore:
scorecards.length > 0
? scorecards.reduce((sum, s) => sum + s.matchScore, 0) / scorecards.length
: 0,
totalTokensIn: scorecards.reduce((sum, s) => sum + s.tokens.input, 0),
totalTokensOut: scorecards.reduce((sum, s) => sum + s.tokens.output, 0),
totalToolCalls: scorecards.reduce((sum, s) => sum + s.totalToolCalls, 0),
allMissingSkills: [...new Set(scorecards.flatMap((s) => s.missingSkills))].sort(),
allUnexpectedSkills: [...new Set(scorecards.flatMap((s) => s.unexpectedSkills))].sort(),
scorecards: scorecards.map((s) => ({
slug: s.slug,
matchScore: s.matchScore,
expected: s.expectedSkills,
actual: s.actualSkills,
missing: s.missingSkills,
unexpected: s.unexpectedSkills,
})),
};
await writeFile(aggregatePath, JSON.stringify(aggregate, null, 2));
// Print summary table
console.log(`=== Analysis Summary ===`);
console.log(
`${"Slug".padEnd(28)} ${"Score".padEnd(8)} ${"Expected".padEnd(30)} ${"Actual".padEnd(30)} Missing`,
);
console.log("-".repeat(120));
for (const s of scorecards) {
const score = `${(s.matchScore * 100).toFixed(0)}%`;
console.log(
`${s.slug.padEnd(28)} ${score.padEnd(8)} ${s.expectedSkills.join(", ").padEnd(30)} ${s.actualSkills.join(", ").padEnd(30)} ${s.missingSkills.join(", ")}`,
);
}
const avgScore = (aggregate.avgMatchScore * 100).toFixed(0);
console.log(`\nAverage match score: ${avgScore}%`);
console.log(`Total tokens: ${aggregate.totalTokensIn} in / ${aggregate.totalTokensOut} out`);
console.log(`Total tool calls: ${aggregate.totalToolCalls}`);
if (aggregate.allMissingSkills.length > 0) {
console.log(`\nSkills never injected when expected: ${aggregate.allMissingSkills.join(", ")}`);
}
console.log(`\nResults saved to: ${RESULTS_DIR}`);
console.log(`Per-project scorecards: results/<slug>/scorecard.json`);
console.log(`Aggregate summary: results/analysis-summary.json\n`);
// Exit with failure if average score is below 50%
process.exit(aggregate.avgMatchScore < 0.5 ? 1 : 0);
}
// Only run main when executed directly (not when imported by tests)
const isDirectRun =
typeof Bun !== "undefined"
? Bun.main === import.meta.path
: process.argv[1] === import.meta.filename;
if (isDirectRun) {
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(2);
});
}