diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 7259ade02..bda534c0b 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -26,6 +26,7 @@ import { runUnit } from "./run-unit.js"; import { debugLog } from "../debug-logger.js"; import { gsdRoot } from "../paths.js"; import { atomicWriteSync } from "../atomic-write.js"; +import { PROJECT_FILES } from "../detection.js"; import { join } from "node:path"; // ─── generateMilestoneReport ────────────────────────────────────────────────── @@ -809,25 +810,27 @@ export async function runUnitPhase( unitId, }); - // ── Worktree health check (#1833) ─────────────────────────────────── + // ── Worktree health check (#1833, #1843) ──────────────────────────── // Verify the working directory is a valid git checkout with project // files before dispatching work. A broken worktree causes agents to // hallucinate summaries since they cannot read or write any files. + // Uses the shared PROJECT_FILES list from detection.ts to support all + // ecosystems (Rust, Go, Python, Java, etc.), not just JS. if (s.basePath && unitType === "execute-task") { const gitMarker = join(s.basePath, ".git"); const hasGit = deps.existsSync(gitMarker); - const hasPackageJson = deps.existsSync(join(s.basePath, "package.json")); - const hasSrcDir = deps.existsSync(join(s.basePath, "src")); if (!hasGit) { const msg = `Worktree health check failed: ${s.basePath} has no .git — refusing to dispatch ${unitType} ${unitId}`; - debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit, hasPackageJson, hasSrcDir }); + debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit }); ctx.ui.notify(msg, "error"); await deps.stopAuto(ctx, pi, msg); return { action: "break", reason: "worktree-invalid" }; } - if (!hasPackageJson && !hasSrcDir) { - const msg = `Worktree health check failed: ${s.basePath} has no package.json or src/ — refusing to dispatch ${unitType} ${unitId}`; - debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit, hasPackageJson, hasSrcDir }); + const hasProjectFile = PROJECT_FILES.some((f) => deps.existsSync(join(s.basePath, f))); + const hasSrcDir = deps.existsSync(join(s.basePath, "src")); + if (!hasProjectFile && !hasSrcDir) { + const msg = `Worktree health check failed: ${s.basePath} has no recognized project files — refusing to dispatch ${unitType} ${unitId}`; + debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasProjectFile, hasSrcDir }); ctx.ui.notify(msg, "error"); await deps.stopAuto(ctx, pi, msg); return { action: "break", reason: "worktree-invalid" }; diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 9401dae9b..9a0c159eb 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -69,7 +69,7 @@ export interface ProjectSignals { // ─── Project File Markers ─────────────────────────────────────────────────────── -const PROJECT_FILES = [ +export const PROJECT_FILES = [ "package.json", "Cargo.toml", "go.mod", diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index de3d5d77d..56dee17bd 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -2122,7 +2122,7 @@ test("autoLoop stops when worktree has no project files for execute-task (#1833) "should stop auto-mode when worktree has no project files", ); const healthNotification = notifications.find( - (n) => n.includes("Worktree health check failed") && n.includes("no package.json or src/"), + (n) => n.includes("Worktree health check failed") && n.includes("no recognized project files"), ); assert.ok( healthNotification, diff --git a/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts b/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts new file mode 100644 index 000000000..cd5d72f46 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts @@ -0,0 +1,178 @@ +/** + * worktree-health-dispatch.test.ts — Regression tests for the worktree health + * check in auto/phases.ts (#1833, #1843). + * + * Verifies that the pre-dispatch health check recognises non-JS project types + * (Rust, Go, Python, etc.) via the shared PROJECT_FILES list from detection.ts, + * rather than hard-coding package.json / src/ only. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { PROJECT_FILES } from "../detection.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Create a minimal git repo and return its path. */ +function createGitRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "wt-dispatch-test-")); + // All execSync calls use hardcoded strings only — no user input, no injection risk. + execSync("git init", { cwd: dir, stdio: "ignore" }); + execSync("git config user.email test@test.com", { cwd: dir, stdio: "ignore" }); + execSync("git config user.name Test", { cwd: dir, stdio: "ignore" }); + writeFileSync(join(dir, "README.md"), "# test\n"); + execSync("git add . && git commit -m init", { cwd: dir, stdio: "ignore" }); + return dir; +} + +/** + * Simulate the health check logic from auto/phases.ts. + * + * Returns true when the directory would PASS the health check (dispatch + * proceeds), false when it would FAIL (dispatch blocked). + * + * This mirrors the fixed logic: .git must exist, AND at least one + * PROJECT_FILES entry or a src/ directory must exist. + */ +function wouldPassHealthCheck(basePath: string, existsSyncFn: (p: string) => boolean): boolean { + const hasGit = existsSyncFn(join(basePath, ".git")); + if (!hasGit) return false; + + for (const file of PROJECT_FILES) { + if (existsSyncFn(join(basePath, file))) return true; + } + if (existsSyncFn(join(basePath, "src"))) return true; + + return false; +} + +import { existsSync } from "node:fs"; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test("PROJECT_FILES is exported and contains expected multi-ecosystem entries", () => { + assert.ok(Array.isArray(PROJECT_FILES), "PROJECT_FILES is an array"); + assert.ok(PROJECT_FILES.length >= 17, `expected >= 17 entries, got ${PROJECT_FILES.length}`); + // Spot-check key ecosystems + assert.ok(PROJECT_FILES.includes("Cargo.toml"), "includes Rust marker"); + assert.ok(PROJECT_FILES.includes("go.mod"), "includes Go marker"); + assert.ok(PROJECT_FILES.includes("pyproject.toml"), "includes Python marker"); + assert.ok(PROJECT_FILES.includes("package.json"), "includes JS marker"); + assert.ok(PROJECT_FILES.includes("pom.xml"), "includes Java marker"); + assert.ok(PROJECT_FILES.includes("Package.swift"), "includes Swift marker"); +}); + +test("health check passes for Rust project (Cargo.toml, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"test\"\n"); + mkdirSync(join(dir, "crates"), { recursive: true }); + assert.ok(wouldPassHealthCheck(dir, existsSync), "Rust project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for Go project (go.mod, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "go.mod"), "module example.com/test\n\ngo 1.21\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "Go project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for Python project (pyproject.toml, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "pyproject.toml"), "[project]\nname = \"test\"\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "Python project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for Java project (pom.xml, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "pom.xml"), "\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "Java project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for Swift project (Package.swift, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "Package.swift"), "// swift-tools-version:5.7\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "Swift project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for C/C++ project (CMakeLists.txt, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "C/C++ project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for Elixir project (mix.exs, no package.json)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "mix.exs"), "defmodule Test.MixProject do\nend\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "Elixir project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for JS project (package.json, backward compat)", () => { + const dir = createGitRepo(); + try { + writeFileSync(join(dir, "package.json"), '{"name":"test"}\n'); + assert.ok(wouldPassHealthCheck(dir, existsSync), "JS project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check passes for src/-only project (backward compat)", () => { + const dir = createGitRepo(); + try { + mkdirSync(join(dir, "src"), { recursive: true }); + assert.ok(wouldPassHealthCheck(dir, existsSync), "src/-only project should pass health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check fails for directory with no .git", () => { + const dir = mkdtempSync(join(tmpdir(), "wt-dispatch-test-nogit-")); + try { + writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"test\"\n"); + assert.ok(!wouldPassHealthCheck(dir, existsSync), "no-git directory should fail health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("health check fails for empty git repo with no project files", () => { + const dir = createGitRepo(); + try { + assert.ok(!wouldPassHealthCheck(dir, existsSync), "empty git repo should fail health check"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});