fix(auto): broaden worktree health check to all ecosystems (#1860)
* fix(auto): use PROJECT_FILES from detection.ts in worktree health check The worktree health check introduced in #1833 hard-coded package.json and src/ as the only valid project markers, blocking auto-mode dispatch for Rust (Cargo.toml), Go (go.mod), Python (pyproject.toml), and 14 other ecosystems. Replace the JS-centric heuristic with the shared PROJECT_FILES array from detection.ts which already covers 17+ ecosystems. Fixes #1843 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update test assertion to match new project files message The health check now says "no recognized project files" instead of "no package.json or src/" after broadening to PROJECT_FILES. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
358dc1da6b
commit
b49cb8cbad
4 changed files with 190 additions and 9 deletions
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export interface ProjectSignals {
|
|||
|
||||
// ─── Project File Markers ───────────────────────────────────────────────────────
|
||||
|
||||
const PROJECT_FILES = [
|
||||
export const PROJECT_FILES = [
|
||||
"package.json",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"), "<project></project>\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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue