From 116abd4e77a94c75f865a42208a190f8e983bfbe Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 25 Jul 2026 06:52:55 +0200 Subject: [PATCH 1/4] fix(boringstack): persist the pristine baseline + reuse on resume (kills the resume false-green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounded in build55: a fresh Company build reached "1/1 verified" but the full validate found 5 real lint errors the fast gate never caught — a FALSE-GREEN. Root-caused it: the differential gate grades features against a BASELINE captured by running the gate on the "pristine scaffold" at build start, and runBoringstackBuild RE-CAPTURES that baseline on EVERY invocation, including a RESUME. On resume the tree is NON-pristine (it holds the in-progress feature's code), so the re-capture sweeps the feature's OWN failures into the baseline and EXCLUDES them from grading → the feature "verifies" while still failing its own gate. Log proof: cycle-1 (pristine) "baseline scaffold is GREEN"; resumes "baseline scaffold is RED: 8 pre-existing failures EXCLUDED". Fix: capture the baseline ONCE and PERSIST it to `.tsforge/greenfield/baseline.json` (beside the checklist, so a fresh build's reset drops it but a resume keeps it); on any run, REUSE the persisted baseline instead of re-capturing. A GREEN pristine scaffold persists as an empty set (present, not null) so a resume reuses it rather than re-capturing. Persist only when infra is healthy (an infra-failed gate isn't a baseline). Tests: save→load round-trip, absent→null (fresh build captures), GREEN pristine → empty set NOT null (the load-bearing resume case), corrupt JSON → null (re-capture, never crash). Ruled out (verified): eslint --cache, scope, autofix, extractFailures parse (extracted all 5 sigs correctly) — the baseline INPUT was contaminated, everything downstream was correct. --- packages/core/src/loop/boringstack/build.ts | 98 ++++++++++++++++--- packages/core/tests/boringstack-build.test.ts | 67 +++++++++++++ 2 files changed, 154 insertions(+), 11 deletions(-) diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index a324a31d..5574d004 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 } from "../greenfield/state"; import { composeBoringstackGate } from "./gate-stages"; import type { Reporter, IHandoff, EscalationRung } from "../loop.types"; import { slicesToFeatures } from "./plan-resources"; @@ -706,6 +707,51 @@ export function partitionBaseline( return { infra, baseline }; } +/** 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. The + * baseline is the differential gate's reference: the failure signatures already present + * on the pristine scaffold, which feature grading EXCLUDES (the model is judged only on + * failures it introduces). It MUST be captured on the pristine tree — see saveBaseline. */ +export async function loadBaseline( + cwd: string +): Promise | null> { + try { + const raw = await readFile(baselinePath(cwd), "utf-8"); + const parsed: unknown = JSON.parse(raw); + + if (!Array.isArray(parsed)) { + return null; + } + + return new Set(parsed.filter((s): s is string => typeof s === "string")); + } 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 and reusing it + * keeps grading honest across resumes. */ +export async function saveBaseline( + cwd: string, + baseline: ReadonlySet +): Promise { + await mkdir(greenfieldDir(cwd), { recursive: true }); + await writeFile( + baselinePath(cwd), + `${JSON.stringify([...baseline], 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). @@ -864,16 +910,46 @@ export async function runBoringstackBuild(opts: { // 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. - onEvent?.({ - kind: "tool", - task: "boringstack", - message: "capturing baseline gate on the pristine scaffold…", - }); + // RESUME-SAFE: capture the baseline ONCE on the pristine tree and PERSIST it; a resume + // REUSES it. Re-capturing on a resume would run the gate on the already-modified tree + // and sweep the in-progress feature's OWN failures into the baseline (then EXCLUDE them + // from grading) — a false-green where a feature "verifies" while still failing its own + // gate. Live-observed on a resumed build. See loadBaseline/saveBaseline. + const persistedBaseline = await loadBaseline(cwd); + let baseline: ReadonlySet; + let infra: string[] = []; + let baselinePassed: boolean; + + if (persistedBaseline !== null) { + baseline = persistedBaseline; + baselinePassed = persistedBaseline.size === 0; + onEvent?.({ + kind: "tool", + task: "boringstack", + message: `reusing the persisted pristine baseline (${String(persistedBaseline.size)} signature(s)) — resume, NOT re-captured`, + }); + } else { + onEvent?.({ + kind: "tool", + task: "boringstack", + message: "capturing baseline gate on the pristine scaffold…", + }); + + const baseRun = await runBoringstackGate(cwd, exec); + const partitioned: IBaselinePartition = baseRun.passed + ? { infra: [], baseline: new Set() } + : partitionBaseline(extractFailures(baseRun.output, cwd)); - const baseRun = await runBoringstackGate(cwd, exec); - const { infra, baseline } = baseRun.passed - ? { infra: [], baseline: new Set() } - : partitionBaseline(extractFailures(baseRun.output, cwd)); + infra = partitioned.infra; + baseline = partitioned.baseline; + baselinePassed = baseRun.passed; + + // Persist ONLY when infra is healthy — an infra-failed gate isn't a real baseline + // (the caller fails needs-infra just below), so it must NOT be frozen in as one. + if (infra.length === 0) { + await saveBaseline(cwd, baseline); + } + } // 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 @@ -898,7 +974,7 @@ export async function runBoringstackBuild(opts: { return { status: "needs-infra", features: [], infra: message }; } - const report = describeBaseline(baseRun.passed, baseline.size); + const report = describeBaseline(baselinePassed, baseline.size); onEvent?.({ kind: report.kind, diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index d8ae2624..bf2dc8d2 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"; @@ -1277,3 +1279,68 @@ 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 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, sigs); + const loaded = await loadBaseline(dir); + + expect(loaded).not.toBeNull(); + expect([...(loaded ?? [])].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 an EMPTY set, NOT null (so a resume REUSES it, not re-captures)", async () => { + // The load-bearing case: build55's pristine scaffold was GREEN → empty baseline. It must + // round-trip as an empty Set (present), 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, new Set()); + const loaded = await loadBaseline(dir); + + expect(loaded).not.toBeNull(); + expect(loaded?.size).toBe(0); + } 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 }); + } + }); +}); From 3277ced79d29d150a5ed9da10b0b23ecf3834a0d Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 25 Jul 2026 07:08:53 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(#198):=20address=20panel=20=E2=80=94=20?= =?UTF-8?q?persist=20passed-bit,=20keep=20infra=20check,=20fresh-vs-resume?= =?UTF-8?q?,=20integration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel BLOCKed the first cut (correctly). Fixes: - PERSIST THE PASSED BIT, never infer it from set size. A RED pristine gate whose output doesn't parse is ALSO signatures:[]; the old `size===0 ⇒ GREEN` re-announced it green on resume (a false-green). Persisted format is now `{ passed, signatures }`. - KEEP THE FAIL-CLOSED needs-infra CHECK on every entry: the baseline gate STILL runs each invocation (it's the infra probe); on a resume its captured signatures are DISCARDED in favour of the persisted ones. Previously reuse skipped the gate → resume could proceed with a dead API. - FRESH vs RESUME is now detected via the greenfield checklist (loadState), not "no file". A resume with no persisted baseline (pre-change build / lost file) is graded STRICTLY (empty baseline — only ever over-strict, never a false-green) and warned; it does NOT re-capture from — nor freeze in — the contaminated tree. - loadBaseline rejects the wrong shape (bare array / missing passed) → null → re-capture. Tests: round-trip passed+signatures; GREEN → passed:true+empty (present); RED-unparseable → passed:FALSE+empty (never re-inferred green); wrong-shape → null; corrupt JSON → null; and an INTEGRATION test — runBoringstackBuild persists on a fresh build then REUSES on resume (gate flipped to failing; baseline stays passed:true = reused, not re-captured). Full validate green (3119 tests). --- packages/core/src/loop/boringstack/build.ts | 149 +++++++++++------- packages/core/tests/boringstack-build.test.ts | 126 +++++++++++++-- 2 files changed, 211 insertions(+), 64 deletions(-) diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 5574d004..c532dda6 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -16,7 +16,7 @@ import { extractFailures } from "./extract-failures"; import { resolveStuckFile } from "../expert-handoff"; import { refinePrompt } from "./refine-prompt"; import { runGreenfield } from "../greenfield/run"; -import { greenfieldDir } from "../greenfield/state"; +import { greenfieldDir, loadState } from "../greenfield/state"; import { composeBoringstackGate } from "./gate-stages"; import type { Reporter, IHandoff, EscalationRung } from "../loop.types"; import { slicesToFeatures } from "./plan-resources"; @@ -707,6 +707,17 @@ 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. */ @@ -714,22 +725,29 @@ function baselinePath(cwd: string): string { return join(greenfieldDir(cwd), "baseline.json"); } -/** Load the persisted pristine baseline, or null if none exists / it's unreadable. The - * baseline is the differential gate's reference: the failure signatures already present - * on the pristine scaffold, which feature grading EXCLUDES (the model is judged only on - * failures it introduces). It MUST be captured on the pristine tree — see saveBaseline. */ +/** Load the persisted pristine baseline, or null if none exists / it's unreadable / it's + * the wrong shape (→ the caller re-captures rather than trust a corrupt file). Requires the + * `{ passed, signatures }` object shape with a string[] `signatures`; anything else is null. */ export async function loadBaseline( cwd: string -): Promise | null> { +): Promise { try { const raw = await readFile(baselinePath(cwd), "utf-8"); const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { + 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 new Set(parsed.filter((s): s is string => typeof s === "string")); + return { passed: parsed.passed, signatures: new Set(parsed.signatures) }; } catch { return null; } @@ -739,16 +757,16 @@ export async function loadBaseline( * 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 and reusing it - * keeps grading honest across resumes. */ + * 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: ReadonlySet + baseline: IPersistedBaseline ): Promise { await mkdir(greenfieldDir(cwd), { recursive: true }); await writeFile( baselinePath(cwd), - `${JSON.stringify([...baseline], null, 2)}\n` + `${JSON.stringify({ passed: baseline.passed, signatures: [...baseline.signatures] }, null, 2)}\n` ); } @@ -905,51 +923,27 @@ 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. - // RESUME-SAFE: capture the baseline ONCE on the pristine tree and PERSIST it; a resume - // REUSES it. Re-capturing on a resume would run the gate on the already-modified tree - // and sweep the in-progress feature's OWN failures into the baseline (then EXCLUDE them - // from grading) — a false-green where a feature "verifies" while still failing its own - // gate. Live-observed on a resumed build. See loadBaseline/saveBaseline. - const persistedBaseline = await loadBaseline(cwd); - let baseline: ReadonlySet; - let infra: string[] = []; - let baselinePassed: boolean; - - if (persistedBaseline !== null) { - baseline = persistedBaseline; - baselinePassed = persistedBaseline.size === 0; - onEvent?.({ - kind: "tool", - task: "boringstack", - message: `reusing the persisted pristine baseline (${String(persistedBaseline.size)} signature(s)) — resume, NOT re-captured`, - }); - } else { - onEvent?.({ - kind: "tool", - task: "boringstack", - message: "capturing baseline gate on the pristine scaffold…", - }); - - const baseRun = await runBoringstackGate(cwd, exec); - const partitioned: IBaselinePartition = baseRun.passed - ? { infra: [], baseline: new Set() } - : partitionBaseline(extractFailures(baseRun.output, cwd)); + // 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: "running the baseline gate (infra check + pristine capture)…", + }); - infra = partitioned.infra; - baseline = partitioned.baseline; - baselinePassed = baseRun.passed; + const baseRun = await runBoringstackGate(cwd, exec); + const fresh: IBaselinePartition = baseRun.passed + ? { infra: [], baseline: new Set() } + : partitionBaseline(extractFailures(baseRun.output, cwd)); - // Persist ONLY when infra is healthy — an infra-failed gate isn't a real baseline - // (the caller fails needs-infra just below), so it must NOT be frozen in as one. - if (infra.length === 0) { - await saveBaseline(cwd, baseline); - } - } + 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 @@ -974,6 +968,51 @@ export async function runBoringstackBuild(opts: { return { status: "needs-infra", features: [], infra: message }; } + // 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); + const priorState = await loadState(cwd); + const isResume = priorState !== null && priorState.features.length > 0; + + let baseline: ReadonlySet; + let baselinePassed: boolean; + + 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; + await saveBaseline(cwd, { + passed: baseRun.passed, + signatures: fresh.baseline, + }); + } else { + baseline = new Set(); + baselinePassed = 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.", + }); + } + const report = describeBaseline(baselinePassed, baseline.size); onEvent?.({ diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index bf2dc8d2..72cb14cd 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -1281,7 +1281,7 @@ describe("e2eParkReason — the pure reason composer", () => { }); describe("baseline persistence — resume-safe differential grading", () => { - test("saveBaseline → loadBaseline round-trips the signature set", async () => { + test("saveBaseline → loadBaseline round-trips passed + the signature set", async () => { const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-")); try { @@ -1290,11 +1290,12 @@ describe("baseline persistence — resume-safe differential grading", () => { "failure:b.ts:2:react-hooks%2Fexhaustive-deps:msg2", ]); - await saveBaseline(dir, sigs); + await saveBaseline(dir, { passed: false, signatures: sigs }); const loaded = await loadBaseline(dir); expect(loaded).not.toBeNull(); - expect([...(loaded ?? [])].sort()).toEqual([...sigs].sort()); + expect(loaded?.passed).toBe(false); + expect([...(loaded?.signatures ?? [])].sort()).toEqual([...sigs].sort()); } finally { await rm(dir, { recursive: true, force: true }); } @@ -1310,19 +1311,56 @@ describe("baseline persistence — resume-safe differential grading", () => { } }); - test("a GREEN pristine baseline persists as an EMPTY set, NOT null (so a resume REUSES it, not re-captures)", async () => { + 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 an empty Set (present), 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. + // 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, new Set()); + await saveBaseline(dir, { passed: true, signatures: new Set() }); const loaded = await loadBaseline(dir); expect(loaded).not.toBeNull(); - expect(loaded?.size).toBe(0); + 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 }); } @@ -1343,4 +1381,74 @@ describe("baseline persistence — resume-safe differential grading", () => { 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 }); + } + }); }); From 11cc18f656aa54a2dadded9157ec3573777ab9d9 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 25 Jul 2026 07:21:54 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(#198):=20panel=20round-2=20=E2=80=94=20?= =?UTF-8?q?test=20the=20strict-fallback=20branch,=20drop=20the=20empty-che?= =?UTF-8?q?cklist=20hole,=20stop=20the=20false=20RED-unparseable=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 v2 was BLOCKed on five findings; all addressed: - [major, 4 reviewers] The resume-without-persisted-baseline STRICT-FALLBACK branch had no test. Added one: a resume whose baseline.json was lost, with a now-FAILING (contaminated) gate, must run WITHOUT crashing and must NOT persist the contaminated capture (loadBaseline stays null) — the exact safety-critical path the panel flagged. - [major, gate-relaxed] isResume required `features.length > 0`, so a loaded-but-empty checklist was graded FRESH and could persist a contaminated capture. Dropped the length check: a resume is ANY prior checklist on disk (`loadState !== null`). build.ts never writes the checklist before this point, so a genuine fresh build has none here; erring toward "resume" is only ever over-strict, never a false-green. - [major+minor, false diagnostic] On the strict fallback the code still called describeBaseline(false, 0), emitting the "RED-but-unparseable pristine scaffold" message — false here (the baseline is MISSING, not unparseable) and contradicting the warning just emitted. Guarded it with hasPristineReport: the fallback now emits ONLY its own warning. - [minor] loadBaseline JSDoc said null ⇒ "the caller re-captures". Corrected: a fresh build re-captures; a resume grades STRICT and does NOT re-capture. Verify: typecheck clean, lint clean, boringstack-build.test.ts 50 pass (0 fail). --- packages/core/src/loop/boringstack/build.ts | 34 +++++--- packages/core/tests/boringstack-build.test.ts | 79 +++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index c532dda6..0cd0bd50 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -726,8 +726,10 @@ function baselinePath(cwd: string): string { } /** Load the persisted pristine baseline, or null if none exists / it's unreadable / it's - * the wrong shape (→ the caller re-captures rather than trust a corrupt file). Requires the - * `{ passed, signatures }` object shape with a string[] `signatures`; anything else is null. */ + * 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 { @@ -979,11 +981,20 @@ export async function runBoringstackBuild(opts: { // (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); - const priorState = await loadState(cwd); - const isResume = priorState !== null && priorState.features.length > 0; + // A resume is ANY prior greenfield checklist on disk — `loadState !== null`. build.ts + // never writes the checklist before this point, so a genuine fresh build has none here; + // its mere PRESENCE (even with an empty feature array) means the tree is no longer + // pristine. Do NOT additionally require `features.length > 0`: that would treat a + // loaded-but-empty checklist as fresh and persist a CONTAMINATED capture. Erring toward + // "resume" is only ever over-strict, never a false-green. + const isResume = (await loadState(cwd)) !== null; let baseline: ReadonlySet; let baselinePassed: boolean; + // 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; @@ -1003,6 +1014,7 @@ export async function runBoringstackBuild(opts: { } else { baseline = new Set(); baselinePassed = false; + hasPristineReport = false; onEvent?.({ kind: "stuck", task: "boringstack", @@ -1013,13 +1025,15 @@ export async function runBoringstackBuild(opts: { }); } - const report = describeBaseline(baselinePassed, baseline.size); + 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 diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index 72cb14cd..384bec72 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -1451,4 +1451,83 @@ describe("baseline persistence — resume-safe differential grading", () => { 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 }); + } + }); }); From 814ea67eecc04e9987da1898b5e008c44608a098 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 25 Jul 2026 08:44:37 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(#198):=20panel=20round-3=20=E2=80=94=20?= =?UTF-8?q?meta-baseline=20resume-safety,=20presence-based=20resume=20dete?= =?UTF-8?q?ction,=20stronger=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 drew three correct findings; all fixed: - [critical/scope-bypass] host.captureMetaBaseline() ran on EVERY resume, capturing the META baseline (workflow perms, lockfile, root-drift) from the CONTAMINATED resume tree and subtracting it from grading — the same contaminated-resume false-green, for the meta gate, and it made the "STRICT (nothing excluded)" warning false. Now captured ONLY on a fresh capture (isFreshCapture); on a resume the session's meta-baseline stays unset ⇒ STRICT (nothing subtracted), the safe direction. A clean scaffold has an empty meta baseline anyway, so the common case is unchanged. - [major/gate-relaxed] isResume was `loadState(cwd) !== null`, but loadState returns null for a missing AND a present-but-CORRUPT features.json — so a corrupt-state resume (tree already built into) looked fresh and could persist a CONTAMINATED baseline. New `hasState(cwd)` keys off FILE PRESENCE, so a corrupt/empty checklist is correctly a resume (→ strict). Presence, not parse-success, is the "tree may already be built" signal. - [major/missing-test] strengthened coverage: a FRESH positive control (meta captured once + baseline persisted); a RESUME asserting meta is NOT re-captured AND the false "RED … did NOT parse" report is suppressed (only the strict warning emitted); a CORRUPT features.json treated as a resume (no contaminated persist, no meta capture); and a hasState unit test proving it diverges from loadState exactly on a corrupt file. Verify: typecheck + lint clean; full suite 3124 tests, 0 fail. --- packages/core/src/loop/boringstack/build.ts | 35 +++-- packages/core/src/loop/greenfield/index.ts | 1 + packages/core/src/loop/greenfield/state.ts | 10 ++ packages/core/tests/boringstack-build.test.ts | 140 ++++++++++++++++++ packages/core/tests/greenfield.test.ts | 20 +++ 5 files changed, 196 insertions(+), 10 deletions(-) diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 0cd0bd50..34506188 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -16,7 +16,7 @@ import { extractFailures } from "./extract-failures"; import { resolveStuckFile } from "../expert-handoff"; import { refinePrompt } from "./refine-prompt"; import { runGreenfield } from "../greenfield/run"; -import { greenfieldDir, loadState } from "../greenfield/state"; +import { greenfieldDir, hasState } from "../greenfield/state"; import { composeBoringstackGate } from "./gate-stages"; import type { Reporter, IHandoff, EscalationRung } from "../loop.types"; import { slicesToFeatures } from "./plan-resources"; @@ -981,16 +981,22 @@ export async function runBoringstackBuild(opts: { // (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 — `loadState !== null`. build.ts - // never writes the checklist before this point, so a genuine fresh build has none here; - // its mere PRESENCE (even with an empty feature array) means the tree is no longer - // pristine. Do NOT additionally require `features.length > 0`: that would treat a - // loaded-but-empty checklist as fresh and persist a CONTAMINATED capture. Erring toward + // 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 loadState(cwd)) !== null; + 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. @@ -1007,6 +1013,7 @@ export async function runBoringstackBuild(opts: { } else if (!isResume) { baseline = fresh.baseline; baselinePassed = baseRun.passed; + isFreshCapture = true; await saveBaseline(cwd, { passed: baseRun.passed, signatures: fresh.baseline, @@ -1036,9 +1043,17 @@ export async function runBoringstackBuild(opts: { } // 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 384bec72..0b6012b5 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -26,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) { @@ -86,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(); @@ -1530,4 +1560,114 @@ describe("baseline persistence — resume-safe differential grading", () => { 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(