diff --git a/packages/opencode/src/testing/simulation/file-generator.play.ts b/packages/opencode/src/testing/simulation/file-generator.play.ts new file mode 100644 index 00000000000..fbd58a5b7f7 --- /dev/null +++ b/packages/opencode/src/testing/simulation/file-generator.play.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun + +import { SimulationFileGenerator } from "./file-generator" + +function arg(name: string) { + const prefix = `--${name}=` + return Bun.argv.find((item) => item.startsWith(prefix))?.slice(prefix.length) +} + +function numberArg(name: string, fallback: number) { + const value = Number(arg(name)) + return Number.isFinite(value) ? value : fallback +} + +function weight(name: string) { + const value = Number(arg(name)) + return Number.isFinite(value) ? value : undefined +} + +const generated = SimulationFileGenerator.generateFiles({ + seed: numberArg("seed", Math.random() * 10000 | 0), + count: numberArg("count", 10), + maxDepth: numberArg("max-depth", 3), + maxWidth: numberArg("max-width", 5), + root: arg("root") ?? "src", + weights: { + txt: weight("txt"), + ts: weight("ts"), + shallow: weight("shallow"), + deep: weight("deep"), + }, +}) + +const patches = SimulationFileGenerator.generatePatches(generated, { + seed: numberArg("patch-seed", numberArg("seed", 1) + 1), + count: numberArg("patch-count", 3), + patchDir: arg("patch-dir") ?? ".simulation/patches", + weights: { + editTxt: weight("edit-txt"), + editTs: weight("edit-ts"), + }, +}) + +if (Bun.argv.includes("--json")) { + console.log(JSON.stringify({ generated, patches }, null, 2)) + process.exit(0) +} + +console.log(`seed: ${generated.seed}`) +console.log(`files: ${generated.entries.length}`) +for (const entry of generated.entries) { + console.log(`${entry.path}: ${entry.content}\n`) +} + +console.log(`\npatch seed: ${patches.seed}`) +console.log(`patches: ${patches.patches.length}`) +for (const patch of patches.patches) { + console.log('--------------------------------------------------------------------') + console.log(patch.patch) +} + +console.log("\nTry: bun src/testing/simulation/file-generator.play.ts --seed=42 --count=5 --ts=4 --txt=1 --deep=3") +console.log("JSON: bun src/testing/simulation/file-generator.play.ts --json") diff --git a/packages/opencode/src/testing/simulation/file-generator.ts b/packages/opencode/src/testing/simulation/file-generator.ts new file mode 100644 index 00000000000..1131dd2e178 --- /dev/null +++ b/packages/opencode/src/testing/simulation/file-generator.ts @@ -0,0 +1,397 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +export interface Weights { + readonly txt?: number + readonly ts?: number + readonly shallow?: number + readonly deep?: number + readonly editTxt?: number + readonly editTs?: number +} + +export interface GenerateFilesOptions { + readonly seed?: number + readonly count?: number + readonly maxDepth?: number + readonly maxWidth?: number + readonly root?: string + readonly weights?: Weights +} + +export interface GeneratedFile { + readonly path: string + readonly kind: "txt" | "ts" + readonly content: string +} + +export interface GeneratedFiles { + readonly seed: number + readonly files: Record + readonly entries: readonly GeneratedFile[] +} + +export interface GeneratePatchesOptions { + readonly seed?: number + readonly count?: number + readonly patchDir?: string + readonly weights?: Weights +} + +export interface GeneratedPatch { + readonly path: string + readonly target: string + readonly before: string + readonly after: string + readonly patch: string +} + +export interface GeneratedPatches { + readonly seed: number + readonly files: Record + readonly patchedFiles: Record + readonly patches: readonly GeneratedPatch[] +} + +type Random = () => number + +const defaults = { + txt: 1, + ts: 1, + shallow: 3, + deep: 1, + editTxt: 1, + editTs: 1, +} + +function random(seed: number): Random { + let current = seed + return () => { + current = (current * 1664525 + 1013904223) >>> 0 + return current / 0x100000000 + } +} + +function resolveSeed(value: number | undefined) { + return value ?? Math.floor(Math.random() * 0xffffffff) + 1 +} + +function integer(rng: Random, min: number, max: number) { + return min + Math.floor(rng() * (max - min + 1)) +} + +function pickWeighted(rng: Random, entries: readonly (readonly [T, number])[]) { + const total = entries.reduce((sum, item) => sum + Math.max(0, item[1]), 0) + const threshold = rng() * (total || entries.length) + const picked = entries.reduce<{ value: T; remaining: number }>( + (state, item) => + state.remaining <= 0 ? state : { value: item[0], remaining: state.remaining - Math.max(0, item[1]) }, + { value: entries[0][0], remaining: threshold }, + ) + return picked.remaining <= 0 ? picked.value : entries.at(-1)![0] +} + +function slug(rng: Random) { + const words = ["alpha", "bravo", "cedar", "delta", "ember", "field", "glade", "harbor", "iris", "juniper"] + return `${words[integer(rng, 0, words.length - 1)]}-${integer(rng, 1, 99)}` +} + +function generatedPath( + rng: Random, + input: { root: string; kind: "txt" | "ts"; maxDepth: number; maxWidth: number; weights: Weights }, +) { + const depthBias = pickWeighted(rng, [ + ["shallow", input.weights.shallow ?? defaults.shallow], + ["deep", input.weights.deep ?? defaults.deep], + ]) + const depth = depthBias === "shallow" ? integer(rng, 0, Math.min(1, input.maxDepth)) : integer(rng, 1, input.maxDepth) + return path.posix.join( + input.root, + ...Array.from({ length: depth }, () => `dir-${integer(rng, 1, input.maxWidth)}`), + `${slug(rng)}.${input.kind}`, + ) +} + +function txtContent(rng: Random, name: string) { + return Array.from( + { length: integer(rng, 2, 6) }, + (_, index) => `${name} note ${index + 1}: ${slug(rng)} ${slug(rng)}.`, + ).join("\n") +} + +function tsContent(rng: Random, name: string) { + const fn = name.replace(/[^a-zA-Z0-9]/g, "_") + const templates = [ + () => [ + `export const ${fn}Value = ${integer(rng, 1, 100)}`, + "", + `export function ${fn}Message(input = \"${slug(rng)}\") {`, + ` return \`${name}: \${input}\``, + "}", + "", + ], + () => [ + `export interface ${fn}Record {`, + " readonly id: string", + " readonly enabled: boolean", + " readonly tags: readonly string[]", + "}", + "", + `export const ${fn}Records: readonly ${fn}Record[] = [`, + ` { id: \"${slug(rng)}\", enabled: ${rng() > 0.5}, tags: [\"${slug(rng)}\", \"${slug(rng)}\"] },`, + ` { id: \"${slug(rng)}\", enabled: ${rng() > 0.5}, tags: [] },`, + "]", + "", + `export function ${fn}Enabled() {`, + ` return ${fn}Records.filter((item) => item.enabled)`, + "}", + "", + ], + () => [ + `export type ${fn}Event =`, + ` | { readonly type: \"created\"; readonly id: string; readonly count: number }`, + ` | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }`, + ` | { readonly type: \"deleted\"; readonly id: string }`, + "", + `export function ${fn}Label(event: ${fn}Event) {`, + ` if (event.type === \"created\") return \`created:\${event.id}:\${event.count}\``, + ` if (event.type === \"updated\") return \`updated:\${event.id}:\${event.fields.length}\``, + ` return \`deleted:\${event.id}\``, + "}", + "", + `export const ${fn}Sample: ${fn}Event = { type: \"created\", id: \"${slug(rng)}\", count: ${integer(rng, 1, 20)} }`, + "", + ], + () => [ + `const ${fn}Defaults = {`, + ` retries: ${integer(rng, 1, 5)},`, + ` timeout: ${integer(rng, 100, 900)},`, + ` label: \"${slug(rng)}\",`, + "} as const", + "", + `export async function load${fn}(input: Partial = {}) {`, + ` const config = { ...${fn}Defaults, ...input }`, + " await Promise.resolve()", + " return {", + " ...config,", + " ready: config.retries > 0 && config.timeout > 0,", + " }", + "}", + "", + ], + () => [ + `export class ${fn}Store {`, + " #items = new Map()", + "", + " add(key: string, value: number) {", + " this.#items.set(key, (this.#items.get(key) ?? 0) + value)", + " return this", + " }", + "", + " snapshot() {", + " return Object.fromEntries(this.#items.entries())", + " }", + "}", + "", + `export const ${fn}StoreInstance = new ${fn}Store().add(\"${slug(rng)}\", ${integer(rng, 1, 10)})`, + "", + ], + () => [ + `export const ${fn}Matrix = [`, + ` [${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}],`, + ` [${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}],`, + "] as const", + "", + `export const ${fn}Total = ${fn}Matrix.flatMap((row) => row).reduce((sum, value) => sum + value, 0)`, + "", + `export const ${fn}Lookup = new Map([`, + ` [\"${slug(rng)}\", ${integer(rng, 10, 99)}],`, + ` [\"${slug(rng)}\", ${integer(rng, 10, 99)}],`, + "])", + "", + ], + ] + return templates[integer(rng, 0, templates.length - 1)]().join("\n") +} + +function mutate(rng: Random, file: GeneratedFile) { + const addHeavy = rng() > 0.5 + if (file.kind === "txt") { + const lines = file.content.trimEnd().split("\n") + const removeCount = Math.min(addHeavy ? integer(rng, 0, 1) : integer(rng, 2, 5), Math.max(0, lines.length - 1)) + const removed = new Set(Array.from({ length: removeCount }, () => integer(rng, 0, Math.max(0, lines.length - 1)))) + const editAt = integer(rng, 0, Math.max(0, lines.length - 1)) + const edited = lines + .filter((_, index) => lines.length <= 1 || !removed.has(index)) + .map((line, index) => (index === editAt ? `${line} Extra detail: ${slug(rng)} ${slug(rng)}.` : line)) + return [ + ...edited, + "", + ...Array.from({ length: addHeavy ? integer(rng, 3, 6) : integer(rng, 0, 2) }, (_, index) => + [ + `## mutation ${integer(rng, 1, 999)}.${index + 1}`, + `${slug(rng)} ${slug(rng)} ${slug(rng)}.`, + `status=${rng() > 0.5 ? "active" : "pending"}`, + ].join("\n"), + ), + "", + ].join("\n") + } + + const lines = file.content.trimEnd().split("\n") + const removable = lines + .map((line, index) => ({ line, index })) + .filter((item) => item.line.trim() && !item.line.trim().endsWith("{") && !item.line.trim().startsWith("}")) + const removeCount = Math.min(addHeavy ? integer(rng, 0, 2) : integer(rng, 3, 8), Math.max(0, removable.length - 1)) + const removed = new Set( + Array.from({ length: removeCount }, () => removable[integer(rng, 0, removable.length - 1)]?.index).filter( + (item): item is number => item !== undefined, + ), + ) + const renamed = lines + .filter((_, index) => !removed.has(index)) + .map((line) => + line.includes("Value =") && rng() > 0.35 ? line.replace(/= \d+/, `= ${integer(rng, 101, 999)}`) : line, + ) + const blocks = Array.from({ length: addHeavy ? integer(rng, 3, 6) : integer(rng, 0, 2) }, () => { + const id = `generated${integer(rng, 100, 999)}` + const templates = [ + () => [ + `export const ${id}Config = {`, + ` id: "${slug(rng)}",`, + ` retries: ${integer(rng, 1, 5)},`, + ` flags: ["${slug(rng)}", "${slug(rng)}"],`, + "} as const", + ], + () => [ + `export function ${id}Normalize(input: readonly string[]) {`, + ` return input.map((item) => item.trim()).filter(Boolean).join("${rng() > 0.5 ? "," : "|"}")`, + "}", + ], + () => [ + `export type ${id}State =`, + ` | { readonly ok: true; readonly value: "${slug(rng)}" }`, + ` | { readonly ok: false; readonly reason: "${slug(rng)}" }`, + ], + () => [ + `export const ${id}Items = [`, + ` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`, + ` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`, + ` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`, + "] as const", + ], + ] + return templates[integer(rng, 0, templates.length - 1)]().join("\n") + }) + const insertAt = rng() > 0.5 ? 0 : renamed.length + return [ + ...renamed.slice(0, insertAt), + "", + ...blocks.flatMap((block) => [block, ""]), + ...renamed.slice(insertAt), + "", + ].join("\n") +} + +function gitPatch(file: string, before: string, after: string) { + const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-simulation-diff-")) + mkdirSync(path.join(dir, "a", path.dirname(file)), { recursive: true }) + mkdirSync(path.join(dir, "b", path.dirname(file)), { recursive: true }) + writeFileSync(path.join(dir, "a", file), before) + writeFileSync(path.join(dir, "b", file), after) + const result = Bun.spawnSync(["git", "-C", dir, "diff", "--no-index", "--no-prefix", "--", `a/${file}`, `b/${file}`]) + rmSync(dir, { recursive: true, force: true }) + if (result.exitCode > 1) throw new Error(new TextDecoder().decode(result.stderr)) + return new TextDecoder().decode(result.stdout) +} + +function isGeneratedFiles(input: GeneratedFiles | Record): input is GeneratedFiles { + return Array.isArray(Reflect.get(input, "entries")) +} + +export function generateFiles(options: GenerateFilesOptions = {}): GeneratedFiles { + const resolvedSeed = resolveSeed(options.seed) + const rng = random(resolvedSeed) + const weights = { ...defaults, ...options.weights } + const count = options.count ?? 12 + const maxDepth = Math.max(0, options.maxDepth ?? 3) + const maxWidth = Math.max(1, options.maxWidth ?? 5) + const root = options.root ?? "src" + const entries = Array.from({ length: count }).reduce((result) => { + const kind = pickWeighted(rng, [ + ["txt", weights.txt], + ["ts", weights.ts], + ]) + const file = generatedPath(rng, { root, kind, maxDepth, maxWidth, weights }) + if (result.some((item) => item.path === file)) return result + const name = path.posix.basename(file, path.posix.extname(file)).replace(/-/g, "_") + return [...result, { path: file, kind, content: kind === "txt" ? txtContent(rng, name) : tsContent(rng, name) }] + }, []) + + return { + seed: resolvedSeed, + entries, + files: Object.fromEntries(entries.map((entry) => [entry.path, entry.content])), + } +} + +export function generatePatches( + input: GeneratedFiles | Record, + options: GeneratePatchesOptions = {}, +): GeneratedPatches { + const resolvedSeed = resolveSeed(options.seed) + const rng = random(resolvedSeed) + const weights = { ...defaults, ...options.weights } + const sourceEntries: GeneratedFile[] = isGeneratedFiles(input) + ? [...input.entries] + : Object.entries(input).map(([file, content]) => ({ + path: file, + kind: file.endsWith(".ts") ? ("ts" as const) : ("txt" as const), + content, + })) + const entries = sourceEntries + .filter((entry) => entry.path.endsWith(".txt") || entry.path.endsWith(".ts")) + .sort((a, b) => a.path.localeCompare(b.path)) + const selected = Array.from({ length: Math.min(options.count ?? 4, entries.length) }).reduce( + (result) => { + const candidates = entries.filter((entry) => !result.some((item) => item.path === entry.path)) + if (candidates.length === 0) return result + const targetKind = pickWeighted(rng, [ + ["txt", weights.editTxt], + ["ts", weights.editTs], + ]) + const preferred = candidates.filter((entry) => entry.kind === targetKind) + return [ + ...result, + (preferred.length ? preferred : candidates)[ + integer(rng, 0, (preferred.length ? preferred : candidates).length - 1) + ], + ] + }, + [], + ) + const patches = selected.map((entry, index) => { + const after = mutate(rng, entry) + const patch = gitPatch(entry.path, entry.content, after) + return { + path: path.posix.join( + options.patchDir ?? ".simulation/patches", + `${String(index + 1).padStart(3, "0")}-${path.posix.basename(entry.path)}.patch`, + ), + target: entry.path, + before: entry.content, + after, + patch, + } + }) + + return { + seed: resolvedSeed, + files: Object.fromEntries(patches.map((patch) => [patch.path, patch.patch])), + patchedFiles: Object.fromEntries(patches.map((patch) => [patch.target, patch.after])), + patches, + } +} + +export * as SimulationFileGenerator from "./file-generator" diff --git a/packages/opencode/test/testing/simulation/file-generator.test.ts b/packages/opencode/test/testing/simulation/file-generator.test.ts new file mode 100644 index 00000000000..f64ea05b2a8 --- /dev/null +++ b/packages/opencode/test/testing/simulation/file-generator.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { parsePatch } from "diff" +import { SimulationFileGenerator } from "../../../src/testing/simulation/file-generator" + +describe("SimulationFileGenerator", () => { + test("generates deterministic txt and ts files", () => { + const first = SimulationFileGenerator.generateFiles({ seed: 42, count: 8 }) + const second = SimulationFileGenerator.generateFiles({ seed: 42, count: 8 }) + + expect(first).toEqual(second) + expect(first.entries.length).toBe(8) + expect(first.entries.some((entry) => entry.kind === "txt")).toBe(true) + expect(first.entries.some((entry) => entry.kind === "ts")).toBe(true) + expect(Object.keys(first.files)).toEqual(first.entries.map((entry) => entry.path)) + }) + + test("uses weights to steer file kind and tree depth", () => { + const generated = SimulationFileGenerator.generateFiles({ + seed: 7, + count: 6, + maxDepth: 4, + weights: { txt: 0, ts: 1, shallow: 0, deep: 1 }, + }) + + expect(generated.entries.every((entry) => entry.kind === "ts")).toBe(true) + expect(generated.entries.every((entry) => entry.path.split("/").length > 2)).toBe(true) + }) + + test("generates patch files for existing generated files", () => { + const generated = SimulationFileGenerator.generateFiles({ seed: 3, count: 5 }) + const patches = SimulationFileGenerator.generatePatches(generated, { seed: 9, count: 3, patchDir: ".git/patches" }) + + expect(patches.patches.length).toBe(3) + expect(Object.keys(patches.files)).toEqual(patches.patches.map((patch) => patch.path)) + expect(Object.keys(patches.patchedFiles)).toEqual(patches.patches.map((patch) => patch.target)) + for (const patch of patches.patches) { + expect(generated.files[patch.target]).toBe(patch.before) + expect(patch.after).not.toBe(patch.before) + expect(patch.path.startsWith(".git/patches/")).toBe(true) + expect(patch.patch.startsWith(`diff --git a/${patch.target} b/${patch.target}`)).toBe(true) + expect(parsePatch(patch.patch)[0].oldFileName).toBe(`a/${patch.target}`) + } + }) +})