// Overwrite policy: per-file write decision using three-way merge against a // canonical snapshot kept under `.metaobjects/.gen-state/`. // // Replaces the rc.11 marker-based clobber-or-refuse policy (strategy (a) from // spike 002) with strategy (b) — git-merge-file-driven three-way merge. The // `@generated` marker becomes purely informational (templates may still emit // it for human readers); the policy no longer consults it. // // Per-file flow: // // 1. Render fresh content to a tmpfile. // 2. If the output doesn't exist → write + copy to .gen-state/. // 3. If .gen-state/ exists → run `git merge-file --diff3` against // (current, .gen-state snapshot, fresh tmpfile). Exit 0 → clean merge, // advance .gen-state to fresh content. Exit > 0 → leave conflict markers // in the output file, do NOT advance .gen-state; status "conflict". // 4. If the .gen-state BODY is absent but the file exists, consult the // committed hash manifest: identical fresh content → "unchanged"; the file // still hashes to what we recorded writing → safe to overwrite; anything // else (edited, or no record at all) → "refused", naming the file. The // `baseline: "fresh"` flag opts into "overwrite from fresh and re-baseline". // // Integrity (caveat 2 from the spike): we keep a sha-256 of each canonical // snapshot at `.gen-state/.hashes.json`. // // THE MANIFEST IS COMMITTED; THE BODIES ARE NOT. That split is what makes step 4 // possible. A hash per path is small and reviewable, where a second full copy of // all generated output is neither — and a hash is already sufficient to tell // "nobody touched this" from "somebody edited this", which is the only thing // steps 4 and the orphan-delete path need to know. The cost, stated: without a // body there is no base to merge against, so a diverged file on a fresh clone is // REFUSED rather than merged. That is a smaller loss than it sounds, because the // behaviour it replaces was a silent overwrite. import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, } from "node:fs"; import { dirname, join, isAbsolute, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { createHash, randomBytes } from "node:crypto"; export type WriteStatus = | "new" | "unchanged" | "overwrite" | "merged" | "conflict" | "refused" | "skipped" /** FR-038 §8 — deleted because it was generated by a previous run, is no longer * generated, and was never edited by hand. Reported as a file outcome rather * than a warning because a deletion is exactly as consequential as a write, * and a run summary that lists writes but hides deletions is how a silent * deletion happens. */ | "removed"; /** * "overwrite" — default; three-way merge if .gen-state exists, else write-if- * different / first-time-existing flow. * "skip-existing" — never write over an existing file; status "skipped". * Useful for `meta gen --dry-run` style flows. */ export type MergeStrategy = "overwrite" | "skip-existing"; /** "default" — the standard three-way merge flow described in the file header. * "fresh" — opt-in via `meta gen --baseline=fresh`. When .gen-state is absent * but the file exists, OVERWRITE with fresh content and seed .gen-state from * the fresh content (caveat 3 escape hatch). */ export type BaselineMode = "default" | "fresh"; export interface DecideAndWriteOpts { strategy?: MergeStrategy; /** Absolute path to the .gen-state/ root for this project. When undefined, * we fall back to a process-isolated tmpdir — fine for tests but the CLI * always supplies the real project value via runGen(). */ genStateDir?: string; /** Path the snapshot is keyed by — usually the path relative to the * project root, but ANY stable identifier works. When undefined, derived * from `path` (so unit tests can call without supplying it). */ outputRelPath?: string; /** First-time-existing-file behavior. */ baseline?: BaselineMode; } export interface WriteResult { path: string; status: WriteStatus; /** Present when status is "conflict" or "refused" — human-readable reason, * naming what the user must do. A refusal nobody can act on gets the file * deleted by hand, which is the outcome refusing exists to prevent. */ conflictHint?: string; } const HASHES_FILE = ".hashes.json"; type HashesFile = Record; function sha256(content: string | Buffer): string { return createHash("sha256").update(content).digest("hex"); } function loadHashes(genStateDir: string): HashesFile { const f = join(genStateDir, HASHES_FILE); if (!existsSync(f)) return {}; try { const txt = readFileSync(f, "utf-8"); const parsed: unknown = JSON.parse(txt); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as HashesFile; } return {}; } catch { return {}; } } function saveHashes(genStateDir: string, hashes: HashesFile): void { mkdirSync(genStateDir, { recursive: true }); // Keys SORTED, because this file is committed. Insertion order would make the // diff — and any merge conflict between two people who both regenerated — // depend on which entity happened to generate first, which is noise nobody can // review. const sorted: HashesFile = {}; for (const key of Object.keys(hashes).sort()) sorted[key] = hashes[key]!; writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(sorted, null, 2) + "\n"); } // --------------------------------------------------------------------------- // FR-038 §8 — readers for orphan reconciliation. // // `.hashes.json`'s key set is the ONLY record of what a previous run wrote, so // it is also the only way to notice that a file we used to generate is no longer // generated. These three functions expose that record without widening the write // path: nothing here decides anything (see reconcile-orphans.ts) and nothing here // touches an output file (see orphan-sweep.ts). // --------------------------------------------------------------------------- /** * Every snapshot key recorded under `genStateDir` — in a runner-driven project, * the project-relative path of every file some previous `meta gen` wrote. * * Empty for a gen-state directory that does not exist yet, which is what makes a * first run, an ephemeral test run and `verify --codegen`'s throwaway root all * reconcile nothing. */ export function listGeneratedPaths(genStateDir: string): string[] { return Object.keys(loadHashes(genStateDir)); } /** * sha-256 of `content`, hex — the same function that produces `.hashes.json`. * * Exported so a caller can ask the one question the manifest exists to answer * ("is this file byte-for-byte what we recorded writing?") without needing the * snapshot body, which is what makes the answer available on a fresh clone. */ export function contentHash(content: string): string { return sha256(content); } /** * The hash we recorded when we last wrote `relPath`, or undefined if we have no * record of ever writing it. * * This is the COMMITTED half of `.gen-state`. The snapshot bodies stay ignored — * they are a second full copy of all generated output — but a hash per path is * small enough to commit and review, and it is sufficient to distinguish "nobody * touched this" from "somebody edited this", which is the only distinction the * overwrite and delete decisions actually need. */ export function readGeneratedHash( genStateDir: string, relPath: string, ): string | undefined { return loadHashes(genStateDir)[relPath]; } /** * True when the file at `relPath` is byte-for-byte what we recorded writing. * * FAILS CLOSED: with no recorded hash we cannot prove anything, so the answer is * false. Both the write path and the orphan-delete path ask this one question of * this one piece of evidence — before this existed they answered the same * uncertainty in opposite directions inside a single feature, refusing to DELETE * a hand-edited file while silently OVERWRITING one. */ export function isPristineGenerated( genStateDir: string, relPath: string, current: string, ): boolean { const recorded = readGeneratedHash(genStateDir, relPath); return recorded !== undefined && recorded === sha256(current); } /** * The snapshot of what we last wrote to `relPath`, or undefined when there is no * trustworthy one. * * HASH-CHECKED on purpose. A caller comparing this against the file on disk is * asking "is the output still exactly what I wrote?" — and a snapshot that fails * its own hash cannot answer that. Returning the stale text would let a * reconciling caller conclude "untouched" and delete a file it cannot vouch for, * so a tampered snapshot reads as absent and the caller fails closed. */ export function readGeneratedSnapshot( genStateDir: string, relPath: string, ): string | undefined { return readSnapshotChecked(genStateDir, relPath)?.text; } /** * Drop both halves of the record for paths this run no longer generates — each * snapshot file and its `.hashes.json` entry. * * BOTH halves, or the next run sees the path again in `listGeneratedPaths` and * re-decides an orphan that has already been dealt with. A no-op for a path that was * never generated. * * Batched deliberately: one manifest read and one write for the whole set. A per-path * variant re-reads, re-sorts and rewrites the entire manifest every call, so clearing k * orphans rewrote it k times — on a project with hundreds of generated files that makes * bookkeeping the dominant cost of `meta gen`, for an identical result. */ export function forgetGeneratedPaths( genStateDir: string, relPaths: Iterable, ): void { const hashes = loadHashes(genStateDir); let changed = false; for (const relPath of relPaths) { const snapshot = snapshotPath(genStateDir, relPath); if (existsSync(snapshot)) rmSync(snapshot, { force: true }); if (relPath in hashes) { delete hashes[relPath]; changed = true; } } if (changed) saveHashes(genStateDir, hashes); } const ENGINE_FILE = ".engine.json"; /** * The codegen engine version that last wrote this `.gen-state`, or undefined if it * was never stamped (a pre-#232 snapshot, or a fresh project). Informational only — * a separate reserved file from `.hashes.json`, it never participates in the * three-way merge decision. (#232) */ export function loadEngineVersion(genStateDir: string): string | undefined { const f = join(genStateDir, ENGINE_FILE); if (!existsSync(f)) return undefined; try { const parsed: unknown = JSON.parse(readFileSync(f, "utf-8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const v = (parsed as { codegenVersion?: unknown }).codegenVersion; return typeof v === "string" ? v : undefined; } } catch { // Malformed → treat as unstamped; the stamp is informational, never load-bearing. } return undefined; } /** Record the codegen engine version alongside the gen-state hashes (#232). */ export function saveEngineVersion(genStateDir: string, version: string): void { mkdirSync(genStateDir, { recursive: true }); writeFileSync( join(genStateDir, ENGINE_FILE), JSON.stringify({ codegenVersion: version }, null, 2) + "\n", ); } function snapshotPath(genStateDir: string, relPath: string): string { // `.hashes.json` is reserved at the top of .gen-state/; relPath must never // collide with it. Output paths derived from entity names ("Post.ts" etc.) // are JS-identifier-shape so this is a non-issue in practice. return join(genStateDir, relPath); } /** Synchronously copy `src` content into the snapshot at `/` * and refresh the hash for `relPath`. */ function advanceSnapshot( genStateDir: string, relPath: string, content: string, ): void { const dest = snapshotPath(genStateDir, relPath); mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, content); const hashes = loadHashes(genStateDir); hashes[relPath] = sha256(content); saveHashes(genStateDir, hashes); } /** Return the snapshot text iff present AND the hash matches; else undefined. */ function readSnapshotChecked( genStateDir: string, relPath: string, ): { text: string } | undefined { const path = snapshotPath(genStateDir, relPath); if (!existsSync(path)) return undefined; const text = readFileSync(path, "utf-8"); const hashes = loadHashes(genStateDir); const expected = hashes[relPath]; if (expected !== undefined && expected !== sha256(text)) { return undefined; } return { text }; } /** Thrown when git is unavailable. Surfaces as a clear CLI error rather than * a generic ENOENT halfway through a regen. */ export class GitMissingError extends Error { constructor() { super( "meta gen: `git` binary not found on PATH. Three-way-merge regen " + "requires git (any modern version). Install git and re-run.", ); this.name = "GitMissingError"; } } interface MergeOutcome { /** 0 → clean merge; > 0 → conflicts present (file contains diff3 markers). */ exitCode: number; /** stderr text — surfaced on unexpected errors. */ stderr: string; /** Resulting file contents on disk after the merge (always read; clean or * conflicting). */ mergedContent: string; } /** Run `git merge-file --diff3 -L<...> `. The output is * written in-place into `outPath`. Returns the exit code (0 = clean, > 0 = * conflicts) and the resulting file contents. * * The git binary is "git" by default; set `META_GEN_GIT` in the environment * to a different path (useful for tests that simulate "git not installed"). */ function runGitMergeFile( outPath: string, basePath: string, freshPath: string, ): MergeOutcome { const gitBin = process.env.META_GEN_GIT ?? "git"; let res; try { res = spawnSync( gitBin, [ "merge-file", "--diff3", "-L", "your edits", "-L", "last generated", "-L", "fresh from meta gen", outPath, basePath, freshPath, ], { encoding: "utf-8" }, ); } catch (err) { const msg = (err as Error).message ?? ""; if (msg.includes("ENOENT")) throw new GitMissingError(); throw err; } if (res.error) { const code = (res.error as NodeJS.ErrnoException).code; if (code === "ENOENT") throw new GitMissingError(); throw res.error; } // `git merge-file` returns the conflict count (0 = clean, > 0 = conflicts, // < 0 = error). Negative is unexpected — surface verbatim. const exitCode = res.status ?? 0; if (exitCode < 0) { throw new Error( `git merge-file failed for ${outPath}: ${res.stderr || res.stdout}`, ); } const mergedContent = readFileSync(outPath, "utf-8"); return { exitCode, stderr: res.stderr ?? "", mergedContent }; } /** Write `content` to a freshly-named tmpfile under the OS tmpdir; return the * path. Caller is responsible for cleanup (acceptable for codegen — tmpdir * is process-scoped). */ function writeTmpfile(content: string): string { const dir = join(tmpdir(), "meta-gen-merge"); mkdirSync(dir, { recursive: true }); const path = join(dir, `${Date.now()}-${randomBytes(6).toString("hex")}.tmp`); writeFileSync(path, content); return path; } /** Resolve the snapshot key for a given output path. Falls back to a stable * hash-of-path so unit tests that pass arbitrary tmpdir outputs still get a * consistent .gen-state key. */ function defaultOutputRelPath(outputPath: string): string { // Use sha256 of the absolute path → hex; lets tests work without supplying // an explicit relPath. NOT used by the runner — runGen always passes the // real project-relative path. return sha256(resolve(outputPath)).slice(0, 32); } /** Where this call's gen-state lives. Shared by the write path and the preview so * the two cannot resolve it differently — a preview that consults a different * manifest than the write would be the same disagreement-between-two-answers bug * this whole change exists to remove. */ function resolveGenStateDir(opts: DecideAndWriteOpts): string { if (opts.genStateDir === undefined) return join(tmpdir(), "meta-gen-state-fallback"); return isAbsolute(opts.genStateDir) ? opts.genStateDir : resolve(opts.genStateDir); } /** True when this project has a hash manifest at all — as opposed to a manifest * that simply has no entry for some path. * * The distinction drives the upgrade message: a project with NO manifest predates * the manifest being committed, so its refusals are one fixable configuration * problem rather than N independent hand edits, and it deserves one instruction * instead of a wall of per-file warnings. */ export function hasHashManifest(genStateDir: string): boolean { return existsSync(join(genStateDir, HASHES_FILE)); } /** Normalize the legacy `MergeStrategy` string shorthand into an options object. */ function normalizeOpts( optsOrStrategy: DecideAndWriteOpts | MergeStrategy, ): DecideAndWriteOpts { return typeof optsOrStrategy === "string" ? { strategy: optsOrStrategy } : optsOrStrategy; } /** * The single decision tree `decideAndWrite` and `previewWriteStatus` both classify * through — the fix for the risk named at the top of this file. Two functions that * each re-derive "which case applies" from scratch can drift out of step in their * branch ORDER, and when they do, the preview lies. Every branch that decides WHICH * case an input falls into lives here, exactly once. `decideAndWrite` executes the * case (the write, the merge, the snapshot advance — see its own comments for the * "how"); `previewWriteStatus` maps the case straight to a `WriteStatus` with no * side effects. Neither function repeats the ordering, so neither can disagree with * the other about it. */ type WriteCase = | { kind: "new" } | { kind: "skip" } | { kind: "no-snapshot-fresh-unchanged" } | { kind: "no-snapshot-fresh-overwrite" } | { kind: "no-snapshot-unchanged" } | { kind: "no-snapshot-pristine-overwrite" } | { kind: "no-snapshot-refused"; hasRecord: boolean } | { kind: "snapshot-unchanged" } | { kind: "snapshot-merge-required"; snapshotText: string }; function classifyWrite( path: string, content: string, opts: DecideAndWriteOpts, ): WriteCase { // First-time write — file doesn't exist. if (!existsSync(path)) return { kind: "new" }; if ((opts.strategy ?? "overwrite") === "skip-existing") return { kind: "skip" }; // File exists. Load the canonical snapshot if any. const genStateDir = resolveGenStateDir(opts); const relPath = opts.outputRelPath ?? defaultOutputRelPath(path); const snapshot = readSnapshotChecked(genStateDir, relPath); const current = readFileSync(path, "utf-8"); // First-time regen on a pre-existing file (no snapshot) — caveat 3. `baseline` // only ever applies here: BaselineMode's own doc comment says so ("When // .gen-state is absent but the file exists"), and a snapshot body — handled // below — always gets the real three-way merge regardless of baseline. if (snapshot === undefined) { if ((opts.baseline ?? "default") === "fresh") { // Opt-in escape hatch: overwrite and seed the snapshot from fresh. return current === content ? { kind: "no-snapshot-fresh-unchanged" } : { kind: "no-snapshot-fresh-overwrite" }; } // No snapshot BODY. That is the normal state, not an edge case: the bodies // are gitignored, so every fresh clone and every CI runner arrives here. // // Previously this branch wrote the fresh content unconditionally, which meant // the documented promise that hand edits survive regeneration was false in // precisely the situation adopters spend most of their time in — and the CLI // labelled the replacement `NEW`. // // The committed hash manifest answers the only question that matters. Note // this is the SAME question, asked of the same evidence, as the orphan-delete // path: see `isPristineGenerated`. if (current === content) return { kind: "no-snapshot-unchanged" }; if (isPristineGenerated(genStateDir, relPath, current)) { // Byte-for-byte what we last wrote, so replacing it loses nothing. This is // the common fresh-clone case (a formatter or engine bump moved the output) // and it must not refuse, or a clean checkout would stall on every file. return { kind: "no-snapshot-pristine-overwrite" }; } // Either somebody edited it (hash mismatch) or we have no record of writing // it at all (no hash). Both are unprovable, so fail closed. return { kind: "no-snapshot-refused", hasRecord: readGeneratedHash(genStateDir, relPath) !== undefined, }; } // Snapshot exists — a real three-way merge decides this one. // Fast path: nothing changed. if (current === content && snapshot.text === content) { return { kind: "snapshot-unchanged" }; } return { kind: "snapshot-merge-required", snapshotText: snapshot.text }; } /** * What `decideAndWrite` WOULD do, touching nothing. Backs `meta gen --dry-run`. * * Exact for every outcome the hash manifest decides, because those are pure * comparisons — `classifyWrite` above is the single source for which one applies. * Deliberately COARSE in one place: with a snapshot body present the result depends * on whether `git merge-file` comes back clean or conflicted, which cannot be known * without performing the merge — so that case reports `overwrite`, meaning "this * file will be rewritten", which is true either way. * * The reason this exists as its own function rather than a flag on * `decideAndWrite`: a preview must be incapable of writing, and the cheapest way to * guarantee that is to give it no write in its body at all — mapping a `WriteCase` * to a status touches no file and no manifest. */ export function previewWriteStatus( path: string, content: string, optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {}, ): WriteStatus { const kase = classifyWrite(path, content, normalizeOpts(optsOrStrategy)); switch (kase.kind) { case "new": return "new"; case "skip": return "skipped"; case "no-snapshot-fresh-unchanged": case "no-snapshot-unchanged": case "snapshot-unchanged": return "unchanged"; case "no-snapshot-fresh-overwrite": case "no-snapshot-pristine-overwrite": return "overwrite"; case "no-snapshot-refused": return "refused"; case "snapshot-merge-required": return "overwrite"; } } /** * The main entry point. Backward-compatible with the rc.11 signature: passing * a `MergeStrategy` string as the third argument continues to work; passing * an options object opts into three-way merge. */ export function decideAndWrite( path: string, content: string, optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {}, ): WriteResult { const opts = normalizeOpts(optsOrStrategy); const genStateDir = resolveGenStateDir(opts); const relPath = opts.outputRelPath ?? defaultOutputRelPath(path); const kase = classifyWrite(path, content, opts); switch (kase.kind) { case "new": mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); advanceSnapshot(genStateDir, relPath, content); return { path, status: "new" }; case "skip": return { path, status: "skipped" }; case "no-snapshot-fresh-unchanged": case "no-snapshot-unchanged": // Identical content needs no write, in either mode — just seed/advance the // snapshot so the file is recognisable as ours next time. advanceSnapshot(genStateDir, relPath, content); return { path, status: "unchanged" }; case "no-snapshot-fresh-overwrite": case "no-snapshot-pristine-overwrite": writeFileSync(path, content); advanceSnapshot(genStateDir, relPath, content); return { path, status: "overwrite" }; case "no-snapshot-refused": // Deliberately does NOT advance the snapshot or the hash: a refusal that // records the current content would make the file look pristine next run and // turn this into a silent overwrite one run later. return { path, status: "refused", conflictHint: kase.hasRecord ? "this file has been edited since it was generated — it was NOT " + "overwritten. Move your edits into a non-generated file, or re-run " + "with --baseline=fresh to discard them and adopt fresh output." : "no record of generating this file, and its content differs from fresh " + "output — it was NOT overwritten. Move it aside, or re-run with " + "--baseline=fresh to overwrite it and adopt fresh output as the baseline.", }; case "snapshot-unchanged": return { path, status: "unchanged" }; case "snapshot-merge-required": { const baseTmp = writeTmpfile(kase.snapshotText); const freshTmp = writeTmpfile(content); const outcome = runGitMergeFile(path, baseTmp, freshTmp); if (outcome.exitCode === 0) { // Clean merge — advance the canonical snapshot to fresh. advanceSnapshot(genStateDir, relPath, content); // Distinguish "user had no changes vs canonical" from "merge integrated // edits". The fresh-equals-snapshot case is `snapshot-unchanged` above — // so if the merged result equals fresh we report a plain overwrite, // otherwise it's a merge that pulled in user edits. return { path, status: outcome.mergedContent === content ? "overwrite" : "merged", }; } // Conflict — do NOT advance the snapshot. The output now contains diff3 // markers from git merge-file. return { path, status: "conflict", conflictHint: "merge conflict — resolve `<<<<<<<` markers and re-run `meta gen` to " + "advance the canonical state.", }; } } }