diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index a51d2d47d..36df025a7 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -25,6 +25,7 @@ import { } from "./paths.js"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { hasImplementationArtifacts } from "./auto-recovery.js"; import { buildResearchMilestonePrompt, buildPlanMilestonePrompt, @@ -543,6 +544,17 @@ const DISPATCH_RULES: DispatchRule[] = [ } } + // Safety guard (#1703): verify the milestone produced implementation + // artifacts (non-.gsd/ files). A milestone with only plan files and + // zero implementation code should not be marked complete. + if (!hasImplementationArtifacts(basePath)) { + return { + action: "stop", + reason: `Cannot complete milestone ${mid}: no implementation files found outside .gsd/. The milestone has only plan files — actual code changes are required.`, + level: "error", + }; + } + return { action: "dispatch", unitType: "complete-milestone", diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index c4e752180..b33e53088 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -46,6 +46,7 @@ import { writeFileSync, unlinkSync, } from "node:fs"; +import { execFileSync } from "node:child_process"; import { dirname, join } from "node:path"; // ─── Artifact Resolution & Verification ─────────────────────────────────────── @@ -119,6 +120,112 @@ export function resolveExpectedArtifactPath( } } +/** + * Check whether a milestone produced implementation artifacts (non-`.gsd/` files) + * in the git history. Uses `git log --name-only` to inspect all commits on the + * current branch that touch files outside `.gsd/`. + * + * Returns true if at least one non-`.gsd/` file was committed, false otherwise. + * Non-fatal: returns true on git errors to avoid blocking the pipeline when + * running outside a git repo (e.g., tests). + */ +export function hasImplementationArtifacts(basePath: string): boolean { + try { + // Verify we're in a git repo — fail open if not + try { + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + } catch { + return true; + } + + // Strategy: check `git diff --name-only` against the merge-base with the + // main branch. This captures ALL files changed during the milestone's + // lifetime. If no merge-base exists (e.g., single-branch workflow), fall + // back to checking the last N commits. + const mainBranch = detectMainBranch(basePath); + const changedFiles = getChangedFilesSinceBranch(basePath, mainBranch); + + // No files changed at all — fail open (could be detached HEAD, single- + // commit repo, or other edge case where git diff returns nothing). + if (changedFiles.length === 0) return true; + + // Filter out .gsd/ files — only implementation files count. + // If every changed file is under .gsd/, the milestone produced no + // implementation code (#1703). + const implFiles = changedFiles.filter(f => !f.startsWith(".gsd/") && !f.startsWith(".gsd\\")); + return implFiles.length > 0; + } catch { + // Non-fatal — if git operations fail, don't block the pipeline + return true; + } +} + +/** + * Detect the main/master branch name. + */ +function detectMainBranch(basePath: string): string { + try { + const result = execFileSync("git", ["rev-parse", "--verify", "main"], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + if (result.trim()) return "main"; + } catch { + // main doesn't exist + } + try { + const result = execFileSync("git", ["rev-parse", "--verify", "master"], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + if (result.trim()) return "master"; + } catch { + // master doesn't exist either + } + return "main"; // default fallback +} + +/** + * Get files changed since the branch diverged from the target branch. + * Falls back to checking HEAD~20 if merge-base detection fails. + */ +function getChangedFilesSinceBranch(basePath: string, targetBranch: string): string[] { + try { + // Try merge-base approach first + const mergeBase = execFileSync( + "git", ["merge-base", targetBranch, "HEAD"], + { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, + ).trim(); + + if (mergeBase) { + const result = execFileSync( + "git", ["diff", "--name-only", mergeBase, "HEAD"], + { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, + ).trim(); + return result ? result.split("\n").filter(Boolean) : []; + } + } catch { + // merge-base failed — fall back + } + + // Fallback: check last 20 commits + try { + const result = execFileSync( + "git", ["log", "--name-only", "--pretty=format:", "-20", "HEAD"], + { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, + ).trim(); + return result ? [...new Set(result.split("\n").filter(Boolean))] : []; + } catch { + return []; + } +} + /** * Check whether the expected artifact(s) for a unit exist on disk. * Returns true if all required artifacts exist, or if the unit type has no @@ -287,6 +394,13 @@ export function verifyExpectedArtifact( } } + // complete-milestone must have produced implementation artifacts (#1703). + // A milestone with only .gsd/ plan files and zero implementation code is + // not genuinely complete — the LLM wrote plan files but skipped actual work. + if (unitType === "complete-milestone") { + if (!hasImplementationArtifacts(base)) return false; + } + return true; } diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 2bd57caef..45f0a485d 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -11,6 +11,7 @@ import { diagnoseExpectedArtifact, buildLoopRemediationSteps, selfHealRuntimeRecords, + hasImplementationArtifacts, } from "../auto-recovery.ts"; import { parseRoadmap, clearParseCache } from "../files.ts"; import { invalidateAllCaches } from "../cache.ts"; @@ -484,3 +485,106 @@ test("#793: invalidateAllCaches clears all caches so deriveState sees fresh disk cleanup(base); } }); + +// ─── hasImplementationArtifacts (#1703) ─────────────────────────────────── + +import { execFileSync } from "node:child_process"; + +function makeGitBase(): string { + const base = join(tmpdir(), `gsd-test-git-${randomUUID()}`); + mkdirSync(base, { recursive: true }); + execFileSync("git", ["init", "--initial-branch=main"], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: base, stdio: "ignore" }); + // Create initial commit so HEAD exists + writeFileSync(join(base, ".gitkeep"), ""); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: base, stdio: "ignore" }); + return base; +} + +test("hasImplementationArtifacts returns false when only .gsd/ files committed (#1703)", () => { + const base = makeGitBase(); + try { + // Create a feature branch and commit only .gsd/ files + execFileSync("git", ["checkout", "-b", "feat/test-milestone"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Summary"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "chore: add plan files"], { cwd: base, stdio: "ignore" }); + + const result = hasImplementationArtifacts(base); + assert.equal(result, false, "should return false when only .gsd/ files were committed"); + } finally { + cleanup(base); + } +}); + +test("hasImplementationArtifacts returns true when implementation files committed (#1703)", () => { + const base = makeGitBase(); + try { + // Create a feature branch with both .gsd/ and implementation files + execFileSync("git", ["checkout", "-b", "feat/test-impl"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); + mkdirSync(join(base, "src"), { recursive: true }); + writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "feat: add feature"], { cwd: base, stdio: "ignore" }); + + const result = hasImplementationArtifacts(base); + assert.equal(result, true, "should return true when implementation files are present"); + } finally { + cleanup(base); + } +}); + +test("hasImplementationArtifacts returns true on non-git directory (fail-open)", () => { + const base = join(tmpdir(), `gsd-test-nogit-${randomUUID()}`); + mkdirSync(base, { recursive: true }); + try { + const result = hasImplementationArtifacts(base); + assert.equal(result, true, "should return true (fail-open) in non-git directory"); + } finally { + cleanup(base); + } +}); + +// ─── verifyExpectedArtifact: complete-milestone requires impl artifacts (#1703) ── + +test("verifyExpectedArtifact complete-milestone fails with only .gsd/ files (#1703)", () => { + const base = makeGitBase(); + try { + // Create feature branch with only .gsd/ files + execFileSync("git", ["checkout", "-b", "feat/ms-only-gsd"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "chore: milestone plan files"], { cwd: base, stdio: "ignore" }); + + const result = verifyExpectedArtifact("complete-milestone", "M001", base); + assert.equal(result, false, "complete-milestone should fail verification when only .gsd/ files present"); + } finally { + cleanup(base); + } +}); + +test("verifyExpectedArtifact complete-milestone passes with impl files (#1703)", () => { + const base = makeGitBase(); + try { + // Create feature branch with implementation files AND milestone summary + execFileSync("git", ["checkout", "-b", "feat/ms-with-impl"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); + mkdirSync(join(base, "src"), { recursive: true }); + writeFileSync(join(base, "src", "app.ts"), "console.log('hello');"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "feat: implementation"], { cwd: base, stdio: "ignore" }); + + const result = verifyExpectedArtifact("complete-milestone", "M001", base); + assert.equal(result, true, "complete-milestone should pass verification with implementation files"); + } finally { + cleanup(base); + } +});