feat(react-edit-benchmark): implemented AST-based mutation system

- Replaced regex-based code mutation with Babel AST.
- Introduced a wider range of precise, structural bug types.
- Integrated code formatting into generated benchmark fixtures.
- Updated dependencies with Babel and `regexp-tree`.
This commit is contained in:
can1357
2026-02-18 03:29:08 +01:00
parent 95c9bc4f52
commit 3c65b3b1f5
6 changed files with 1198 additions and 687 deletions
+26 -1
View File
@@ -53,7 +53,7 @@
"chalk": "^5.6.2",
"openai": "^6.22.0",
"partial-json": "^0.1.7",
"zod": "4.3.6",
"zod": "^4.3.6",
"zod-to-json-schema": "^3.25.1",
},
"devDependencies": {
@@ -113,11 +113,16 @@
"react-edit-benchmark": "src/index.ts",
},
"dependencies": {
"@babel/generator": "7.29.1",
"@babel/parser": "7.29.0",
"@babel/traverse": "7.29.0",
"@babel/types": "7.29.0",
"@oh-my-pi/pi-agent-core": "workspace:*",
"@oh-my-pi/pi-coding-agent": "workspace:*",
"@oh-my-pi/pi-utils": "workspace:*",
"diff": "^8.0.3",
"prettier": "^3.8.1",
"regexp-tree": "0.1.27",
},
"devDependencies": {
"@types/bun": "^1.3.9",
@@ -262,10 +267,24 @@
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.2", "@biomejs/cli-darwin-x64": "2.4.2", "@biomejs/cli-linux-arm64": "2.4.2", "@biomejs/cli-linux-arm64-musl": "2.4.2", "@biomejs/cli-linux-x64": "2.4.2", "@biomejs/cli-linux-x64-musl": "2.4.2", "@biomejs/cli-win32-arm64": "2.4.2", "@biomejs/cli-win32-x64": "2.4.2" }, "bin": { "biome": "bin/biome" } }, "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA=="],
@@ -754,6 +773,8 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
@@ -914,6 +935,8 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1074,6 +1097,8 @@
"@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
"@babel/traverse/@babel/generator": ["@babel/generator@7.29.0", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"cli-truncate/string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="],
+1 -1
View File
@@ -24,7 +24,7 @@
"fix:rs": "cargo clippy --fix --allow-dirty --all-targets --no-deps --allow-staged --broken-code --allow-no-vcs && cargo fmt --all",
"build:native": "bun --cwd=packages/natives run build:native",
"dev:native": "bun --cwd=packages/natives run dev:native",
"bench:gen-fixtures": "bun run packages/react-edit-benchmark/generate.ts --react-dir /tmp/react-source --count-per-type 3",
"bench:gen-fixtures": "bun --cwd=packages/react-edit-benchmark run src/generate.ts --react-dir /tmp/react-source --count-per-type 8",
"bench:edit": "bun --cwd=packages/react-edit-benchmark run start",
"version:patch": "npm version patch -ws --no-git-tag-version && bun scripts/sync-versions.ts && rm -rf node_modules packages/*/node_modules bun.lock && bun install",
"version:minor": "npm version minor -ws --no-git-tag-version && bun scripts/sync-versions.ts && rm -rf node_modules packages/*/node_modules bun.lock && bun install",
Binary file not shown.
+6 -1
View File
@@ -24,11 +24,16 @@
"bun": ">=1.3.7"
},
"dependencies": {
"@babel/generator": "7.29.1",
"@babel/parser": "7.29.0",
"@babel/traverse": "7.29.0",
"@babel/types": "7.29.0",
"@oh-my-pi/pi-agent-core": "workspace:*",
"@oh-my-pi/pi-coding-agent": "workspace:*",
"@oh-my-pi/pi-utils": "workspace:*",
"diff": "^8.0.3",
"prettier": "^3.8.1"
"prettier": "^3.8.1",
"regexp-tree": "0.1.27"
},
"devDependencies": {
"@types/bun": "^1.3.9"
+117 -45
View File
@@ -19,16 +19,18 @@
* - nightmare: Long files where target line repeats, minimal info
*/
import * as fs from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import * as path from "node:path";
import { parseArgs } from "node:util";
import { TempDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { diffLines } from "diff";
import { formatContent } from "./formatter";
import { ALL_MUTATIONS, CATEGORY_MAP, type Mutation, type MutationInfo } from "./mutations";
const SCRIPT_DIR = import.meta.dir;
const SUPPORTED_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"]);
using DEFAULT_REACT_DIR = TempDir.createSync("@react-source");
const DEFAULT_OUTPUT = join(SCRIPT_DIR, "../fixtures.tar.gz");
const DEFAULT_OUTPUT = path.join(SCRIPT_DIR, "../fixtures.tar.gz");
const REACT_REPO_URL = "https://github.com/facebook/react.git";
const EXCLUDE_DIRS = new Set([
@@ -105,13 +107,13 @@ function parseArguments(): Args {
async function ensureReactSource(reactDir: string): Promise<void> {
if (fs.existsSync(reactDir)) {
const packagesDir = join(reactDir, "packages");
const packagesDir = path.join(reactDir, "packages");
if (fs.existsSync(packagesDir)) return;
throw new Error(`Directory exists but missing packages/: ${reactDir}`);
}
console.log(`Cloning React repository to ${reactDir}…`);
fs.mkdirSync(dirname(reactDir), { recursive: true });
fs.mkdirSync(path.dirname(reactDir), { recursive: true });
const result = await $`git clone --depth 1 ${REACT_REPO_URL} ${reactDir}`.quiet().nothrow();
if (result.exitCode !== 0) {
const decoder = new TextDecoder();
@@ -126,7 +128,7 @@ function isExcluded(filePath: string): boolean {
for (const part of parts) {
if (EXCLUDE_DIRS.has(part)) return true;
}
const filename = basename(filePath);
const filename = path.basename(filePath);
if (filename.includes(".test.") || filename.includes(".spec.") || filename.includes(".fixture.")) {
return true;
}
@@ -138,14 +140,14 @@ function hasStructure(content: string): boolean {
}
async function collectFiles(reactDir: string): Promise<string[]> {
const packagesDir = join(reactDir, "packages");
const packagesDir = path.join(reactDir, "packages");
const candidates: string[] = [];
async function walk(dir: string): Promise<void> {
if (isExcluded(dir)) return;
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
@@ -365,27 +367,6 @@ function getCandidatesForDifficulty(files: FileEntry[], difficulty: Difficulty):
}
}
async function bunCheck(content: string, suffix: string): Promise<boolean> {
await using tempPath = await TempDir.create("@rb-bench-check-");
try {
const absPath = join(tempPath.path(), suffix);
await Bun.write(absPath, content);
const result = await $`timeout 5s bun build ${absPath} --no-bundle`;
return result.exitCode === 0;
} catch {
return false;
}
}
async function isParsable(content: string, suffix: string): Promise<boolean> {
if (content.includes("@flow")) return true;
try {
return await bunCheck(content, suffix);
} catch {
return true;
}
}
function regionAvailable(usedLines: Map<string, number[]>, filePath: string, lineNumber: number): boolean {
const used = usedLines.get(filePath) ?? [];
return !used.some(ln => Math.abs(ln - lineNumber) <= 3);
@@ -404,7 +385,7 @@ function buildPrompt(
difficulty: Difficulty,
entry: FileEntry,
): string {
const header = `# Fix the bug in \`${basename(filePath)}\``;
const header = `# Fix the bug in \`${path.basename(filePath)}\``;
const isStructural = mutation.category === "structural";
const isMultiEdit = mutation.name === "identifier-multi-edit";
@@ -461,7 +442,7 @@ function buildPrompt(
}
return [
header,
"There is a subtle bug in this file. The fix is a one-character change.",
"There is a subtle bug in this file.",
"Track it down and fix it with a minimal edit.",
].join("\n\n");
}
@@ -474,6 +455,70 @@ function createSeededRng(seed: number): () => number {
};
}
function countChangedHunks(original: string, mutated: string): number {
const changes = diffLines(original, mutated);
let hunks = 0;
let inChangedChunk = false;
for (const change of changes) {
if (change.added || change.removed) {
if (!inChangedChunk) {
hunks++;
inChangedChunk = true;
}
} else {
inChangedChunk = false;
}
}
return hunks;
}
function countChangedLines(original: string, mutated: string): number {
const changes = diffLines(original, mutated);
let changedLines = 0;
for (const change of changes) {
if (!change.added && !change.removed) continue;
const split = change.value.split("\n");
changedLines += split.filter((line, idx) => idx < split.length - 1 || line.length > 0).length;
}
return changedLines;
}
function countPositionalLineChanges(original: string, mutated: string): number {
const originalLines = original.split("\n");
const mutatedLines = mutated.split("\n");
const max = Math.max(originalLines.length, mutatedLines.length);
let changed = 0;
for (let i = 0; i < max; i++) {
if ((originalLines[i] ?? "") !== (mutatedLines[i] ?? "")) {
changed++;
}
}
return changed;
}
function countPositionalLineHunks(original: string, mutated: string): number {
const originalLines = original.split("\n");
const mutatedLines = mutated.split("\n");
const max = Math.max(originalLines.length, mutatedLines.length);
let hunks = 0;
let inChanged = false;
for (let i = 0; i < max; i++) {
const changed = (originalLines[i] ?? "") !== (mutatedLines[i] ?? "");
if (changed) {
if (!inChanged) {
hunks++;
inChanged = true;
}
} else {
inChanged = false;
}
}
return hunks;
}
async function generateCase(
rng: () => number,
mutation: Mutation,
@@ -486,9 +531,17 @@ async function generateCase(
let candidates = getCandidatesForDifficulty(files, difficulty);
if (candidates.length === 0) candidates = files;
let applicable = candidates.filter(entry => mutation.canApply(entry.content));
function canApply(entry: FileEntry): boolean {
try {
return mutation.canApply(entry.content);
} catch {
return false;
}
}
let applicable = candidates.filter(canApply);
if (applicable.length === 0 && candidates !== files) {
applicable = files.filter(entry => mutation.canApply(entry.content));
applicable = files.filter(canApply);
}
if (applicable.length === 0) return null;
@@ -496,12 +549,27 @@ async function generateCase(
for (let attempt = 0; attempt < attemptLimit; attempt++) {
const entry = applicable[Math.floor(rng() * applicable.length)];
const [mutatedContent, info] = mutation.mutate(entry.content, rng);
let mutatedContent: string;
let info: { lineNumber: number; originalSnippet: string; mutatedSnippet: string };
try {
[mutatedContent, info] = mutation.mutate(entry.content, rng);
} catch {
continue;
}
if (mutatedContent === entry.content) continue;
if (!regionAvailable(usedLines, entry.path, info.lineNumber)) continue;
const changedHunks = countChangedHunks(entry.content, mutatedContent);
const changedLines = countChangedLines(entry.content, mutatedContent);
const positionalHunks = countPositionalLineHunks(entry.content, mutatedContent);
const positionalChanges = countPositionalLineChanges(entry.content, mutatedContent);
const isStructural = mutation.category === "structural";
const isMultiEdit = mutation.name === "identifier-multi-edit";
if (!isStructural && !isMultiEdit) {
if (changedHunks !== 1) continue;
if (positionalHunks !== 1) continue;
if (changedLines > 30 || positionalChanges > 30) continue;
}
const suffix = `.${entry.path.split(".").pop()}`;
if (!(await isParsable(mutatedContent, suffix))) continue;
if (!regionAvailable(usedLines, entry.path, info.lineNumber)) continue;
const diffScore = scoreDifficulty(entry, info.lineNumber);
@@ -548,9 +616,9 @@ async function writeTarball(entries: TarEntry[], outputPath: string): Promise<vo
await Bun.Archive.write(outputPath, data, { compress: "gzip" });
}
function buildCaseEntries(result: CaseResult, reactDir: string): TarEntry[] {
const filename = basename(result.filePath);
const relativePath = relative(reactDir, result.filePath);
async function buildCaseEntries(result: CaseResult, reactDir: string): Promise<TarEntry[]> {
const filename = path.basename(result.filePath);
const relativePath = path.relative(reactDir, result.filePath);
const caseDir = `fixtures/${result.caseId}`;
const lines = result.originalContent.split("\n");
@@ -593,9 +661,13 @@ function buildCaseEntries(result: CaseResult, reactDir: string): TarEntry[] {
},
};
const [formattedInput, formattedExpected] = await Promise.all([
formatContent(filename, result.mutatedContent),
formatContent(filename, result.originalContent),
]);
return [
{ name: `${caseDir}/input/${filename}`, content: ensureTrailingNewline(result.mutatedContent) },
{ name: `${caseDir}/expected/${filename}`, content: ensureTrailingNewline(result.originalContent) },
{ name: `${caseDir}/input/${filename}`, content: ensureTrailingNewline(formattedInput.formatted) },
{ name: `${caseDir}/expected/${filename}`, content: ensureTrailingNewline(formattedExpected.formatted) },
{ name: `${caseDir}/prompt.md`, content: prompt },
{ name: `${caseDir}/metadata.json`, content: JSON.stringify(metadata, null, 2) },
];
@@ -706,7 +778,7 @@ async function main(): Promise<number> {
const cases = byDifficulty.get(diff) ?? [];
console.log(`\n${diff.toUpperCase()} (${cases.length} cases):`);
for (const result of cases.slice(0, 5)) {
const rel = relative(reactDir, result.filePath);
const rel = path.relative(reactDir, result.filePath);
console.log(` ${result.caseId}: ${rel}`);
console.log(` score=${result.difficultyScore}, lines=${result.originalContent.split("\n").length}`);
}
@@ -723,12 +795,12 @@ async function main(): Promise<number> {
return 0;
}
const tarEntries: TarEntry[] = [];
const tarEntries: Promise<TarEntry[]>[] = [];
for (const result of results) {
tarEntries.push(...buildCaseEntries(result, reactDir));
tarEntries.push(buildCaseEntries(result, reactDir));
}
await writeTarball(tarEntries, args.output);
await writeTarball((await Promise.all(tarEntries)).flat(), args.output);
console.log(`Generated ${results.length} cases in ${args.output}`);
const scores = results.map(r => r.difficultyScore);
File diff suppressed because it is too large Load Diff