diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index a324a31d..34506188 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -1,4 +1,4 @@ -import { readdir, readFile } from "node:fs/promises"; +import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import type { IGreenfieldDeps, @@ -16,6 +16,7 @@ import { extractFailures } from "./extract-failures"; import { resolveStuckFile } from "../expert-handoff"; import { refinePrompt } from "./refine-prompt"; import { runGreenfield } from "../greenfield/run"; +import { greenfieldDir, hasState } from "../greenfield/state"; import { composeBoringstackGate } from "./gate-stages"; import type { Reporter, IHandoff, EscalationRung } from "../loop.types"; import { slicesToFeatures } from "./plan-resources"; @@ -706,6 +707,71 @@ export function partitionBaseline( return { infra, baseline }; } +/** The persisted pristine baseline: the differential gate's reference. `signatures` are + * the failure signatures already present on the PRISTINE scaffold, which feature grading + * EXCLUDES (the model is judged only on failures it introduces). `passed` is whether the + * pristine gate itself was GREEN — persisted SEPARATELY, never inferred from an empty + * signature set: a RED gate whose output didn't parse into known signatures is ALSO + * `signatures: []` but `passed: false`, and must NOT be re-announced GREEN on resume. */ +export interface IPersistedBaseline { + readonly passed: boolean; + readonly signatures: ReadonlySet; +} + +/** Where the pristine-scaffold baseline is persisted — beside the greenfield checklist, + * so a fresh build's reset (which clears `.tsforge/greenfield`) also drops it, while a + * RESUME keeps it. */ +function baselinePath(cwd: string): string { + return join(greenfieldDir(cwd), "baseline.json"); +} + +/** Load the persisted pristine baseline, or null if none exists / it's unreadable / it's + * the wrong shape — never trust a corrupt file. What the caller does with null depends on + * the tree: a genuine FRESH build (no greenfield checklist) re-captures + persists; a RESUME + * (checklist present) grades STRICT and does NOT re-capture (the tree is no longer pristine). + * Requires the `{ passed, signatures }` object shape with a string[] `signatures`; else null. */ +export async function loadBaseline( + cwd: string +): Promise { + try { + const raw = await readFile(baselinePath(cwd), "utf-8"); + const parsed: unknown = JSON.parse(raw); + + if ( + typeof parsed !== "object" || + parsed === null || + !("passed" in parsed) || + !("signatures" in parsed) || + typeof parsed.passed !== "boolean" || + !Array.isArray(parsed.signatures) || + !parsed.signatures.every((s) => typeof s === "string") + ) { + return null; + } + + return { passed: parsed.passed, signatures: new Set(parsed.signatures) }; + } catch { + return null; + } +} + +/** Persist the pristine baseline so a later RESUME reuses it instead of re-capturing. + * Re-capturing on resume is the bug this fixes: the resume tree is NON-pristine (it holds + * the in-progress feature's code), so a fresh capture sweeps that feature's OWN failures + * into the baseline and EXCLUDES them from grading — a false-green (feature verified while + * it still fails its own gate). Persisting the FIRST (pristine) capture — BOTH its passed + * bit and its signatures — and reusing it keeps grading honest across resumes. */ +export async function saveBaseline( + cwd: string, + baseline: IPersistedBaseline +): Promise { + await mkdir(greenfieldDir(cwd), { recursive: true }); + await writeFile( + baselinePath(cwd), + `${JSON.stringify({ passed: baseline.passed, signatures: [...baseline.signatures] }, null, 2)}\n` + ); +} + /** * Run the final acceptance gate and chain verification after all features pass the fast gate. * Note: approved is guaranteed to be non-null (checked by caller). @@ -859,22 +925,28 @@ export async function runBoringstackBuild(opts: { features, }; - // Capture the BASELINE gate on the pristine scaffold (before any resource is - // generated) so feature grading is differential — the model is judged only on - // failures IT introduces, never on pre-existing base-suite/scaffold defects it's - // frozen out of. A red baseline is surfaced LOUDLY (not silently tolerated): it - // means the scaffold itself doesn't pass its own gate and should be fixed. + // Capture the BASELINE gate for differential feature grading — the model is judged only + // on failures IT introduces, never on pre-existing scaffold defects it's frozen out of. + // The baseline MUST come from the PRISTINE tree; it is captured ONCE on a fresh build and + // PERSISTED (with its pass/fail bit), and a RESUME reuses it. Re-capturing on a resume + // runs the gate on the ALREADY-MODIFIED tree and sweeps the in-progress feature's OWN + // failures into the baseline (then EXCLUDES them from grading) — a false-green where a + // feature "verifies" while still failing its own gate (live-observed). The gate STILL runs + // every invocation — it is the fail-closed needs-infra check below — but on a resume its + // captured signatures are DISCARDED in favour of the persisted ones. onEvent?.({ kind: "tool", task: "boringstack", - message: "capturing baseline gate on the pristine scaffold…", + message: "running the baseline gate (infra check + pristine capture)…", }); const baseRun = await runBoringstackGate(cwd, exec); - const { infra, baseline } = baseRun.passed + const fresh: IBaselinePartition = baseRun.passed ? { infra: [], baseline: new Set() } : partitionBaseline(extractFailures(baseRun.output, cwd)); + const infra = fresh.infra; + // FAIL CLOSED on an unmet infra precondition. If the pristine gate can't reach the // API's OpenAPI spec, the UI's generate:api will fail EVERY cycle — driving the // model against infra it cannot fix. Stop here with a clear, actionable status @@ -898,18 +970,90 @@ export async function runBoringstackBuild(opts: { return { status: "needs-infra", features: [], infra: message }; } - const report = describeBaseline(baseRun.passed, baseline.size); + // Choose the GRADING baseline (infra is healthy here — the fail-closed above returned): + // - a persisted baseline exists → REUSE it (the pristine capture; carries its own passed + // bit — NEVER inferred from an empty set, which would re-announce a RED-unparseable + // pristine gate as GREEN). + // - else a genuine FRESH start (no greenfield checklist yet ⇒ pristine tree) → use THIS + // capture and persist it. + // - else a RESUME with no persisted baseline (a build started before this shipped, or a + // lost file) → the tree is NON-pristine, so this capture is CONTAMINATED. Grade STRICTLY + // (empty baseline — every failure counts, only ever over-strict, never a false-green) + // and warn; do NOT freeze the contaminated capture in. + const persisted = await loadBaseline(cwd); + // A resume is ANY prior greenfield checklist ON DISK — detected by FILE PRESENCE + // (`hasState`), NOT by `loadState !== null`. `loadState` returns null for a missing + // file AND for a present-but-corrupt one, so using it would treat a corrupt-state + // resume (whose tree WAS already built into) as a fresh start and persist a + // CONTAMINATED baseline (a false-green). build.ts never writes the checklist before + // this point, so a genuine fresh build has no file here; mere PRESENCE (even an empty + // or unparseable checklist) means the tree is no longer pristine. Erring toward + // "resume" is only ever over-strict, never a false-green. + const isResume = await hasState(cwd); + + let baseline: ReadonlySet; + let baselinePassed: boolean; + // Whether THIS invocation is on the pristine tree (the fresh capture). Only then may we + // trust the tree for the differential command baseline AND the meta-rule baseline; on a + // resume both are reused-or-strict, never re-captured from the contaminated tree. + let isFreshCapture = false; + // The RED-unparseable / GREEN / RED-with-signatures report describes a PRISTINE capture. + // The strict fallback below has no pristine result (missing baseline.json on a non-pristine + // resume), so it must NOT emit that report — it emits its own warning instead. + let hasPristineReport = true; + + if (persisted !== null) { + baseline = persisted.signatures; + baselinePassed = persisted.passed; + onEvent?.({ + kind: "tool", + task: "boringstack", + message: `reusing the persisted pristine baseline (${String(persisted.signatures.size)} signature(s), ${persisted.passed ? "GREEN" : "RED"}) — resume, NOT re-captured`, + }); + } else if (!isResume) { + baseline = fresh.baseline; + baselinePassed = baseRun.passed; + isFreshCapture = true; + await saveBaseline(cwd, { + passed: baseRun.passed, + signatures: fresh.baseline, + }); + } else { + baseline = new Set(); + baselinePassed = false; + hasPristineReport = false; + onEvent?.({ + kind: "stuck", + task: "boringstack", + message: + "⚠ resuming without a persisted pristine baseline (older build or lost baseline.json) — " + + "the tree is no longer pristine, so grading falls back to STRICT (nothing excluded). " + + "Reset + rebuild to restore a clean differential baseline.", + }); + } + + if (hasPristineReport) { + const report = describeBaseline(baselinePassed, baseline.size); - onEvent?.({ - kind: report.kind, - task: "boringstack", - message: report.message, - }); + onEvent?.({ + kind: report.kind, + task: "boringstack", + message: report.message, + }); + } // Capture the PRISTINE meta-rule baseline too (workflow perms, lockfile, etc. that - // the model is frozen out of). The command baseline above only covers the command - // gate; meta violations are subtracted separately, via the session, every cycle. - host.captureMetaBaseline(); + // the model is frozen out of) — but ONLY on a fresh capture, for the SAME reason the + // command baseline is only captured fresh. `captureMetaBaseline` reads the CURRENT + // tree; on a resume that tree is contaminated, so re-capturing would sweep meta + // violations introduced before the interruption into the baseline and EXCLUDE them + // from grading — the same contaminated-resume false-green, for the meta gate. On a + // resume the session's meta-baseline stays unset ⇒ STRICT (nothing subtracted), which + // is the safe direction and makes the strict-fallback warning above truthful. (Meta + // violations on a clean scaffold are empty anyway, so the common case is unchanged.) + if (isFreshCapture) { + host.captureMetaBaseline(); + } // Create a lookup function that maps feature ids to their plan slices const sliceFor = (id: string): ISlice | undefined => diff --git a/packages/core/src/loop/greenfield/index.ts b/packages/core/src/loop/greenfield/index.ts index 55a8d45d..fe695266 100644 --- a/packages/core/src/loop/greenfield/index.ts +++ b/packages/core/src/loop/greenfield/index.ts @@ -1,6 +1,7 @@ export { runGreenfield, prepareState } from "./run"; export { loadState, + hasState, saveState, writeSpec, writeProgress, diff --git a/packages/core/src/loop/greenfield/state.ts b/packages/core/src/loop/greenfield/state.ts index 17271f2a..a7a051af 100644 --- a/packages/core/src/loop/greenfield/state.ts +++ b/packages/core/src/loop/greenfield/state.ts @@ -178,6 +178,16 @@ export async function loadState(cwd: string): Promise { return { goal: parsed.goal, features }; } +/** Whether a greenfield checklist EXISTS on disk — regardless of whether it parses. + * This is the true "is this a RESUME?" signal. `loadState` returns null for BOTH a + * missing AND a present-but-corrupt file, so it must NOT be used to detect a fresh + * start: a corrupt features.json on a tree that was already built into would look + * "fresh" and let a caller re-capture a CONTAMINATED baseline (a false-green). Err + * toward resume — presence ⇒ resume, only true absence ⇒ fresh. */ +export async function hasState(cwd: string): Promise { + return Bun.file(featuresPath(cwd)).exists(); +} + /** Persist the feature checklist as pretty JSON (diff-friendly, model-resistant). */ export async function saveState( cwd: string, diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index d8ae2624..0b6012b5 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -13,6 +13,8 @@ import { readResourceCode, verifyAcceptance, e2eParkReason, + loadBaseline, + saveBaseline, APP_SCHEMA_FILE, LOCALE_GLOB, } from "../src/loop/boringstack/build"; @@ -24,6 +26,7 @@ import type { import type { IProvider } from "../src/inference"; import type { IGate } from "../src/gate/gate-runner"; import { writePlan } from "../src/loop/planning/plan-store"; +import { saveState } from "../src/loop/greenfield/state"; import type { IProductPlan } from "../src/loop/planning/plan-types"; function feature(id: string) { @@ -84,6 +87,35 @@ function createEvaluator(): IProvider { }; } +/** A minimal single-slice approved plan, shared by the baseline-persistence tests. */ +function invoicePlan(): IProductPlan { + return { + product: "A simple app", + slices: [ + { + entity: { + id: "Invoice", + desc: "A billable unit", + fields: [{ name: "amount", type: "number" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["list"], + action: "create invoices", + shows: ["amount"], + nav: "Invoices", + }, + verification: { + mustRemainTrue: ["auth required"], + mustNotHappen: ["unauthenticated access"], + acceptanceCheck: "bun test", + }, + }, + ], + }; +} + describe("boringstackDeps.implement", () => { test("calls injected generate with feature id, then freezes scope and sends refine prompt", async () => { const host = createHost(); @@ -1277,3 +1309,365 @@ describe("e2eParkReason — the pure reason composer", () => { expect(reason).not.toContain("browser acceptance assertions failed"); }); }); + +describe("baseline persistence — resume-safe differential grading", () => { + test("saveBaseline → loadBaseline round-trips passed + the signature set", async () => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-")); + + try { + const sigs = new Set([ + "failure:a.ts:1:no-unused-vars:msg", + "failure:b.ts:2:react-hooks%2Fexhaustive-deps:msg2", + ]); + + await saveBaseline(dir, { passed: false, signatures: sigs }); + const loaded = await loadBaseline(dir); + + expect(loaded).not.toBeNull(); + expect(loaded?.passed).toBe(false); + expect([...(loaded?.signatures ?? [])].sort()).toEqual([...sigs].sort()); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("loadBaseline returns null when none is persisted (→ a FRESH build captures)", async () => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-none-")); + + try { + expect(await loadBaseline(dir)).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a GREEN pristine baseline persists as passed:true + empty set, NOT null (so a resume REUSES it)", async () => { + // The load-bearing case: build55's pristine scaffold was GREEN → empty baseline. It must + // round-trip as PRESENT (passed:true, empty set), so a resume reuses it and does NOT + // re-capture from the contaminated tree. If it round-tripped as null, the resume would + // re-capture → the false-green this fix prevents. + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-green-")); + + try { + await saveBaseline(dir, { passed: true, signatures: new Set() }); + const loaded = await loadBaseline(dir); + + expect(loaded).not.toBeNull(); + expect(loaded?.passed).toBe(true); + expect(loaded?.signatures.size).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a RED-but-unparseable baseline persists passed:FALSE + empty set — never re-inferred GREEN from size", async () => { + // The critical bug the panel caught: a RED pristine gate whose output did NOT parse into + // known signatures is ALSO `signatures: []`. The passed bit MUST be stored separately, or a + // resume with `size === 0` would announce it GREEN — the exact false-green this file fixes. + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-redunparsed-")); + + try { + await saveBaseline(dir, { passed: false, signatures: new Set() }); + const loaded = await loadBaseline(dir); + + expect(loaded).not.toBeNull(); + expect(loaded?.passed).toBe(false); + expect(loaded?.signatures.size).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("loadBaseline returns null on a wrong-shape file (e.g. the OLD bare-array format)", async () => { + // A bare array (no passed bit) must be rejected → re-capture, not silently treated as a + // valid passed:false/true baseline. Guards against loading a pre-format-change file. + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-shape-")); + + try { + await mkdir(join(dir, ".tsforge/greenfield"), { recursive: true }); + await writeFile( + join(dir, ".tsforge/greenfield/baseline.json"), + JSON.stringify(["failure:a.ts:1:rule:msg"]) + ); + + expect(await loadBaseline(dir)).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("loadBaseline returns null on corrupt JSON (re-capture, never crash)", async () => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-corrupt-")); + + try { + await mkdir(join(dir, ".tsforge/greenfield"), { recursive: true }); + await writeFile( + join(dir, ".tsforge/greenfield/baseline.json"), + "{not valid json" + ); + + expect(await loadBaseline(dir)).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("runBoringstackBuild persists on a FRESH build and REUSES on resume (not re-captured from the contaminated tree)", async () => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-e2e-")); + + try { + const plan: IProductPlan = { + product: "A simple app", + slices: [ + { + entity: { + id: "Invoice", + desc: "A billable unit", + fields: [{ name: "amount", type: "number" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["list"], + action: "create invoices", + shows: ["amount"], + nav: "Invoices", + }, + verification: { + mustRemainTrue: ["auth required"], + mustNotHappen: ["unauthenticated access"], + acceptanceCheck: "bun test", + }, + }, + ], + }; + + await writePlan(dir, plan, "approved"); + + // FRESH build: the baseline gate passes (createExec(0)) → a passed:true baseline is + // captured and PERSISTED. + await runBoringstackBuild({ + cwd: dir, + goal: "app", + host: createHost(), + evaluator: createEvaluator(), + exec: createExec(0), + generate: async () => undefined, + generateUi: async () => undefined, + }); + + const afterFresh = await loadBaseline(dir); + + expect(afterFresh).not.toBeNull(); + expect(afterFresh?.passed).toBe(true); + + // RESUME (greenfield state now exists): even though the gate now FAILS (createExec(1)), + // the persisted GREEN baseline is REUSED — NOT re-captured from the non-pristine tree — + // so it stays passed:true. A re-capture would have overwritten it to passed:false. + await runBoringstackBuild({ + cwd: dir, + goal: "app", + host: createHost(), + evaluator: createEvaluator(), + exec: createExec(1), + generate: async () => undefined, + generateUi: async () => undefined, + }); + + const afterResume = await loadBaseline(dir); + + expect(afterResume?.passed).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("runBoringstackBuild grades STRICT on a resume whose baseline.json was lost — no crash, no contaminated re-capture persisted", async () => { + // The safety-critical fallback branch: a greenfield checklist EXISTS (it's a resume) but + // the persisted baseline is gone (older build / deleted file). The tree is non-pristine, so + // this invocation's gate capture is CONTAMINATED. The build must fall back to STRICT grading + // (empty baseline, nothing excluded — only ever over-strict, never a false-green) and must + // NOT persist that contaminated capture as a new baseline. Assert: it runs without crashing + // and loadBaseline stays null (the contaminated capture was never frozen in). + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-strict-")); + + try { + const plan: IProductPlan = { + product: "A simple app", + slices: [ + { + entity: { + id: "Invoice", + desc: "A billable unit", + fields: [{ name: "amount", type: "number" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["list"], + action: "create invoices", + shows: ["amount"], + nav: "Invoices", + }, + verification: { + mustRemainTrue: ["auth required"], + mustNotHappen: ["unauthenticated access"], + acceptanceCheck: "bun test", + }, + }, + ], + }; + + await writePlan(dir, plan, "approved"); + + // First run establishes the greenfield checklist (making the next run a RESUME) and a + // persisted baseline. + await runBoringstackBuild({ + cwd: dir, + goal: "app", + host: createHost(), + evaluator: createEvaluator(), + exec: createExec(0), + generate: async () => undefined, + generateUi: async () => undefined, + }); + + expect(await loadBaseline(dir)).not.toBeNull(); + + // Simulate a LOST baseline.json — the checklist survives, the baseline doesn't. + await rm(join(dir, ".tsforge", "greenfield", "baseline.json"), { + force: true, + }); + expect(await loadBaseline(dir)).toBeNull(); + + // RESUME with a now-FAILING (contaminated) gate. The strict fallback must engage. + const res = await runBoringstackBuild({ + cwd: dir, + goal: "app", + host: createHost(), + evaluator: createEvaluator(), + exec: createExec(1), + generate: async () => undefined, + generateUi: async () => undefined, + }); + + // It did not crash… + expect(res.status).toBeDefined(); + // …and the contaminated capture was NOT persisted as a baseline (strict fallback, not + // a re-capture that would later EXCLUDE this feature's own failures from grading). + expect(await loadBaseline(dir)).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a FRESH build captures the meta-rule baseline exactly once AND persists the command baseline", async () => { + // Positive control for the two resume-safety fixes: on the pristine tree we DO capture + // the meta baseline and DO persist the command baseline. The resume tests below assert + // the negatives (neither happens on a resume). + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-fresh-")); + + try { + await writePlan(dir, invoicePlan(), "approved"); + const host = createHost(); + + await runBoringstackBuild({ + cwd: dir, + goal: "app", + host, + evaluator: createEvaluator(), + exec: createExec(0), + generate: async () => undefined, + generateUi: async () => undefined, + }); + + expect(host.metaBaselineCaptures.count).toBe(1); + expect(await loadBaseline(dir)).not.toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a resume does NOT re-capture the meta baseline and emits NO false pristine-baseline report — only the strict warning", async () => { + // The critical scope-bypass fix: on a resume the tree is contaminated, so re-capturing + // the meta baseline would sweep pre-interruption meta violations (workflow/lockfile/ + // root-drift) into the baseline and EXCLUDE them — the same false-green, for the meta + // gate. On a resume the meta baseline must NOT be captured (→ STRICT), and the strict + // fallback must NOT emit the "RED … did NOT parse" pristine-baseline report (which would + // contradict its own warning). + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-resume-meta-")); + + try { + await writePlan(dir, invoicePlan(), "approved"); + // A checklist exists (→ RESUME) but no baseline.json (→ strict fallback). + await saveState(dir, { goal: "app", features: [] }); + expect(await loadBaseline(dir)).toBeNull(); + + const host = createHost(); + const events: string[] = []; + + await runBoringstackBuild({ + cwd: dir, + goal: "app", + host, + evaluator: createEvaluator(), + exec: createExec(1), + generate: async () => undefined, + generateUi: async () => undefined, + onEvent: (e) => { + events.push(e.message); + }, + }); + + // Meta baseline NOT captured on a resume (strict), so the "nothing excluded" warning is true. + expect(host.metaBaselineCaptures.count).toBe(0); + // The strict-fallback warning IS emitted… + expect(events.some((m) => m.includes("falls back to STRICT"))).toBe(true); + // …and the false "RED … did NOT parse" pristine-baseline report is NOT (it describes a + // pristine capture that does not exist on this path). + expect(events.some((m) => m.includes("did NOT parse"))).toBe(false); + // The contaminated tree was never frozen in as a baseline. + expect(await loadBaseline(dir)).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a CORRUPT features.json is treated as a RESUME (strict), never a fresh start — no contaminated capture persisted", async () => { + // The gate-relaxed fix: `loadState` returns null for a corrupt file just as for a missing + // one, so detecting a fresh start via loadState would treat a corrupt-state resume (whose + // tree WAS already built into) as pristine and persist a CONTAMINATED baseline. Presence + // (`hasState`) is the correct signal: a corrupt checklist still means "resume → strict". + const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-corrupt-")); + + try { + await writePlan(dir, invoicePlan(), "approved"); + // A present-but-unparseable checklist — loadState() would see null (looks fresh), + // hasState() sees the file (correctly: resume). + await mkdir(join(dir, ".tsforge", "greenfield"), { recursive: true }); + await writeFile( + join(dir, ".tsforge", "greenfield", "features.json"), + "{ this is not valid json" + ); + + const host = createHost(); + + await runBoringstackBuild({ + cwd: dir, + goal: "app", + host, + evaluator: createEvaluator(), + exec: createExec(1), + generate: async () => undefined, + generateUi: async () => undefined, + }); + + // Treated as a resume: strict fallback, so NO fresh baseline was persisted from the + // contaminated tree, and the meta baseline was NOT captured. + expect(await loadBaseline(dir)).toBeNull(); + expect(host.metaBaselineCaptures.count).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/tests/greenfield.test.ts b/packages/core/tests/greenfield.test.ts index 95ed4601..0e918aa5 100644 --- a/packages/core/tests/greenfield.test.ts +++ b/packages/core/tests/greenfield.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { runGreenfield, loadState, + hasState, saveState, renderProgress, greenfieldDir, @@ -58,6 +59,25 @@ describe("greenfield state", () => { expect(await loadState(dir)).toBeNull(); }); + test("hasState keys off FILE PRESENCE, not parseability — so a corrupt checklist is still a resume", async () => { + // The distinction loadState can't make: a caller deciding "fresh vs resume" must use + // presence, because loadState collapses missing AND corrupt to null. hasState stays true + // for a present-but-unparseable file, so a corrupt-state resume is never mistaken for a + // pristine fresh start (which would let it re-capture a contaminated baseline). + expect(await hasState(dir)).toBe(false); + + await mkdir(greenfieldDir(dir), { recursive: true }); + await writeFile(join(greenfieldDir(dir), "features.json"), "{not json"); + + expect(await hasState(dir)).toBe(true); + // loadState still can't read it — the two diverge exactly here. + expect(await loadState(dir)).toBeNull(); + + // An empty but well-formed checklist is also a resume. + await saveState(dir, { goal: "g", features: [] }); + expect(await hasState(dir)).toBe(true); + }); + test("loadState drops malformed feature entries without crashing", async () => { await mkdir(greenfieldDir(dir), { recursive: true }); await writeFile(