From 4a82bc01dc3710f1566a92e5f41d59e9bb6eee18 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:31:10 -0400 Subject: [PATCH] fix: check bootstrap completeness in init wizard gate, not just .gsd/ existence (#2942) (#3237) A zombie .gsd/ state (symlink exists but missing PREFERENCES.md and milestones/) caused the init wizard to be skipped entirely, resulting in an uninitialized project session. - guided-flow.ts: Replace bare `!existsSync(gsdRoot(basePath))` with a compound check for PREFERENCES.md or milestones/ bootstrap artifacts - auto-start.ts: Check milestones/ path directly instead of .gsd/ which ensureGsdSymlink already created (was dead code) - Add zombie-gsd-state.test.ts verifying both fixes Co-authored-by: Claude Opus 4.6 --- src/resources/extensions/gsd/auto-start.ts | 9 +- src/resources/extensions/gsd/guided-flow.ts | 12 ++- .../gsd/tests/zombie-gsd-state.test.ts | 95 +++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 85bdbe370..37baa27ca 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -198,10 +198,13 @@ export async function bootstrapAutoSession( ensureGitignore(base, { manageGitignore }); if (manageGitignore !== false) untrackRuntimeFiles(base); - // Bootstrap .gsd/ if it doesn't exist + // Bootstrap milestones/ if it doesn't exist. + // Check milestones/ directly — ensureGsdSymlink above already created .gsd/, + // so checking .gsd/ existence would be dead code (#2942). const gsdDir = join(base, ".gsd"); - if (!existsSync(gsdDir)) { - mkdirSync(join(gsdDir, "milestones"), { recursive: true }); + const milestonesPath = join(gsdDir, "milestones"); + if (!existsSync(milestonesPath)) { + mkdirSync(milestonesPath, { recursive: true }); try { nativeAddAll(base); nativeCommit(base, "chore: init gsd"); diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index a0d074a36..86a8dfd15 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -981,7 +981,15 @@ export async function showSmartEntry( } // ── Detection preamble — run before any bootstrap ──────────────────── - if (!existsSync(gsdRoot(basePath))) { + // Check bootstrap completeness, not just .gsd/ directory existence. + // A zombie .gsd/ state (symlink exists but missing PREFERENCES.md and + // milestones/) must trigger the init wizard, not skip it (#2942). + const gsdPath = gsdRoot(basePath); + const hasBootstrapArtifacts = existsSync(gsdPath) + && (existsSync(join(gsdPath, "PREFERENCES.md")) + || existsSync(join(gsdPath, "milestones"))); + + if (!hasBootstrapArtifacts) { const detection = detectProjectState(basePath); // v1 .planning/ detected — offer migration before anything else @@ -996,7 +1004,7 @@ export async function showSmartEntry( // "fresh" — fall through to init wizard } - // No .gsd/ — run the project init wizard + // No .gsd/ or zombie .gsd/ — run the project init wizard const result = await showProjectInit(ctx, pi, basePath, detection); if (!result.completed) return; // User cancelled diff --git a/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts b/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts new file mode 100644 index 000000000..d18a7fcf8 --- /dev/null +++ b/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { createTestContext } from "./test-helpers.ts"; + +const { assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); + +// ─── #2942: Zombie .gsd state skips init wizard ───────────────────────────── +// +// A partially initialized .gsd/ (symlink exists but no PREFERENCES.md or +// milestones/) causes the init wizard gate in showSmartEntry to be skipped, +// resulting in an uninitialized project session. + +console.log("\n=== #2942: zombie .gsd state must not skip init wizard ==="); + +// ── guided-flow.ts — init wizard gate must check bootstrap completeness ── + +const guidedFlowSrc = readFileSync( + join(import.meta.dirname, "..", "guided-flow.ts"), + "utf-8", +); + +// Find the showSmartEntry function +const smartEntryIdx = guidedFlowSrc.indexOf("export async function showSmartEntry("); +assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showSmartEntry"); + +// Extract the region between showSmartEntry and the first showProjectInit call +// This is where the init wizard gate lives. +const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : ""; + +// The gate must NOT be a bare `!existsSync(gsdRoot(basePath))` check. +// It must also verify that bootstrap artifacts (PREFERENCES.md or milestones/) exist. +assertTrue( + afterSmartEntry.includes("PREFERENCES.md") || afterSmartEntry.includes("PREFERENCES"), + "init wizard gate checks for PREFERENCES.md, not just .gsd/ existence (#2942)", +); + +assertTrue( + afterSmartEntry.includes("milestones"), + "init wizard gate checks for milestones/ directory, not just .gsd/ existence (#2942)", +); + +// The init wizard should be shown when .gsd/ exists but has no bootstrap artifacts. +// The old code was: if (!existsSync(gsdRoot(basePath))) { ... showProjectInit ... } +// The fix should use a compound check so zombie states trigger the wizard. +// Verify we no longer have the bare existence check as the sole gate. + +// Find the specific init wizard gate pattern — the detection preamble block. +const detectionPreambleIdx = afterSmartEntry.indexOf("Detection preamble"); +const detectionRegion = detectionPreambleIdx >= 0 + ? afterSmartEntry.slice(detectionPreambleIdx, detectionPreambleIdx + 600) + : afterSmartEntry.slice(0, 1500); + +// The gate condition must reference PREFERENCES.md or milestones (bootstrap artifacts) +assertMatch( + detectionRegion, + /PREFERENCES\.md|milestones/, + "detection preamble gate references bootstrap artifacts, not just directory existence (#2942)", +); + +// ── auto-start.ts — milestones/ dir creation must not be dead code ────────── + +console.log("\n=== #2942: auto-start milestones/ bootstrap not dead code ==="); + +const autoStartSrc = readFileSync( + join(import.meta.dirname, "..", "auto-start.ts"), + "utf-8", +); + +// After ensureGsdSymlink, the code that creates milestones/ must check for +// the milestones directory specifically (not .gsd/ which ensureGsdSymlink already created). +const symlinkIdx = autoStartSrc.indexOf("ensureGsdSymlink(base)"); +assertTrue(symlinkIdx >= 0, "auto-start.ts calls ensureGsdSymlink(base)"); + +const afterSymlink = symlinkIdx >= 0 ? autoStartSrc.slice(symlinkIdx, symlinkIdx + 800) : ""; + +// The milestones bootstrap must check milestones path, not gsdDir +// Old (dead) code: if (!existsSync(gsdDir)) { mkdirSync(join(gsdDir, "milestones"), ...) } +// Fixed code should check: if (!existsSync(milestonesPath)) or similar +assertTrue( + afterSymlink.includes("milestones") && afterSymlink.includes("mkdirSync"), + "auto-start.ts creates milestones/ directory after ensureGsdSymlink (#2942)", +); + +// The guard for milestones/ creation should NOT be `!existsSync(gsdDir)` — +// that's dead code since ensureGsdSymlink already created gsdDir. +// It should check for the milestones/ dir directly. +const mkdirRegion = afterSymlink.slice(0, afterSymlink.indexOf("mkdirSync") + 200); +assertMatch( + mkdirRegion, + /existsSync\([^)]*milestones/, + "milestones bootstrap checks milestones path existence, not .gsd/ (#2942)", +); + +report();