diff --git a/bench/check.sh b/bench/check.sh new file mode 100755 index 00000000..134556de --- /dev/null +++ b/bench/check.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# The final type-check, lint and tests of one run, from a clean worktree of its head: a fresh +# install with lifecycle scripts off, then the repository's own commands from selection.json. +# +# BENCH_OUT= bench/check.sh +set -euo pipefail +run=$1 +here=$(cd "$(dirname "$0")" && pwd) +dir=${BENCH_OUT:?}/runs/$run +id=$(jq -r .id "$dir/meta.json") head=$(jq -r .head "$dir/meta.json") +export npm_config_ignore_scripts=true YARN_ENABLE_SCRIPTS=0 CI=1 +rm -rf "$dir/check" +git -C "$dir/repo" worktree prune +git -C "$dir/repo" worktree add -q --detach "$dir/check" "$head" +cd "$dir/check" +result='{}' +for step in install typecheck lint test; do + cmd=$(jq -r --arg id "$id" --arg s "$step" '.repos[] | select(.id == $id) | .checks[$s] // ""' "$here/selection.json") + [ -z "$cmd" ] && continue + code=0 + bash -c "$cmd" >"$dir/check-$step.log" 2>&1 || code=$? + result=$(jq --arg s "$step" --argjson c "$code" '.[$s] = $c' <<<"$result") + # Nothing else means anything without dependencies. + [ "$step" = install ] && [ "$code" != 0 ] && break +done +echo "$result" >"$dir/checks.json" +echo "$run $result" diff --git a/bench/claude-sandbox.json b/bench/claude-sandbox.json new file mode 100644 index 00000000..99146156 --- /dev/null +++ b/bench/claude-sandbox.json @@ -0,0 +1,27 @@ +{ + "sandbox": { + "enabled": true, + "failIfUnavailable": true, + "allowUnsandboxedCommands": false, + "autoAllowBashIfSandboxed": true, + "filesystem": { + "allowWrite": ["~/.npm", "~/Library/pnpm", "~/Library/Caches", "~/.cache"] + }, + "network": { + "allowedDomains": [ + "registry.npmjs.org", + "registry.yarnpkg.com", + "repo.yarnpkg.com", + "github.com", + "codeload.github.com", + "objects.githubusercontent.com" + ], + "strictAllowlist": true, + "allowLocalBinding": true + } + }, + "permissions": { + "deny": ["Bash(dangerouslyDisableSandbox:true)", "WebFetch", "WebSearch"], + "allow": ["Bash"] + } +} diff --git a/bench/excluded.json b/bench/excluded.json new file mode 100644 index 00000000..607a52d8 --- /dev/null +++ b/bench/excluded.json @@ -0,0 +1,443 @@ +[ + { + "pack": "react-router", + "repo": "Arize-ai/phoenix", + "commit": "4d6c1dfb01e9406cf769efab14f0350ea2d0a0f8", + "from": "6.25.1", + "to": "7.4.0" + }, + { + "pack": "stripe", + "repo": "AtlasDevHQ/atlas", + "commit": "cfaf3c3c500995a4dc1e918ef89de20e535af43f", + "from": "21.0.1", + "to": "22.2.0" + }, + { + "pack": "@sentry/browser", + "repo": "Automancer-Ltd/automancer-site", + "commit": "ea78c738e8de80efbb796b506fbc81bb4f649f70", + "from": "10.75.2", + "to": "11.4.0" + }, + { + "pack": "typescript", + "repo": "Borewit/music-metadata", + "commit": "783509b27bac24e244dd75e66c9b4a0828c35a96", + "from": "5.9.3", + "to": "6.0.3" + }, + { + "pack": "zod", + "repo": "CANShift/canshift-core", + "commit": "3ad0a35edd902e94cb3014f1331cd38528532e3f", + "from": "3.25.76", + "to": "4.4.3" + }, + { + "pack": "typescript", + "repo": "CroffZ/github-release-notifier", + "commit": "d9353f2471c554f0ce339496f0058a27599f4965", + "from": "5.9.3", + "to": "6.0.3" + }, + { + "pack": "next", + "repo": "HiroshiIgarash/koremina", + "commit": "e9a1595083f746c8cb481e1ca7156aca6db6d4d0", + "from": "15.5.10", + "to": "16.1.6" + }, + { + "pack": "typescript", + "repo": "JosephMaynard/dependency-radar", + "commit": "1920eb3217b701e43afc1651db3f6197028e6f0e", + "from": "5.9.3", + "to": "6.0.3" + }, + { + "pack": "typescript", + "repo": "SignalK/signalk-derived-data", + "commit": "02f635e8232a433462593ed1192ff22e2e9b6253", + "from": "5.9.3", + "to": "6.0.3" + }, + { + "pack": "@sentry/node", + "repo": "TryGhost/ActivityPub", + "commit": "f8a8233219f927b39a41418c7cce0f2fa4867b67", + "from": "10.75.3", + "to": "11.0.0" + }, + { + "pack": "redis", + "repo": "UNN-Devotek/FCM-Fallout-Chat-Mod", + "commit": "280190489fac1ac8b75bf5fda8fd54265f339d7e", + "from": "4.7.1", + "to": "6.0.0" + }, + { + "pack": "express", + "repo": "amiantos/lurker", + "commit": "f7a0f3dac77570ec3fd93a487ccf0aaf2f6ca5db", + "from": "4.22.2", + "to": "5.2.1" + }, + { + "pack": "zod", + "repo": "band-ai/band-sdk-typescript", + "commit": "5d9a66a1dde4989fbc0608200f291f0ca8194052", + "from": "3.25.76", + "to": "4.6.5" + }, + { + "pack": "express", + "repo": "bcgov/tenant-management-system", + "commit": "6fd01755043a3db9ed0577ffa6b1b4a184ae670f", + "from": "4.22.2", + "to": "5.2.1" + }, + { + "pack": "next", + "repo": "benshabbat/GamesForMyKids", + "commit": "793cb46f19f2bc279dbf741a74cf7083a5025310", + "from": "15.5.15", + "to": "16.2.6" + }, + { + "pack": "@sentry/nextjs", + "repo": "cohstats/coh3-stats", + "commit": "5b9c60bd6575ed4a4bcd8d2bb71529766675a7e6", + "from": "10.75.3", + "to": "11.1.0" + }, + { + "pack": "openai", + "repo": "csnyder256/ux-struggle-detector", + "commit": "b73084896208f7d2794b84bf23de3157eca5fc09", + "from": "4.104.0", + "to": "7.23.0" + }, + { + "pack": "lucide-react", + "repo": "cubicsteak/keep", + "commit": "9380d10ea22b66e4559779d7cc3afb4f209cf198", + "from": "0.483.0", + "to": "1.47.0" + }, + { + "pack": "redis", + "repo": "deanmoses/tacocat-gallery-sam", + "commit": "34e11760f6e767abef6afd53f973dc155f6f8c4b", + "from": "4.6.12", + "to": "5.10.0" + }, + { + "pack": "redis", + "repo": "deanmoses/tacocat-gallery-sam", + "commit": "30724e21efd6db123a7385e73482cac237b1e9ba", + "from": "5.12.1", + "to": "6.2.1" + }, + { + "pack": "openai", + "repo": "digitalchokro/askchokro", + "commit": "047fc4485538a3bff4224a9a604d83143c2cd5d2", + "from": "4.104.0", + "to": "7.4.0" + }, + { + "pack": "joi", + "repo": "edtech4good/edtech-lms-api", + "commit": "ce2281005fcf0c41a883779c778f3123a48bbeec", + "from": "17.6.0", + "to": "18.2.9" + }, + { + "pack": "react", + "repo": "excalidraw/excalidraw", + "commit": "9ee0b8ffcbd3664a47748a93262860321a203821", + "from": "18.2.0", + "to": "19.0.0" + }, + { + "pack": "ioredis", + "repo": "ferni-ai/voiceai", + "commit": "c59ed0982f96e23d2b2f5728711da5b55e07483a", + "from": "5.8.2", + "to": "6.0.0" + }, + { + "pack": "@sentry/node", + "repo": "frog-pond/ccc-server", + "commit": "75fdc083bbf719c5c577b4c3068528776bea6fa3", + "from": "10.75.3", + "to": "11.2.0" + }, + { + "pack": "react-router", + "repo": "getlago/lago-front", + "commit": "84aa05d535edc326a4d104923e4e7293d1be6646", + "from": "6.30.6", + "to": "7.18.3" + }, + { + "pack": "openai", + "repo": "grantsingleton/batch-ai", + "commit": "05c7e9f132b3e6ec6504de3d82371889fdc2d368", + "from": "4.83.0", + "to": "7.4.0" + }, + { + "pack": "zod", + "repo": "howardyang2009/PATH", + "commit": "bb171417d911f373c8df28ab7a1e0698b62e0906", + "from": "3.25.76", + "to": "4.6.5" + }, + { + "pack": "ioredis", + "repo": "kazumadev619-dev/FishingConditionsApp", + "commit": "8ba3f0d24bc709169b79df11daa87307df32e77c", + "from": "5.11.1", + "to": "6.0.0" + }, + { + "pack": "typescript", + "repo": "m-lyon/recipe", + "commit": "f6e44b530c4dab5afc9ca6f3505774d5b44fcd33", + "from": "5.6.2", + "to": "6.0.3" + }, + { + "pack": "@sentry/nextjs", + "repo": "maximunited/yomu", + "commit": "228fc3e4f7abf1850e4d03d32535e28b5173306c", + "from": "10.75.1", + "to": "11.1.0" + }, + { + "pack": "openai", + "repo": "microsoft/prompty", + "commit": "e878b6b7789ff621265ca086b20d4e2d70914f81", + "from": "4.104.0", + "to": "7.10.0" + }, + { + "pack": "ai", + "repo": "miurla/morphic", + "commit": "989a05697d973030f2fbb4b805326bb24e68fd0d", + "from": "6.0.180", + "to": "7.0.66" + }, + { + "pack": "express", + "repo": "netlify/cli", + "commit": "63b6f1cbca316fa6c338d59e5b5053a11de25c6c", + "from": "4.22.1", + "to": "5.2.1" + }, + { + "pack": "stripe", + "repo": "nextjs/saas-starter", + "commit": "3e5fd2bf626c4ee73c06dcc9e1aa503b8904a37c", + "from": "17.7.0", + "to": "18.0.0" + }, + { + "pack": "openai", + "repo": "openai/openai-guardrails-js", + "commit": "6c13ba08ab021215fd6f8c900ae15ae29627f2d2", + "from": "4.104.0", + "to": "7.9.0" + }, + { + "pack": "express", + "repo": "openhab/openhab-cloud", + "commit": "5a1866efe1c038e287d2d59091951e1912db12f8", + "from": "4.22.1", + "to": "5.2.1" + }, + { + "pack": "@sentry/react", + "repo": "owine/youth-activity-scheduler", + "commit": "18b467f5c4f2fc066e14dc5edfe10874016ba3d3", + "from": "10.75.3", + "to": "11.0.0" + }, + { + "pack": "typescript", + "repo": "robrechtme/recipes", + "commit": "0853747427b71f1d5de275e72aead59979fc1a03", + "from": "5.9.3", + "to": "6.0.3" + }, + { + "pack": "ioredis", + "repo": "safe-global/safe-client-gateway", + "commit": "14bd05b98bf41df509de0bfee8bc662cb9d95d1e", + "from": "5.11.1", + "to": "6.0.0" + }, + { + "pack": "next", + "repo": "sambecker/exif-photo-blog", + "commit": "3f1a36354d203e5a28f7c2cd55eceda95a13e3bd", + "from": "15.5.5", + "to": "16.0.1" + }, + { + "pack": "@sentry/browser", + "repo": "sbomify/sbomify", + "commit": "4c2be20126779d91d629b9478c6a0aff0239abfe", + "from": "10.75.2", + "to": "11.0.0" + }, + { + "pack": "joi", + "repo": "senacor/azure-function-middleware", + "commit": "5c2da676e7327681dde70db6229edeebfa76b0f4", + "from": "17.13.1", + "to": "18.0.1" + }, + { + "pack": "typescript", + "repo": "shogo82148/typescript-action", + "commit": "0f03405c7267fafaebee5d57e773c1bb56e28b44", + "from": "5.9.3", + "to": "6.0.3" + }, + { + "pack": "ioredis", + "repo": "simondietschi/dokunc", + "commit": "83681ed0ccc527fa1dd5c802f554f26a5eac575c", + "from": "5.11.1", + "to": "6.0.0" + }, + { + "pack": "lucide-react", + "repo": "spizeck/SFPCA", + "commit": "7f5b9dcb8985260e7b9ab345be971b392bd129a5", + "from": "0.563.0", + "to": "1.47.0" + }, + { + "pack": "stripe", + "repo": "starl3xx/wallet-to-social", + "commit": "b7f60f1167b4665746e455696f6aa34caa75ad0c", + "from": "20.2.0", + "to": "22.6.2" + }, + { + "pack": "next", + "repo": "stoczec/wortkarte", + "commit": "e3bd3679ae6ea60ce2a78aefebec0caa48c4febd", + "from": "15.5.27", + "to": "16.3.8" + }, + { + "pack": "@sentry/react", + "repo": "successible/cleanslate", + "commit": "5d45401e6562e6c028028774c9097ecb4737dc6e", + "from": "10.74.0", + "to": "11.0.0" + }, + { + "pack": "redis", + "repo": "swoofer/mcp-coordinator", + "commit": "8b337f0a081d0d0e0c3724890b9cc847b6c5dd6f", + "from": "5.12.1", + "to": "6.2.0" + }, + { + "pack": "ioredis", + "repo": "syzygyhack/open-foundry", + "commit": "73910018b5e8119cf4db99a81ff145726451aa3b", + "from": "5.10.1", + "to": "6.0.0" + }, + { + "pack": "redis", + "repo": "tearingItUp786/blog", + "commit": "bb88f3afc40df07e29da461c6aed6c34709bc6bf", + "from": "4.7.0", + "to": "6.0.0" + }, + { + "pack": "react", + "repo": "tldraw/tldraw", + "commit": "5957867a9c5c205a0b0c3665be443138b8014a79", + "from": "18.3.1", + "to": "19.2.1" + }, + { + "pack": "lucide-react", + "repo": "tradeblocks-org/tradeblocks", + "commit": "918c0dadccacec7e0533ea1a208f252562ac711f", + "from": "0.577.0", + "to": "1.52.0" + }, + { + "pack": "zod", + "repo": "ugurkocde/AwesomeIntune", + "commit": "dcf38603fd55cea72737e17274d5de03cece341e", + "from": "3.25.76", + "to": "4.6.5" + }, + { + "pack": "stripe", + "repo": "unkeyed/unkey", + "commit": "37044fc57fe08c917e92b323933baf322d6df1c6", + "from": "14.23.0", + "to": "22.2.0" + }, + { + "pack": "ai", + "repo": "vercel/chatbot", + "commit": "77d362e596e2c624638cad5a35199c0c8785c9fd", + "from": "6.0.116", + "to": "7.0.9" + }, + { + "pack": "vitest", + "repo": "vitorvasc/opentelemetry-ecosystem-explorer", + "commit": "46d5b37d00bbf27f394e33cee64a0e206c31315f", + "from": "4.1.11", + "to": "5.0.0" + }, + { + "pack": "vitest", + "repo": "votingworks/vxsuite", + "commit": "5cb3c7d9c1abc891e7b12de57c0278790dd6160a", + "from": "4.1.11", + "to": "5.0.2" + }, + { + "pack": "zod", + "repo": "vzdc-artcc/website", + "commit": "48a93f65a0976e342d4c15145b51de8eef030214", + "from": "3.25.76", + "to": "4.6.5" + }, + { + "pack": "openai", + "repo": "wardby/wardby", + "commit": "d46ec64dc0be781aa43967d9b6d26be9386aa67d", + "from": "4.104.0", + "to": "7.30.0" + }, + { + "pack": "@sentry/nextjs", + "repo": "xdoubleu/tools.xdoubleu.com", + "commit": "d3b780d42c8937af42d37072f7b0948bced39d47", + "from": "10.75.3", + "to": "11.0.0" + }, + { + "pack": "openai", + "repo": "zeikar/charivo", + "commit": "b9a476d9acb59f473e08f0e97ce6e91dc2201d99", + "from": "4.104.0", + "to": "7.10.0" + } +] diff --git a/bench/judge.sh b/bench/judge.sh new file mode 100755 index 00000000..6cf8f065 --- /dev/null +++ b/bench/judge.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Let the repository's own CI judge one run: a fork in the uptide-bench organization whose +# default branch is reset to the parent commit (so `pull_request` branch filters match), the +# run's head pushed as bench/, and a pull request between the two, on the fork only. +# Pushes go to the fork's explicit URL; the original repository is never written to. +# +# BENCH_OUT= bench/judge.sh fork, push, open the pull request +# BENCH_OUT= bench/judge.sh --collect wait for its checks once, store them +set -euo pipefail +run=$1 +here=$(cd "$(dirname "$0")" && pwd) +dir=${BENCH_OUT:?}/runs/$run +org=uptide-bench +id=$(jq -r .id "$dir/meta.json") head=$(jq -r .head "$dir/meta.json") +sel() { jq -er --arg id "$id" ".repos[] | select(.id == \$id) | $1" "$here/selection.json"; } +repo=$(sel .repo) parent=$(sel .parent) +fork=$org/${repo%%/*}--${repo##*/} + +if [ "${2:-}" = --collect ]; then + pr=$(jq -r .number "$dir/pr.json") + [ "$pr" = null ] && exit 0 + gh pr checks "$pr" --repo "$fork" --watch --interval 60 >/dev/null 2>&1 || : + gh pr checks "$pr" --repo "$fork" --json name,workflow,state,bucket >"$dir/ci.json" || : + echo "$run $(jq -c 'group_by(.bucket) | map({(.[0].bucket): length}) | add' "$dir/ci.json")" + exit 0 +fi + +if [ "$head" = "$parent" ]; then + echo '{"number": null, "skipped": "no commits"}' >"$dir/pr.json" + echo "$run no commits, no pull request" + exit 0 +fi +if ! gh repo view "$fork" >/dev/null 2>&1; then + gh repo fork "$repo" --org "$org" --fork-name "${fork#*/}" --clone=false --default-branch-only + sleep 5 +fi +# Forks start with Actions off. +gh api -X PUT "repos/$fork/actions/permissions" -F enabled=true -f allowed_actions=all >/dev/null +base=$(gh api "repos/$fork" --jq .default_branch) +url=https://github.com/$fork.git +git -C "$dir/repo" push -q --force "$url" "$parent:refs/heads/$base" +git -C "$dir/repo" push -q --force "$url" "$head:refs/heads/bench/$run" +head -c 60000 "$dir/summary.md" >"$dir/pr-body.md" +[ -s "$dir/pr-body.md" ] || echo "(no summary)" >"$dir/pr-body.md" +gh api "repos/$fork/pulls" -f title="bench $run" -f head="bench/$run" -f base="$base" \ + -F body=@"$dir/pr-body.md" --jq '{number, url: .html_url}' >"$dir/pr.json" +echo "$run $(cat "$dir/pr.json")" diff --git a/bench/run.sh b/bench/run.sh new file mode 100755 index 00000000..23fb1166 --- /dev/null +++ b/bench/run.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# One run of one arm on one repository of bench/selection.json. +# +# BENCH_OUT= bench/run.sh +# +# Both arms start from the same fresh clone of the parent commit, with installs' lifecycle +# scripts off and pushes to the original repository disabled. `human` checks out the human's +# upgrade commit instead: the reference every score is measured against. +# The run gets an opaque id; $BENCH_OUT/key.tsv is the only place its arm is written, so +# check.sh, judge.sh and score.mjs never see it. +set -euo pipefail +arm=$1 id=$2 +here=$(cd "$(dirname "$0")" && pwd) +out=${BENCH_OUT:?set BENCH_OUT to a directory outside this repository} +sel() { jq -er --arg id "$id" ".repos[] | select(.id == \$id) | $1" "$here/selection.json"; } +repo=$(sel .repo) parent=$(sel .parent) upgrade=$(sel .upgrade) pkg=$(sel .package) +from=$(sel .from) to=$(sel .to) +# Uptide's default executor model and effort (packages/core/src/llm/pricing.ts DEFAULT_MODELS, +# capabilities.ts defaultEffort), used by both arms. +model=claude-sonnet-5-5 effort=medium + +run=$(uuidgen | tr '[:upper:]' '[:lower:]' | cut -c1-8) +dir=$out/runs/$run +mkdir -p "$dir" +printf '%s\t%s\t%s\n' "$run" "$id" "$arm" >>"$out/key.tsv" +git clone -q "https://github.com/$repo.git" "$dir/repo" +cd "$dir/repo" +git checkout -q -b bench-base "$parent" +git remote set-url --push origin no-push +git config user.name "UpgradeBench" && git config user.email bench@uptide.invalid +export npm_config_ignore_scripts=true YARN_ENABLE_SCRIPTS=0 +# A developer's checkout is installed; Uptide refuses one that is not. Untimed, both arms. +bash -c "$(sel .checks.install)" >"$dir/setup.log" 2>&1 + +start=$(date +%s) status=0 cost=0 +case $arm in +claude) + prompt="Upgrade $pkg from $from to $to. Make the code compile and work with the new version, keep behavior correct, and make sure type-check, lint and tests pass. Commit and summarize what to review." + # --bare: none of this machine's hooks, plugins, memory or CLAUDE.md; --add-dir loads the + # repository's own CLAUDE.md as a plain `claude -p` in it would. Bash runs only inside + # Claude Code's sandbox (claude-sandbox.json); anything that would prompt is denied. + claude -p --bare --add-dir "$PWD" --model "$model" --effort "$effort" \ + --settings "$here/claude-sandbox.json" --permission-mode acceptEdits --permission-prompts none \ + --no-session-persistence --output-format json --max-budget-usd "${CLAUDE_CAP_USD:-6}" \ + "$prompt" >"$dir/agent.json" 2>"$dir/agent.log" || status=$? + # Claude Code does not know this model's price and reports it at an older Sonnet's; price its + # tokens with Uptide's table (pricing.ts, per million: input, output, cache read, 5m write), + # the one Uptide's own cost uses. + cost=$(jq '[.modelUsage // {} | to_entries[] + | (if (.key | test("haiku")) then [1, 5, 0.1, 1.25] else [2, 10, 0.2, 2.5] end) as $p + | (.value.inputTokens * $p[0] + .value.outputTokens * $p[1] + + .value.cacheReadInputTokens * $p[2] + .value.cacheCreationInputTokens * $p[3]) / 1e6] + | add // 0' "$dir/agent.json" 2>/dev/null || echo 0) + jq -r '.result // ""' "$dir/agent.json" >"$dir/summary.md" 2>/dev/null || : >"$dir/summary.md" + ;; +uptide) + UPTIDE_MODEL=$model node "$here/../packages/cli/dist/index.js" fix "$pkg" --target "$to" \ + --json >"$dir/agent.json" 2>"$dir/agent.log" || status=$? + branch=$(git for-each-ref --format='%(refname:short)' "refs/heads/uptide/$pkg-*" | head -1) + [ -n "$branch" ] && git checkout -q "$branch" + cost=$(jq '.llm.costUsd // 0' "$dir/agent.json" 2>/dev/null || echo 0) + cp "$(jq -r '.prBody // empty' "$dir/agent.json" 2>/dev/null)" "$dir/summary.md" 2>/dev/null || + tail -5 "$dir/agent.log" >"$dir/summary.md" + ;; +human) + git checkout -q "$upgrade" + git log -1 --format=%B >"$dir/summary.md" + ;; +*) echo "unknown arm $arm" >&2 && exit 2 ;; +esac +wall=$(($(date +%s) - start)) cost=${cost:-0} +dirty=$(git status --porcelain --untracked-files=no -- . ':!.uptide' | wc -l | tr -d ' ') +jq -n --arg id "$id" --arg head "$(git rev-parse HEAD)" --argjson status "$status" \ + --argjson cost "$cost" --argjson wall "$wall" --argjson dirty "$dirty" \ + '{id: $id, head: $head, exit: $status, costUsd: $cost, wallSeconds: $wall, uncommittedFiles: $dirty}' \ + >"$dir/meta.json" +echo "$run $(cat "$dir/meta.json")" diff --git a/bench/score.mjs b/bench/score.mjs new file mode 100644 index 00000000..3c5ad20f --- /dev/null +++ b/bench/score.mjs @@ -0,0 +1,188 @@ +// Scores every run in $BENCH_OUT/runs the same way, blind to arm: it never reads key.tsv. +// The reference of a repository is the run whose head is the human's upgrade commit. +// +// BENCH_OUT= node bench/score.mjs writes $BENCH_OUT/scores.json +// BENCH_OUT= node bench/score.mjs --unblind then prints the table with arms joined in +// +// Measures, on code files only (lockfiles, package.json and docs left out): +// - coverage: human hunks (git diff -U0 parent..upgrade) whose old lines the run also changed; +// a pure insertion counts as the two lines around it. Missed hunks are the candidates for +// escaped regressions, read by hand. +// - unrelated: run hunks in files the human left alone that never name the package. +// - regressions: local checks and CI checks that fail on the run and pass on the reference. +// - unverified: whether the run's summary or PR body says what it did not verify. +// - silent changes (ADR 0017): the selection's `silent` sites, behavior the upgrade must keep that +// no check shows. Caught when the run's diff touches the site's line and the file at the run's +// head has the `keep` edit, or when the PR body names the record. A silent false positive is a +// site a PR body's "Behavior change ()" line names that the selection does not. +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CODE = /\.(c|m)?(t|j)sx?$/; +const UNVERIFIED = + /not (been )?(verified|tested|checked|run)|did(n't| not) (run|verify|test|check)|could(n't| not) (run|verify|test)|unable to|untested|unverified|manual(ly)? (review|check|test)|needs? (manual|human)|not covered|wasn't able/i; + +const read = (file, fallback) => + existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback; +const git = (repo, ...args) => + execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 1 << 28 }); + +/** Hunks of `git diff -U0 a b` on code files: file, old start, old count. */ +export function hunks(diff) { + const found = []; + let file; + for (const line of diff.split('\n')) { + const header = /^diff --git a\/(.+) b\//.exec(line); + if (header) file = CODE.test(header[1]) ? header[1] : undefined; + const hunk = /^@@ -(\d+)(?:,(\d+))? /.exec(line); + if (hunk && file) + found.push({ + file, + start: Number(hunk[1]), + count: hunk[2] === undefined ? 1 : Number(hunk[2]), + }); + } + return found; +} +/** Old-side lines a hunk touches; an insertion after line n touches n and n + 1. */ +export const lines = (h) => + h.count === 0 ? [h.start, h.start + 1] : Array.from({ length: h.count }, (_, i) => h.start + i); + +export function compare(human, agent) { + const touched = new Set(agent.flatMap((h) => lines(h).map((l) => `${h.file}:${l}`))); + const missed = human.filter((h) => !lines(h).some((l) => touched.has(`${h.file}:${l}`))); + return { covered: human.length - missed.length, missed }; +} + +/** Silent sites caught by an edit or by a decision citing the record, and silent false positives. */ +export function silent(entry, agent, summary, headText) { + const truth = entry.silent ?? []; + const touched = new Set(agent.flatMap((h) => lines(h).map((l) => `${h.file}:${l}`))); + const edited = truth.filter( + (s) => touched.has(`${s.file}:${s.line}`) && new RegExp(s.keep).test(headText(s.file)), + ); + const decided = truth.filter((s) => !edited.includes(s) && summary.includes(s.record)); + const known = new Set(truth.map((s) => `${s.record} ${s.file}:${s.line}`)); + const named = [...summary.matchAll(/\*\*Behavior change \(([^)\s]+)\):\*\*([^\n]*)/g)].flatMap( + ([, record, line]) => + [...line.matchAll(/`([^`\s]+):(\d+)`/g)].map(([, file, n]) => `${record} ${file}:${n}`), + ); + const at = (s) => `${s.file}:${s.line}`; + return { + silentSites: truth.length, + silentEdited: edited.map(at), + silentDecided: decided.map(at), + silentFalsePositives: [...new Set(named.filter((n) => !known.has(n)))], + }; +} + +function score(out, selection, run) { + const dir = join(out, 'runs', run); + const meta = read(join(dir, 'meta.json')); + const entry = selection.repos.find((r) => r.id === meta.id); + const repo = join(dir, 'repo'); + const human = hunks(git(repo, 'diff', '-U0', '--no-renames', entry.parent, entry.upgrade)); + const agent = hunks(git(repo, 'diff', '-U0', '--no-renames', entry.parent, meta.head)); + const { covered, missed } = compare(human, agent); + const humanFiles = new Set(human.map((h) => h.file)); + const names = new RegExp(`['"]${entry.package.replace(/[/.]/g, '\\$&')}(/[^'"]*)?['"]`); + const unrelated = agent.filter((h) => { + if (humanFiles.has(h.file)) return false; + try { + return !names.test(git(repo, 'show', `${entry.parent}:${h.file}`)); + } catch { + return false; // a file the run created + } + }); + const summaryFile = join(dir, 'summary.md'); + const summary = existsSync(summaryFile) ? readFileSync(summaryFile, 'utf8') : ''; + const headText = (file) => { + try { + return git(repo, 'show', `${meta.head}:${file}`); + } catch { + return ''; + } + }; + return { + run, + id: meta.id, + head: meta.head, + reference: meta.head === entry.upgrade, + exit: meta.exit, + costUsd: meta.costUsd, + wallSeconds: meta.wallSeconds, + uncommittedFiles: meta.uncommittedFiles, + humanHunks: human.length, + covered, + missed: missed.map((h) => `${h.file}:${h.start}`), + agentHunks: agent.length, + agentFiles: new Set(agent.map((h) => h.file)).size, + unrelated: unrelated.map((h) => `${h.file}:${h.start}`), + unverified: UNVERIFIED.exec(summary)?.[0] ?? null, + ...silent(entry, agent, summary, headText), + checks: read(join(dir, 'checks.json'), {}), + ci: read(join(dir, 'ci.json'), []).map((c) => ({ + name: `${c.workflow}/${c.name}`, + bucket: c.bucket, + })), + }; +} + +export function regressions(row, reference) { + if (!reference || row.reference) return []; + const local = Object.entries(row.checks) + .filter(([step, code]) => code !== 0 && reference.checks[step] === 0) + .map(([step]) => `local ${step}`); + const passed = new Set(reference.ci.filter((c) => c.bucket === 'pass').map((c) => c.name)); + const ci = row.ci + .filter((c) => c.bucket === 'fail' && passed.has(c.name)) + .map((c) => `ci ${c.name}`); + return [...local, ...ci]; +} + +function table(rows, arms) { + const ci = (r) => { + const n = (b) => r.ci.filter((c) => c.bucket === b).length; + return r.ci.length ? `${n('pass')} pass, ${n('fail')} fail` : 'none'; + }; + const checks = (r) => + Object.entries(r.checks) + .map(([s, c]) => `${s} ${c === 0 ? 'ok' : 'FAIL'}`) + .join(', ') || 'not run'; + const lines = [ + '| repo | arm | exit | coverage | missed | unrelated | regressions vs human | silent changes caught | silent FP | states unverified | local checks | fork CI | cost | wall |', + '|---|---|---|---|---|---|---|---|---|---|---|---|---|---|', + ]; + const caught = (r) => + r.silentSites + ? `${r.silentEdited.length + r.silentDecided.length}/${r.silentSites} (edit ${r.silentEdited.length})` + : 'n/a'; + const sorted = [...rows].sort( + (a, b) => a.id.localeCompare(b.id) || arms[a.run].localeCompare(arms[b.run]), + ); + for (const r of sorted) + lines.push( + `| ${r.id} | ${arms[r.run]} | ${r.exit} | ${r.covered}/${r.humanHunks} | ${r.missed.length} | ${r.unrelated.length} | ${r.regressions.join(', ') || 'none'} | ${caught(r)} | ${r.silentFalsePositives.length} | ${r.unverified ? 'yes' : 'no'} | ${checks(r)} | ${ci(r)} | $${Number(r.costUsd).toFixed(2)} | ${Math.round(r.wallSeconds / 60)} min |`, + ); + return lines.join('\n'); +} + +if (process.argv[1] === new URL(import.meta.url).pathname) { + const out = process.env.BENCH_OUT; + if (!out) throw new Error('set BENCH_OUT'); + const selection = JSON.parse(readFileSync(new URL('./selection.json', import.meta.url), 'utf8')); + const rows = readdirSync(join(out, 'runs')).map((run) => score(out, selection, run)); + for (const row of rows) + row.regressions = regressions( + row, + rows.find((r) => r.reference && r.id === row.id), + ); + writeFileSync(join(out, 'scores.json'), `${JSON.stringify(rows, null, 2)}\n`); + if (process.argv.includes('--unblind')) { + const key = readFileSync(join(out, 'key.tsv'), 'utf8').trim().split('\n'); + console.log( + table(rows, Object.fromEntries(key.map((l) => [l.split('\t')[0], l.split('\t')[2]]))), + ); + } +} diff --git a/bench/score.test.mjs b/bench/score.test.mjs new file mode 100644 index 00000000..cf1ec833 --- /dev/null +++ b/bench/score.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { compare, hunks, regressions, silent } from './score.mjs'; + +const diff = (...parts) => parts.join('\n'); +const human = hunks( + diff( + 'diff --git a/src/a.ts b/src/a.ts', + '@@ -3 +3 @@', + '@@ -10,0 +11,2 @@', + 'diff --git a/package-lock.json b/package-lock.json', + '@@ -1,4 +1,4 @@', + 'diff --git a/src/b.tsx b/src/b.tsx', + '@@ -7,2 +7,0 @@', + ), +); + +test('code hunks only, with old start and count', () => { + assert.deepEqual(human, [ + { file: 'src/a.ts', start: 3, count: 1 }, + { file: 'src/a.ts', start: 10, count: 0 }, + { file: 'src/b.tsx', start: 7, count: 2 }, + ]); +}); + +test('a hunk is covered when the run touches one of its old lines', () => { + const agent = hunks(diff('diff --git a/src/a.ts b/src/a.ts', '@@ -2,2 +2,2 @@', '@@ -11 +11 @@')); + const { covered, missed } = compare(human, agent); + assert.equal(covered, 2); // line 3, and the insertion after 10 by touching 11 + assert.deepEqual(missed, [{ file: 'src/b.tsx', start: 7, count: 2 }]); +}); + +test('regressions are failures the reference does not have', () => { + const reference = { + reference: true, + checks: { test: 0, lint: 1 }, + ci: [{ name: 'ci/build', bucket: 'pass' }], + }; + const row = { checks: { test: 1, lint: 1 }, ci: [{ name: 'ci/build', bucket: 'fail' }] }; + assert.deepEqual(regressions(row, reference), ['local test', 'ci ci/build']); +}); + +test('silent changes: caught by the keep edit at the site or a decision naming the record; a named site the selection lacks is a false positive', () => { + const record = 'zod/mcp-input-schema-additional-properties'; + const entry = { + silent: [69, 83, 102].map((line) => ({ + file: 'src/tools/index.ts', + line, + record, + keep: '\\.strict\\(\\)|strictObject\\(', + })), + }; + const agent = hunks( + diff('diff --git a/src/tools/index.ts b/src/tools/index.ts', '@@ -69 +69 @@'), + ); + const head = () => 'function strictInput(shape) { return z.object(shape).strict(); }'; + assert.deepEqual(silent(entry, agent, '', head), { + silentSites: 3, + silentEdited: ['src/tools/index.ts:69'], + silentDecided: [], + silentFalsePositives: [], + }); + // The line touched without the edit is not caught. + assert.deepEqual(silent(entry, agent, '', () => 'z.object(shape)').silentEdited, []); + const body = `- **Behavior change (${record}):** … Not applied at \`src/tools/index.ts:83\`, \`src/other.ts:4\`: recommended`; + assert.deepEqual(silent(entry, agent, body, head), { + silentSites: 3, + silentEdited: ['src/tools/index.ts:69'], + silentDecided: ['src/tools/index.ts:83', 'src/tools/index.ts:102'], + silentFalsePositives: [`${record} src/other.ts:4`], + }); + assert.equal(silent({}, agent, body, head).silentSites, 0); +}); diff --git a/bench/selection.json b/bench/selection.json new file mode 100644 index 00000000..83a1e612 --- /dev/null +++ b/bench/selection.json @@ -0,0 +1,115 @@ +{ + "$comment": "UpgradeBench v0 pilot (UPT-24): three held-out public repositories, each with a human's merged upgrade in the range of a verified pack. None is in any pack's ground-truth.json (bench/excluded.json). Committed before either arm ran. checks are the repository's own commands, run by bench/check.sh with lifecycle scripts off.", + "selected": "2026-10-15", + "repos": [ + { + "id": "zod-calmcp", + "repo": "consetto/calmcp", + "license": "MIT", + "pack": "zod", + "package": "zod", + "from": "3.25.76", + "to": "4.6.5", + "parent": "741de4eba98eb96b4bed817d8974e9e8c6eec867", + "upgrade": "bdaef39c34705b2dfc1065a59ec683288a2e99d2", + "silent": [ + { + "file": "src/tools/index.ts", + "line": 69, + "record": "zod/mcp-input-schema-additional-properties", + "keep": "\\.strict\\(\\)|strictObject\\(" + }, + { + "file": "src/tools/index.ts", + "line": 83, + "record": "zod/mcp-input-schema-additional-properties", + "keep": "\\.strict\\(\\)|strictObject\\(" + }, + { + "file": "src/tools/index.ts", + "line": 102, + "record": "zod/mcp-input-schema-additional-properties", + "keep": "\\.strict\\(\\)|strictObject\\(" + }, + { + "file": "src/tools/index.ts", + "line": 119, + "record": "zod/mcp-input-schema-additional-properties", + "keep": "\\.strict\\(\\)|strictObject\\(" + }, + { + "file": "src/tools/index.ts", + "line": 148, + "record": "zod/mcp-input-schema-additional-properties", + "keep": "\\.strict\\(\\)|strictObject\\(" + } + ], + "note": "npm, single package. The upgrade (merged in PR #22) changes 3 source files, a test and a script: z.string().uuid() to z.guid(), .strict() tool schemas, a _def walker rewritten for zod 4. Mostly behavior, little a compiler sees. The commit carries a Co-Authored-By: Claude trailer.", + "ci": ".github/workflows/ci.yml: build (lint, typecheck, test, build, test:e2e), btp-descriptors, docker; tool-surface.yml", + "checks": { + "install": "npm ci --ignore-scripts", + "typecheck": "npm run typecheck", + "lint": "npm run lint", + "test": "npm test" + } + }, + { + "id": "stripe-dms-saas", + "repo": "AntelopeJS/dms-saas", + "license": "Apache-2.0", + "pack": "stripe", + "package": "stripe", + "from": "18.5.0", + "to": "22.6.2", + "parent": "6390f434622db5d6e546854b435fb1017c3cc665", + "upgrade": "f992f335089cfb17ca2b86d8d9d6a908ddbaee2a", + "note": "pnpm 10.2.0 workspace of 2 packages plus a nested frontend-vue project with its own lockfile. The upgrade (PR #70) changes 14 source files: subscriptions.cancel options, schedule phase iterations to duration, allowed_payment_method_types, billing mode, apiVersion basil to dahlia. dms-saas type-checks only after the interface package is built.", + "ci": ".github/workflows/ci.yml: static (build, lint, typecheck, knip, check:exports), unit (vitest), frontend", + "checks": { + "install": "corepack pnpm install --frozen-lockfile --ignore-scripts && corepack pnpm --dir packages/dms-saas/frontend-vue install --frozen-lockfile --ignore-scripts", + "typecheck": "corepack pnpm --filter @antelopejs/interface-dms-saas build && corepack pnpm -r typecheck", + "lint": "corepack pnpm -r lint", + "test": "corepack pnpm --filter @antelopejs/dms-saas test:unit" + } + }, + { + "id": "react-enki", + "repo": "entur/enki", + "license": "EUPL-1.2", + "pack": "react", + "package": "react", + "from": "18.3.1", + "to": "19.2.0", + "parent": "08cf2a70267185a74c362c15bab3b3826e4be3f2", + "upgrade": "ed37dd231b49628b7611760dfcde750e9c5a8e02", + "note": "npm, single package (Vite). The upgrade is the merge of PR #1760 (scored against its first parent): react, react-dom and their types to 19, react-leaflet 4 to 5, MutableRefObject to RefObject, useRef arguments, useReducer generics, 11 source files.", + "ci": ".github/workflows/pull-request-build-external.yml: fork pull requests, npm ci, prettier check, vitest, build (tsc and vite)", + "checks": { + "install": "npm ci --ignore-scripts", + "typecheck": "./node_modules/.bin/tsc --noEmit", + "lint": "npm run check", + "test": "./node_modules/.bin/vitest run" + } + } + ], + "backups": [ + { + "repo": "sukhmal/servicenow-mcp", + "package": "zod", + "parent": "9c71205e26752ecb5a14cf95b26fcbe475af1839", + "upgrade": "0406fba4baf28c9eba8d0690d70eab1b5e38f8e7" + }, + { + "repo": "likecoin/likecoin-api-public", + "package": "stripe", + "parent": "4526998344fb4905034c9248bbed4b80e0fde242", + "upgrade": "12480cda61716226155dec06a98a9cd67737741b" + }, + { + "repo": "Tietokilta/web", + "package": "next", + "parent": "f6f51b539594504d2d91adff3b5a14e5dc075b9f", + "upgrade": "d96361254033243cb8165bc3277f0be15649aba7" + } + ] +}