Browse Source

file generator

James Long 3 tháng trước cách đây
mục cha
commit
c41dc21292

+ 63 - 0
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")

+ 397 - 0
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<string, string>
+  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<string, string>
+  readonly patchedFiles: Record<string, string>
+  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<T extends string>(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<typeof ${fn}Defaults> = {}) {`,
+      `  const config = { ...${fn}Defaults, ...input }`,
+      "  await Promise.resolve()",
+      "  return {",
+      "    ...config,",
+      "    ready: config.retries > 0 && config.timeout > 0,",
+      "  }",
+      "}",
+      "",
+    ],
+    () => [
+      `export class ${fn}Store {`,
+      "  #items = new Map<string, number>()",
+      "",
+      "  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<string, number>([`,
+      `  [\"${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<string, string>): 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<GeneratedFile[]>((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<string, string>,
+  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<GeneratedFile[]>(
+    (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"

+ 44 - 0
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}`)
+    }
+  })
+})