diff --git a/.github/workflows/eval-refresh.yml b/.github/workflows/eval-refresh.yml index 413060f2..47cacb7b 100644 --- a/.github/workflows/eval-refresh.yml +++ b/.github/workflows/eval-refresh.yml @@ -174,7 +174,12 @@ jobs: if [ -n "$eval_ids" ]; then matching=() while IFS= read -r id; do - [ -d "evals/$id" ] && matching+=("$id") + if [ -f "evals/$id/PROMPT.md" ]; then + pnpm --silent eval -- list --eval "$id" > /dev/null + matching+=("$id") + else + echo "SKIP $id (published eval is absent from this checkout: evals/$id/PROMPT.md not found)" + fi done < <(jq -Rr 'split(",") | map(gsub("^\\s+|\\s+$"; "")) | .[]' <<< "$eval_ids") else matching=() @@ -182,7 +187,11 @@ jobs: [ -d "$dir" ] || continue id=$(basename "$dir") prompt="$dir/PROMPT.md" - [ -f "$prompt" ] || continue + if [ ! -f "$prompt" ]; then + echo "SKIP $id (eval directory has no PROMPT.md)" + continue + fi + pnpm --silent eval -- list --eval "$id" > /dev/null suite_val=$(sed -n 's/^suite:[[:space:]]*//p' "$prompt" | head -n 1) if jq -e --arg s "$suite_val" 'index($s) != null' <<< "$suite_json" > /dev/null 2>&1; then matching+=("$id") @@ -223,7 +232,14 @@ jobs: case "$eval_suite" in benchmark) experiment_suites=(benchmark no-skills) ;; regression) experiment_suites=(regression) ;; - *) continue ;; + other) + echo "SKIP $id (suite other has no scheduled experiment suite)" + continue + ;; + *) + echo "Invalid suite in evals/$id/PROMPT.md: $eval_suite" >&2 + exit 1 + ;; esac for experiment_suite in "${experiment_suites[@]}"; do @@ -309,6 +325,7 @@ jobs: set -euo pipefail pnpm --filter @supabase-evals/framework eval:vercel -- \ + --strict \ --pairs-json "$EVAL_PAIRS" \ --revision "$EVAL_REVISION" \ --runs "${{ needs.prepare.outputs.runs }}" \ diff --git a/apps/framework/harness/run-eval.ts b/apps/framework/harness/run-eval.ts index 0c43b29f..8a9202fd 100644 --- a/apps/framework/harness/run-eval.ts +++ b/apps/framework/harness/run-eval.ts @@ -1,4 +1,5 @@ #!/usr/bin/env tsx +import { execFileSync, spawnSync } from 'node:child_process'; import { cpSync, existsSync, @@ -6,14 +7,16 @@ import { readdirSync, readFileSync, realpathSync, + renameSync, rmSync, statSync, writeFileSync, } from 'node:fs'; -import { join, dirname, relative } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { jsonSchema, tool, type ToolSet } from 'ai'; import { parseEvalMarkdown } from '@supabase-evals/core/eval-markdown'; +import { rawEvalResultSchema } from '@supabase-evals/core/eval-metadata'; import { createBareSandbox, frontmatterDescription, @@ -26,14 +29,16 @@ import { readFlag, readRepeatedFlag, readSuiteFilters, + validateCliArgs, } from '../lib/cli-args.js'; import { bootPlatformBackend } from './platform-backend.js'; import { viteBuild, vitestRun } from './project-runner.js'; import { buildDocsResult, buildSkillResult, - rehydrateTruncatedDocsResults, getExperimentDisplayMetadata, + MCP_SERVER_VERSION, + rehydrateTruncatedDocsResults, supabaseMcpServerMounts, } from '@supabase-evals/core'; import type { @@ -53,6 +58,8 @@ import type { const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..', '..', '..'); +const RESULTS_ROOT = process.env.LOCAL_RESULTS_ROOT ?? ROOT; +const SKILLS_ROOT = process.env.LOCAL_SKILLS_ROOT ?? join(ROOT, 'skills'); // Fixed identifiers for the mocked hosted project a local-stack eval links to. // Both must satisfy the CLI's format checks: ref is `^[a-z]{20}$`, token is @@ -61,10 +68,41 @@ const HOSTED_PROJECT_REF = 'evalshostedprojectxy'; const HOSTED_ACCESS_TOKEN = 'sbp_' + '0'.repeat(40); const rawArgs = process.argv.slice(2); +const CLI_ARGS = { + booleanFlags: [ + 'skip-existing', + 'smoke', + 'dry', + 'strict', + 'run-all-attempts', + 'debug', + ], + valueFlags: [ + 'mcp', + 'experiment', + 'eval', + 'suite', + 'experiment-suite', + 'runs', + 'timeout-sec', + 'concurrency', + ], + positionals: ['list'], + usage: + 'Usage: pnpm eval -- [list] [--skip-existing] [--smoke] [--dry] [--strict] [--run-all-attempts] [--debug] [--mcp PATH] [--experiment NAME] [--eval ID] [--suite SUITE] [--experiment-suite SUITE] [--runs N] [--timeout-sec N] [--concurrency N]', +} as const; +try { + validateCliArgs(rawArgs, CLI_ARGS); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} const args = new Set(rawArgs); const FORCE = !args.has('--skip-existing'); const SMOKE = args.has('--smoke'); const DRY = args.has('--dry'); +const STRICT = args.has('--strict'); +const MCP_PATH = readFlag(rawArgs, 'mcp'); const EXPERIMENT_FILTERS = readRepeatedFlag(rawArgs, 'experiment').map( normalizeExperimentName ); @@ -163,7 +201,7 @@ type ToolsSkill = { name: string; description: string; body: string }; function loadToolsSkills(skillNames: string[]): ToolsSkill[] { const skills: ToolsSkill[] = []; for (const name of skillNames) { - const p = join(ROOT, 'skills', name, 'SKILL.md'); + const p = join(SKILLS_ROOT, name, 'SKILL.md'); if (!existsSync(p)) { console.warn( `SKILL ${name} not found at skills/${name} — ensure the submodule is initialised (\`git submodule update --init\`); skipping` @@ -250,7 +288,7 @@ function resolveSkillSources( ): Array<{ name: string; dir: string }> { const sources: Array<{ name: string; dir: string }> = []; for (const name of skillNames) { - const dir = join(ROOT, 'skills', name); + const dir = join(SKILLS_ROOT, name); if (!existsSync(dir)) { console.warn( `SKILL ${name} not found at skills/${name} — ensure the submodule is initialised (\`git submodule update --init\`); skipping` @@ -263,12 +301,12 @@ function resolveSkillSources( } function resultPath(modelName: string, ev: Pick) { - return join(ROOT, 'results', modelName, `${ev.id}.json`); + return join(RESULTS_ROOT, 'results', modelName, `${ev.id}.json`); } function workspacePath(modelName: string, evalId: string, attempt: number) { return join( - ROOT, + RESULTS_ROOT, 'results', modelName, evalId, @@ -339,22 +377,21 @@ function disposable }>( }, }); } +type RunResult = ScoreResult & { + attempts: number; + skills: SkillResult; + docs: DocsResult; + toolCalls: ToolCallRecord[]; + transcript: TranscriptPart[]; + agentReport: string; + stoppedReason: string; +}; async function runOne( expName: string, exp: ExperimentConfig, ev: EvalManifest -): Promise< - ScoreResult & { - attempts: number; - skills: SkillResult; - docs: DocsResult; - toolCalls: ToolCallRecord[]; - transcript: TranscriptPart[]; - agentReport: string; - stoppedReason: string; - } -> { +): Promise { const prompt = parseEvalMarkdown( readFileSync(ev.promptPath, 'utf8'), ev.promptPath @@ -630,39 +667,156 @@ async function runConcurrent( ); await Promise.all(workers); } +type Provenance = { + generatedAt: string; + host: { sha?: string; branch?: string; dirtyFiles: number }; + mcpOverride?: { path: string; sha?: string; dirtyFiles?: number }; + platform: string; +}; + +function tryGit(args: string[], cwd: string): string | undefined { + try { + return execFileSync('git', args, { cwd, maxBuffer: 1 << 28 }) + .toString() + .trim(); + } catch { + return undefined; + } +} + +function collectProvenance(mcpPath?: string): Provenance { + const dirty = (cwd: string) => + (tryGit(['status', '--porcelain'], cwd) ?? '').split('\n').filter(Boolean) + .length; + const provenance: Provenance = { + generatedAt: new Date().toISOString(), + host: { + sha: tryGit(['rev-parse', 'HEAD'], ROOT), + branch: tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], ROOT), + dirtyFiles: dirty(ROOT), + }, + platform: `${process.platform}/${process.arch} node ${process.version}`, + }; + if (mcpPath) { + const repository = tryGit(['rev-parse', '--show-toplevel'], mcpPath); + provenance.mcpOverride = { + path: mcpPath, + sha: repository ? tryGit(['rev-parse', 'HEAD'], repository) : undefined, + dirtyFiles: repository ? dirty(repository) : undefined, + }; + } + return provenance; +} + +function resolveMcpServerPath(raw: string): string { + let path = isAbsolute(raw) ? raw : resolve(process.cwd(), raw); + if (!existsSync(path)) throw new Error(`--mcp path does not exist: ${path}`); + const packageDir = join(path, 'packages', 'mcp-server-supabase'); + if (existsSync(packageDir)) path = packageDir; + if (!existsSync(join(path, 'dist', 'transports', 'stdio.js'))) { + throw new Error( + `no built server at ${path} (dist/transports/stdio.js missing) — build it first:\n pnpm install && pnpm build` + ); + } + try { + const localVersion = JSON.parse( + readFileSync(join(path, 'package.json'), 'utf8') + ).version; + if (localVersion && localVersion !== MCP_SERVER_VERSION) { + console.error( + `note: local mcp build is v${localVersion}; the harness fixture (platform-lite) tracks the v${MCP_SERVER_VERSION} pin — endpoint drift is possible` + ); + } + } catch { + // An unversioned checkout is valid when it has the expected built entry. + } + return realpathSync(path); +} + +function validateJudgeKeys(evals: readonly EvalManifest[]) { + if (process.env.OPENAI_API_KEY || DRY) return; + const judged = evals + .filter( + (ev) => + existsSync(ev.evalPath) && + /\bjudge\b/.test(readFileSync(ev.evalPath, 'utf8')) + ) + .map((ev) => ev.id); + if (judged.length > 0) { + throw new Error( + `these evals score with the LLM judge (OpenAI-backed, regardless of the agent under test): ${judged.join(', ')}\nadd OPENAI_API_KEY to .env at the repo root before running them` + ); + } +} + +function validateStrictSkills( + experiment: string, + skillNames: readonly string[] +) { + if (!STRICT) return; + const missing = skillNames.filter( + (name) => !existsSync(join(SKILLS_ROOT, name, 'SKILL.md')) + ); + if (missing.length > 0) { + throw new Error( + `experiment ${experiment} declares skills this checkout is missing: ${missing.join(', ')}\ninitialise the skills submodule first: git submodule update --init` + ); + } +} +function fakeRun(out: string, experiment: string, evalId: string): RunResult { + const command = process.env.LOCAL_EVAL_CMD; + if (!command) throw new Error('LOCAL_EVAL_CMD is required'); + mkdirSync(dirname(out), { recursive: true }); + const fakeOut = `${out}.fake`; + const result = spawnSync(command, { + shell: true, + stdio: 'inherit', + env: { ...process.env, RES: fakeOut, EVAL: evalId, EXPERIMENT: experiment }, + }); + if (result.status !== 0) { + rmSync(fakeOut, { force: true }); + throw new Error(`LOCAL_EVAL_CMD failed for ${experiment} x ${evalId}`); + } + try { + return JSON.parse(readFileSync(fakeOut, 'utf8')) as RunResult; + } finally { + rmSync(fakeOut, { force: true }); + } +} async function main() { - if (rawArgs.filter((a) => a !== '--')[0] === 'list') { + if (rawArgs.filter((arg) => arg !== '--')[0] === 'list') { const experiments = await loadExperiments(); let filtered = EXPERIMENT_SUITE_FILTERS.length > 0 ? experiments.filter( - (e) => - e.config.suite !== undefined && - e.config.suite.some((suite) => + (experiment) => + experiment.config.suite !== undefined && + experiment.config.suite.some((suite) => EXPERIMENT_SUITE_FILTERS.includes(suite) ) ) : experiments; if (EVAL_FILTERS.length > 0) { - // Drop experiments that would skipEval every requested eval, so callers - // building an experiment x eval matrix (e.g. the eval-refresh workflow) - // don't plan a pair that will produce no results — and no artifact — - // to upload. - const evals = discoverEvals().filter((ev) => - EVAL_FILTERS.includes(ev.id) + const evals = discoverEvals().filter((evaluation) => + EVAL_FILTERS.includes(evaluation.id) ); filtered = filtered.filter(({ config }) => - evals.some((ev) => !config.skipEval?.(ev)) + evals.some((evaluation) => !config.skipEval?.(evaluation)) ); } - console.log(JSON.stringify(filtered.map((e) => e.name))); + console.log(JSON.stringify(filtered.map((experiment) => experiment.name))); return; } + const mcpPath = MCP_PATH ? resolveMcpServerPath(MCP_PATH) : undefined; + if (mcpPath) process.env.SUPABASE_MCP_SERVER_PATH = mcpPath; + const allExperiments = await loadExperiments(); if (EXPERIMENT_FILTERS.length > 0) { - const experimentNames = new Set(allExperiments.map(({ name }) => name)); + const experimentNames = new Set( + allExperiments.map((experiment) => experiment.name) + ); const missing = EXPERIMENT_FILTERS.filter( (name) => !experimentNames.has(name) ); @@ -672,26 +826,27 @@ async function main() { } const experiments = allExperiments.filter(({ name, config }) => { - if (EXPERIMENT_FILTERS.length > 0 && !EXPERIMENT_FILTERS.includes(name)) + if (EXPERIMENT_FILTERS.length > 0 && !EXPERIMENT_FILTERS.includes(name)) { return false; + } if ( EXPERIMENT_SUITE_FILTERS.length > 0 && (config.suite === undefined || !config.suite.some((suite) => EXPERIMENT_SUITE_FILTERS.includes(suite))) - ) + ) { return false; + } return true; }); - if (EXPERIMENT_FILTERS.length > 0) { - if (experiments.length === 0) { - throw new Error( - `no experiments matched experiment=${EXPERIMENT_FILTERS.join(',')}` - ); - } + if (EXPERIMENT_FILTERS.length > 0 && experiments.length === 0) { + throw new Error( + `no experiments matched experiment=${EXPERIMENT_FILTERS.join(',')}` + ); } + const evals = discoverEvals(); if (EVAL_FILTERS.length > 0) { - const evalIds = new Set(evals.map((e) => e.id)); + const evalIds = new Set(evals.map((evaluation) => evaluation.id)); const missing = EVAL_FILTERS.filter((evalId) => !evalIds.has(evalId)); if (missing.length > 0) { throw new Error(`no eval matched: ${missing.join(',')}`); @@ -700,17 +855,19 @@ async function main() { const filtered = SMOKE ? Object.values( - evals.reduce>((acc, e) => { - acc[e.stage] ??= e; + evals.reduce>((acc, evaluation) => { + acc[evaluation.stage] ??= evaluation; return acc; }, {}) ) : EVAL_FILTERS.length > 0 - ? evals.filter((e) => EVAL_FILTERS.includes(e.id)) + ? evals.filter((evaluation) => EVAL_FILTERS.includes(evaluation.id)) : evals; const suiteFiltered = SUITE_FILTERS.length > 0 - ? filtered.filter((e) => SUITE_FILTERS.includes(e.suite)) + ? filtered.filter((evaluation) => + SUITE_FILTERS.includes(evaluation.suite) + ) : filtered; if (suiteFiltered.length === 0) { @@ -723,9 +880,7 @@ async function main() { throw new Error(`no evals matched ${filter}`); } - // Suppress noisy supabase-js logs from expected failures; --debug keeps them visible. const stderr = console.error; - if (!DEBUG) console.error = () => undefined; console.log( `${experiments.length} experiment(s), ${suiteFiltered.length} eval(s), ` + @@ -742,8 +897,10 @@ async function main() { if (!DRY) { try { config.agent.assertReady(); - } catch (e) { - stderr(`SKIP ${name} (${e instanceof Error ? e.message : String(e)})`); + } catch (error) { + const message = `${name} (${error instanceof Error ? error.message : String(error)})`; + if (STRICT) throw new Error(message, { cause: error }); + stderr(`SKIP ${message}`); continue; } } @@ -751,19 +908,34 @@ async function main() { for (const ev of suiteFiltered) { const out = resultPath(name, ev); if (!FORCE && existsSync(out)) { - console.log(`SKIP ${name} x ${ev.id} (already ran)`); - continue; + let existingResultIsValid = false; + try { + existingResultIsValid = rawEvalResultSchema.safeParse( + JSON.parse(readFileSync(out, 'utf8')) + ).success; + } catch { + // A partial write is incomplete work and must run again. + } + if (existingResultIsValid) { + console.log(`SKIP ${name} x ${ev.id} (already ran)`); + continue; + } + console.log(`RERUN ${name} x ${ev.id} (existing result is invalid)`); } if (ev.mode === 'local-stack' && !config.localStack) { - console.log( - `SKIP ${name} x ${ev.id} (no local stack runtime — add \`localStack: localStackRuntime()\` from "@supabase-evals/sandbox" to experiments/${name}.ts)` - ); + const message = + `${name} x ${ev.id} (no local stack runtime — add ` + + '`localStack: localStackRuntime()` from "@supabase-evals/sandbox" ' + + `to experiments/${name}.ts)`; + if (STRICT) throw new Error(message); + console.log(`SKIP ${message}`); continue; } if (config.skipEval?.(ev)) { console.log(`SKIP ${name} x ${ev.id} (skipEval)`); continue; } + validateStrictSkills(name, ev.metadata.skills ?? config.skills); if (DRY) { console.log(formatPlanLine(name, config, ev)); continue; @@ -772,6 +944,10 @@ async function main() { } } + validateJudgeKeys([...new Set(allWork.map(({ ev }) => ev))]); + if (!DEBUG) console.error = () => undefined; + const provenance = collectProvenance(mcpPath); + let localStackTurn = Promise.resolve(); const errored: Error[] = []; @@ -781,46 +957,42 @@ async function main() { console.log(`⏳ RUN ${name} x ${ev.id}`); const run = async () => { try { - const res = await runOne(name, config, ev); + const res = process.env.LOCAL_EVAL_CMD + ? fakeRun(out, name, ev.id) + : await runOne(name, config, ev); mkdirSync(dirname(out), { recursive: true }); const experimentDisplay = getExperimentDisplayMetadata(config); - writeFileSync( - out, - JSON.stringify( - { - experiment: name, - experimentSuite: SELECTED_EXPERIMENT_SUITE ?? config.suite?.[0], - experimentDisplay, - eval: ev.id, - ...ev.metadata, - ...res, - }, - null, - 2 - ) + const resultJson = JSON.stringify( + { + experiment: name, + experimentSuite: SELECTED_EXPERIMENT_SUITE ?? config.suite?.[0], + experimentDisplay, + eval: ev.id, + ...ev.metadata, + ...res, + provenance, + }, + null, + 2 ); + const temporaryOut = `${out}.tmp`; + writeFileSync(temporaryOut, resultJson); + renameSync(temporaryOut, out); const elapsed = Math.round((Date.now() - start) / 1000); console.log( `${res.passed ? '✅ PASS' : '❌ FAIL'} ${name} x ${ev.id} (${formatRunSummary(res)}, ${elapsed}s)\n → ${relative(ROOT, out)}` ); - } catch (e) { - errored.push(new Error(`${name} x ${ev.id}`, { cause: e })); + } catch (error) { + errored.push(new Error(`${name} x ${ev.id}`, { cause: error })); const elapsed = Math.round((Date.now() - start) / 1000); stderr( - `💥 ERR ${name} x ${ev.id}: ${e instanceof Error ? e.message : String(e)} (${elapsed}s)` + `💥 ERR ${name} x ${ev.id}: ${error instanceof Error ? error.message : String(error)} (${elapsed}s)` ); } }; if (ev.mode !== 'local-stack') return run(); - const prev = localStackTurn; - let release!: () => void; - localStackTurn = new Promise((r) => (release = r)); - await prev; - try { - await run(); - } finally { - release(); - } + localStackTurn = localStackTurn.then(run); + await localStackTurn; }; await runConcurrent(allWork, CONCURRENCY, runWork); diff --git a/apps/framework/lib/cli-args.test.ts b/apps/framework/lib/cli-args.test.ts index 8b2f3a1a..f1340829 100644 --- a/apps/framework/lib/cli-args.test.ts +++ b/apps/framework/lib/cli-args.test.ts @@ -1,5 +1,7 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { positiveInteger, readFlag } from './cli-args.js'; +import { positiveInteger, readFlag, validateCliArgs } from './cli-args.js'; describe('readFlag', () => { it('reads flags in both --name value and --name=value form', () => { @@ -18,6 +20,77 @@ describe('readFlag', () => { }); }); +describe('validateCliArgs', () => { + const definition = { + booleanFlags: ['strict', 'smoke'], + valueFlags: ['mcp', 'eval'], + positionals: ['list'], + usage: 'Usage: pnpm eval -- [list] [options]', + }; + + it('accepts declared flags, values, separators, and positionals', () => { + expect(() => + validateCliArgs( + ['--', 'list', '--strict', '--mcp', './server', '--eval=id'], + definition + ) + ).not.toThrow(); + }); + + it('rejects unknown flags with a close-match hint and usage', () => { + expect(() => validateCliArgs(['--strcit'], definition)).toThrow( + 'unknown argument: --strcit\nDid you mean --strict?\n\nUsage:' + ); + expect(() => validateCliArgs(['--mpc', './server'], definition)).toThrow( + 'unknown argument: --mpc\nDid you mean --mcp?\n\nUsage:' + ); + }); + + it('rejects unexpected positionals', () => { + expect(() => validateCliArgs(['run'], definition)).toThrow( + 'unexpected argument: run\n\nUsage:' + ); + }); + + it('only accepts positionals in the command position', () => { + expect(() => validateCliArgs(['--strict', 'list'], definition)).toThrow( + 'unexpected argument: list\n\nUsage:' + ); + }); +}); + +describe('run-eval argument validation', () => { + const frameworkRoot = join(import.meta.dirname, '..'); + + function run(...args: string[]) { + return spawnSync( + process.execPath, + ['--import', 'tsx/esm', 'harness/run-eval.ts', ...args], + { + cwd: frameworkRoot, + encoding: 'utf8', + } + ); + } + + it.each([ + ['--strcit', '--strict'], + ['--mpc', '--mcp'], + ])('rejects unknown argument %s before running', (token, hint) => { + const result = run(token, './server'); + expect(result.status).toBe(1); + expect(result.stderr).toContain(`unknown argument: ${token}`); + expect(result.stderr).toContain(`Did you mean ${hint}?`); + expect(result.stderr).toContain('Usage: pnpm eval'); + }); + + it('accepts a valid list invocation', () => { + const result = run('list', '--experiment-suite', 'benchmark'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('codex-gpt-5.6'); + }); +}); + describe('positiveInteger', () => { it('rejects non-positive-integer CLI options', () => { expect(positiveInteger('3', 'runs')).toBe(3); diff --git a/apps/framework/lib/cli-args.ts b/apps/framework/lib/cli-args.ts index 77eaf1e5..3a155af5 100644 --- a/apps/framework/lib/cli-args.ts +++ b/apps/framework/lib/cli-args.ts @@ -15,6 +15,89 @@ export function positiveInteger(value: string, name: string): number { return parsed.data; } +export interface CliArgsDefinition { + booleanFlags: readonly string[]; + valueFlags: readonly string[]; + positionals?: readonly string[]; + usage: string; +} + +function editDistance(left: string, right: string): number { + const previous = Array.from( + { length: right.length + 1 }, + (_, index) => index + ); + + for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) { + let diagonal = previous[0] ?? 0; + previous[0] = leftIndex + 1; + for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) { + const above = previous[rightIndex + 1] ?? 0; + const next = + left[leftIndex] === right[rightIndex] + ? diagonal + : 1 + Math.min(diagonal, above, previous[rightIndex] ?? 0); + diagonal = above; + previous[rightIndex + 1] = next; + } + } + + return previous[right.length] ?? left.length; +} + +function suggestion( + token: string, + flags: readonly string[] +): string | undefined { + const closest = flags + .map((flag) => ({ + flag: `--${flag}`, + distance: editDistance(token, `--${flag}`), + })) + .sort((left, right) => left.distance - right.distance)[0]; + if (!closest || closest.distance > 3) return undefined; + return closest.flag; +} + +/** Rejects tokens that are not part of a command's declared CLI surface. */ +export function validateCliArgs( + rawArgs: readonly string[], + definition: CliArgsDefinition +): void { + const positionals = definition.positionals ?? []; + const knownFlags = [...definition.booleanFlags, ...definition.valueFlags]; + let hasArgument = false; + + for (let index = 0; index < rawArgs.length; index += 1) { + const token = rawArgs[index]; + if (!token || token === '--') continue; + const isFirstArgument = !hasArgument; + hasArgument = true; + + if (!token.startsWith('--')) { + if (isFirstArgument && positionals.includes(token)) continue; + throw new Error(`unexpected argument: ${token}\n\n${definition.usage}`); + } + + const equalsIndex = token.indexOf('='); + const name = token.slice(2, equalsIndex === -1 ? undefined : equalsIndex); + if (definition.booleanFlags.includes(name) && equalsIndex === -1) continue; + if (definition.valueFlags.includes(name)) { + const value = rawArgs[index + 1]; + if (equalsIndex === -1 && value && !value.startsWith('--')) index += 1; + continue; + } + + const hint = suggestion( + token.slice(0, equalsIndex === -1 ? undefined : equalsIndex), + knownFlags + ); + throw new Error( + `unknown argument: ${token}${hint ? `\nDid you mean ${hint}?` : ''}\n\n${definition.usage}` + ); + } +} + /** Reads one CLI flag in either `--name value` or `--name=value` form. */ export function readFlag(rawArgs: string[], name: string): string | undefined { const prefix = `--${name}=`; diff --git a/apps/framework/package.json b/apps/framework/package.json index 5b282780..c03b57c6 100644 --- a/apps/framework/package.json +++ b/apps/framework/package.json @@ -5,16 +5,17 @@ "type": "module", "scripts": { "check": "pnpm typecheck && pnpm test:framework && pnpm test:vercel-runner", - "eval": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts", - "eval:dry": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts --dry", - "eval:smoke": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts --smoke", + "eval": "node --env-file-if-exists=../../.env --import tsx/esm harness/run-eval.ts", + "eval:dry": "node --env-file-if-exists=../../.env --import tsx/esm harness/run-eval.ts --dry", + "eval:smoke": "node --env-file-if-exists=../../.env --import tsx/esm harness/run-eval.ts --smoke", "eval:vercel": "node --env-file=../../.env --import tsx/esm scripts/run-vercel-evals.ts", "typecheck": "tsc --noEmit", "test:framework": "node --env-file-if-exists=../../.env --import tsx/esm scripts/smoke-framework.ts", "test:vercel-runner": "vitest run scripts/run-vercel-evals.test.ts lib/cli-args.test.ts", "export-results": "node --import tsx/esm scripts/export-results.ts", "demo:mcp": "node --env-file=../../.env --import tsx/esm scripts/mcp-demo.ts", - "demo:executor": "node --env-file=../../.env --import tsx/esm scripts/executor-demo.ts" + "demo:executor": "node --env-file=../../.env --import tsx/esm scripts/executor-demo.ts", + "test:local": "node --env-file-if-exists=../../.env --import tsx/esm scripts/smoke-local.ts" }, "dependencies": { "@ai-sdk/anthropic": "catalog:", diff --git a/apps/framework/scripts/run-vercel-evals.test.ts b/apps/framework/scripts/run-vercel-evals.test.ts index 538c4407..c0029738 100644 --- a/apps/framework/scripts/run-vercel-evals.test.ts +++ b/apps/framework/scripts/run-vercel-evals.test.ts @@ -1,6 +1,10 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { APIError } from '@vercel/sandbox'; import { describe, expect, it } from 'vitest'; import { + filterLocallyAvailablePairs, isRetryableSandboxCreateError, isTerminalSandboxCreateError, parsePairs, @@ -56,6 +60,36 @@ describe('Vercel eval controller', () => { ); }); + it('skips published evals absent from the checked-out tree', () => { + const evalsRoot = mkdtempSync(join(tmpdir(), 'eval-pairs-')); + const localEval = join(evalsRoot, 'local-eval'); + mkdirSync(localEval); + writeFileSync(join(localEval, 'PROMPT.md'), 'malformed on purpose'); + const pair = { + experiment: 'experiment-1', + experiment_suite: 'benchmark', + eval_suite: 'benchmark', + }; + const notes: string[] = []; + try { + expect( + filterLocallyAvailablePairs( + [ + { ...pair, eval_id: 'local-eval' }, + { ...pair, eval_id: 'newer-main-eval' }, + ], + evalsRoot, + (message) => notes.push(message) + ) + ).toEqual([{ ...pair, eval_id: 'local-eval' }]); + expect(notes).toEqual([ + 'SKIP newer-main-eval (published eval is absent from this checkout: evals/newer-main-eval/PROMPT.md not found)', + ]); + } finally { + rmSync(evalsRoot, { recursive: true, force: true }); + } + }); + it('retries sandbox creation only on 429s and 5xx API responses', () => { const apiError = (status: number) => new APIError(new Response(null, { status })); diff --git a/apps/framework/scripts/run-vercel-evals.ts b/apps/framework/scripts/run-vercel-evals.ts index 0f21eb0f..1de23125 100644 --- a/apps/framework/scripts/run-vercel-evals.ts +++ b/apps/framework/scripts/run-vercel-evals.ts @@ -2,7 +2,7 @@ import { APIError, Sandbox } from '@vercel/sandbox'; import { execFile, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -51,6 +51,7 @@ interface RunnerOptions { timeoutSec: number; concurrency: number; vcpus: number; + strict: boolean; } interface PairOptions extends RunnerOptions { @@ -255,6 +256,7 @@ async function runPairOnce( args: [ 'eval', '--', + ...(options.strict ? ['--strict'] : []), '--experiment', pair.experiment, '--experiment-suite', @@ -503,6 +505,29 @@ export function parsePairs(value: string): EvalPair[] { return parsed.data; } +/** + * Drops pairs published by a newer main revision when this checkout does not + * contain their prompt. Existing local evals still reach run-eval validation. + */ +export function filterLocallyAvailablePairs( + pairs: readonly EvalPair[], + evalsRoot = join(ROOT, 'evals'), + note: (message: string) => void = console.warn +): EvalPair[] { + const missing = new Set(); + const available = pairs.filter((pair) => { + if (existsSync(join(evalsRoot, pair.eval_id, 'PROMPT.md'))) return true; + missing.add(pair.eval_id); + return false; + }); + for (const evalId of missing) { + note( + `SKIP ${evalId} (published eval is absent from this checkout: evals/${evalId}/PROMPT.md not found)` + ); + } + return available; +} + /** Returns an environment variable or a useful configuration error. */ function requireEnv(name: string, hint: string): string { const value = process.env[name]; @@ -603,9 +628,8 @@ async function main(): Promise { const rawArgs = process.argv.slice(2).filter((arg) => arg !== '--'); const pairsValue = readFlag(rawArgs, 'pairs-json') ?? process.env.EVAL_PAIRS; if (!pairsValue) throw new Error('--pairs-json or EVAL_PAIRS is required'); - const options: RunnerOptions = { - pairs: parsePairs(pairsValue), + pairs: filterLocallyAvailablePairs(parsePairs(pairsValue)), revision: readFlag(rawArgs, 'revision') ?? currentRevision(), repoUrl: readFlag(rawArgs, 'repo-url') ?? repositoryUrl(), outputDir: resolve( @@ -622,6 +646,7 @@ async function main(): Promise { 'concurrency' ), vcpus: positiveInteger(readFlag(rawArgs, 'vcpus') ?? '4', 'vcpus'), + strict: rawArgs.includes('--strict'), }; console.log( @@ -629,6 +654,7 @@ async function main(): Promise { ); for (const pair of options.pairs) console.log(`PLAN ${pairLabel(pair)}`); if (rawArgs.includes('--dry-run')) return; + if (options.pairs.length === 0) return; await runPairs(options); } diff --git a/apps/framework/scripts/smoke-local.ts b/apps/framework/scripts/smoke-local.ts new file mode 100644 index 00000000..b5a0c3f9 --- /dev/null +++ b/apps/framework/scripts/smoke-local.ts @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const ROOT = join(import.meta.dirname, '..', '..', '..'); +const SANDBOX = mkdtempSync(join(tmpdir(), 'smoke-eval-strict-')); +const EXPERIMENT = 'claude-code-sonnet-5'; +const evalIds = readdirSync(join(ROOT, 'evals')).filter((id) => { + const prompt = join(ROOT, 'evals', id, 'PROMPT.md'); + if (!existsSync(prompt)) { + console.warn( + `SKIP ${id} (eval is absent from this checkout: evals/${id}/PROMPT.md not found)` + ); + return false; + } + return existsSync(join(ROOT, 'evals', id, 'EVAL.ts')); +}); +const EVAL = evalIds[0]; +assert.ok(EVAL, 'no eval fixture found'); +const JUDGED_EVAL = evalIds.find((id) => + /\bjudge\b/.test(readFileSync(join(ROOT, 'evals', id, 'EVAL.ts'), 'utf8')) +); +assert.ok(JUDGED_EVAL, 'no judged eval fixture found'); + +const fakeScript = join(SANDBOX, 'fake-eval.cjs'); +writeFileSync( + fakeScript, + `const fs = require('node:fs'); +const path = require('node:path'); +fs.mkdirSync(path.dirname(process.env.RES), { recursive: true }); +fs.writeFileSync(process.env.RES, JSON.stringify({ + passed: true, + checks: [{ name: 'fake run', passed: true }], + attempts: 1, + skills: { available: [], loaded: [] }, + docs: { calls: [] }, + toolCalls: [], + transcript: [], + agentReport: '', + stoppedReason: 'end_turn' +})); +` +); +const FAKE = `${JSON.stringify(process.execPath)} ${JSON.stringify(fakeScript)}`; + +function runEval(args: string[], env: Record = {}) { + const result = spawnSync('pnpm', ['eval', '--', ...args], { + cwd: ROOT, + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, + ANTHROPIC_API_KEY: 'placeholder', + OPENAI_API_KEY: 'placeholder', + LOCAL_EVAL_CMD: FAKE, + LOCAL_RESULTS_ROOT: SANDBOX, + FORCE_COLOR: '0', + ...env, + }, + }); + return { + output: `${result.stdout}\n${result.stderr}`, + status: result.status, + }; +} + +let passed = 0; +function check(name: string, assertion: () => void, diagnostic?: string) { + try { + assertion(); + passed += 1; + } catch (error) { + console.error(`FAIL: ${name}`); + if (diagnostic) { + throw new Error(`${name} failed\n\nChild output:\n${diagnostic}`, { + cause: error, + }); + } + throw error; + } +} + +try { + { + const result = runEval([ + '--strict', + '--experiment', + 'bogus-model', + '--eval', + EVAL, + ]); + check('unknown experiment is refused', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /no experiment matched: bogus-model/); + }); + } + + { + const result = runEval([ + '--strict', + '--experiment', + EXPERIMENT, + '--eval', + 'not-an-eval-dir', + ]); + check('unknown eval is refused', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /no eval matched: not-an-eval-dir/); + }); + } + + { + const result = runEval( + ['--strict', '--experiment', EXPERIMENT, '--eval', EVAL], + { ANTHROPIC_API_KEY: '' } + ); + check('strict refuses a missing agent key', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /ANTHROPIC_API_KEY/); + }); + } + + { + const result = runEval(['--experiment', EXPERIMENT, '--eval', EVAL], { + ANTHROPIC_API_KEY: '', + }); + check('default mode keeps the missing-key skip', () => { + assert.equal(result.status, 0, result.output); + assert.match(result.output, new RegExp(`SKIP ${EXPERIMENT}`)); + }); + } + + { + const emptySkills = join(SANDBOX, 'empty-skills'); + mkdirSync(emptySkills); + const result = runEval( + ['--strict', '--experiment', EXPERIMENT, '--eval', EVAL], + { LOCAL_SKILLS_ROOT: emptySkills } + ); + check('strict refuses missing experiment skills', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /declares skills this checkout is missing/); + assert.match(result.output, /git submodule update --init/); + }); + } + + { + const partialSkills = join(SANDBOX, 'partial-skills'); + mkdirSync(join(partialSkills, 'supabase'), { recursive: true }); + mkdirSync(join(partialSkills, 'supabase-postgres-best-practices'), { + recursive: true, + }); + const result = runEval( + ['--strict', '--experiment', EXPERIMENT, '--eval', EVAL], + { LOCAL_SKILLS_ROOT: partialSkills } + ); + check('strict refuses skill directories without SKILL.md', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /declares skills this checkout is missing/); + }); + } + + { + const result = runEval( + ['--strict', '--experiment', EXPERIMENT, '--eval', JUDGED_EVAL], + { OPENAI_API_KEY: '', LOCAL_EVAL_CMD: '' } + ); + check('judged eval without OPENAI_API_KEY is refused pre-spend', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /score with the LLM judge/); + assert.match(result.output, /add OPENAI_API_KEY/); + }); + } + + { + const result = runEval([ + '--strict', + '--experiment', + EXPERIMENT, + '--eval', + EVAL, + '--mcp', + '/definitely/not/a/path', + ]); + check('missing MCP override path is refused', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /--mcp path does not exist/); + }); + } + + const mcpCheckout = join(SANDBOX, 'mcp-checkout'); + const mcpPackage = join(mcpCheckout, 'packages', 'mcp-server-supabase'); + mkdirSync(mcpPackage, { recursive: true }); + writeFileSync(join(mcpPackage, 'package.json'), '{"version":"0.0.0"}'); + + { + const result = runEval([ + '--strict', + '--experiment', + EXPERIMENT, + '--eval', + EVAL, + '--mcp', + mcpCheckout, + ]); + check('unbuilt MCP override is refused with a build hint', () => { + assert.equal(result.status, 1, result.output); + assert.match(result.output, /no built server at .*mcp-server-supabase/); + assert.match(result.output, /pnpm install && pnpm build/); + }); + } + + mkdirSync(join(mcpPackage, 'dist', 'transports'), { recursive: true }); + writeFileSync(join(mcpPackage, 'dist', 'transports', 'stdio.js'), ''); + + const receiptRun = runEval([ + '--strict', + '--experiment', + EXPERIMENT, + '--eval', + EVAL, + '--mcp', + mcpCheckout, + ]); + check('built MCP override reaches the eval path', () => { + assert.equal(receiptRun.status, 0, receiptRun.output); + assert.match(receiptRun.output, new RegExp(`PASS ${EXPERIMENT} x ${EVAL}`)); + }); + + const resultPath = join(SANDBOX, 'results', EXPERIMENT, `${EVAL}.json`); + check( + 'result receipt stays under results experiment subdirectory', + () => { + const receipt = JSON.parse(readFileSync(resultPath, 'utf8')); + assert.equal(receipt.eval, EVAL); + assert.equal(receipt.experiment, EXPERIMENT); + assert.ok(receipt.provenance.generatedAt); + assert.equal(receipt.provenance.host.sha.length, 40); + assert.match( + receipt.provenance.mcpOverride.path, + /packages[/\\]mcp-server-supabase$/ + ); + assert.equal(existsSync(join(SANDBOX, 'results-local')), false); + }, + receiptRun.output + ); + + { + writeFileSync(resultPath, '{'); + const result = runEval([ + '--strict', + '--skip-existing', + '--experiment', + EXPERIMENT, + '--eval', + EVAL, + ]); + check('skip-existing reruns a corrupt result', () => { + assert.equal(result.status, 0, result.output); + assert.match(result.output, /existing result is invalid/); + assert.match(result.output, new RegExp(`PASS ${EXPERIMENT} x ${EVAL}`)); + assert.doesNotThrow(() => JSON.parse(readFileSync(resultPath, 'utf8'))); + }); + } + + { + const result = runEval([ + '--strict', + '--skip-existing', + '--experiment', + EXPERIMENT, + '--eval', + EVAL, + ]); + check('strict keeps skip-existing intentional', () => { + assert.equal(result.status, 0, result.output); + assert.match(result.output, /already ran/); + }); + } + + { + const result = runEval(['list', '--strict', '--eval', EVAL], { + ANTHROPIC_API_KEY: '', + OPENAI_API_KEY: '', + }); + check('strict keeps list planning free of credential gates', () => { + assert.equal(result.status, 0, result.output); + assert.match(result.output, /claude-code-sonnet-5/); + }); + } +} finally { + rmSync(SANDBOX, { recursive: true, force: true }); +} + +console.log(`smoke-local: ${passed} checks passed`); diff --git a/packages/core/src/eval-metadata.ts b/packages/core/src/eval-metadata.ts index 66c3f8bf..6d94b683 100644 --- a/packages/core/src/eval-metadata.ts +++ b/packages/core/src/eval-metadata.ts @@ -347,6 +347,7 @@ const evalResultShape = { // Raw result files may carry extra fields we don't model; tolerate them. export const rawEvalResultSchema = z.looseObject(evalResultShape); +export type RawEvalResult = z.infer; // Web-facing result; a clean strict object so its inferred type stays usable. export const evalResultSchema = z.object({