feat: environment health checks, progress score, and status integration (#1263)
This commit is contained in:
parent
d4d8d1a81e
commit
652ac385b7
10 changed files with 1367 additions and 4 deletions
|
|
@ -17,6 +17,9 @@ tests/*:AKIA_EXAMPLE
|
|||
tests/*:test-secret-value
|
||||
tests/*:fake[-_]?(password|secret|token|key)
|
||||
|
||||
# Doctor environment tests use dummy localhost DB URLs
|
||||
src/resources/extensions/gsd/tests/doctor-environment.test.ts:postgres://localhost
|
||||
|
||||
# Documentation examples
|
||||
*.md:AKIA[0-9A-Z]{16}
|
||||
*.md:sk_(live|test)_
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import { handleConfig } from "./commands-config.js";
|
|||
import { handleInspect } from "./commands-inspect.js";
|
||||
import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
|
||||
import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
|
||||
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
|
||||
import { runEnvironmentChecks } from "./doctor-environment.js";
|
||||
import { handleLogs } from "./commands-logs.js";
|
||||
import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
|
||||
|
||||
|
|
@ -1068,6 +1070,11 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<
|
|||
function formatTextStatus(state: GSDState): string {
|
||||
const lines: string[] = ["GSD Status\n"];
|
||||
|
||||
// Progress score — traffic light (#1221)
|
||||
const progressScore = computeProgressScore();
|
||||
lines.push(formatProgressLine(progressScore));
|
||||
lines.push("");
|
||||
|
||||
// Phase
|
||||
lines.push(`Phase: ${state.phase}`);
|
||||
|
||||
|
|
@ -1114,5 +1121,17 @@ function formatTextStatus(state: GSDState): string {
|
|||
}
|
||||
}
|
||||
|
||||
// Environment health (#1221)
|
||||
const envResults = runEnvironmentChecks(projectRoot());
|
||||
const envIssues = envResults.filter(r => r.status !== "ok");
|
||||
if (envIssues.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Environment:");
|
||||
for (const r of envIssues) {
|
||||
const icon = r.status === "error" ? "✗" : "⚠";
|
||||
lines.push(` ${icon} ${r.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { getActiveWorktreeName } from "./worktree-command.js";
|
|||
import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
|
||||
import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
|
||||
import { estimateTimeRemaining } from "./auto-dashboard.js";
|
||||
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
|
||||
import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
|
||||
|
||||
function unitLabel(type: string): string {
|
||||
switch (type) {
|
||||
|
|
@ -310,6 +312,15 @@ export class GSDDashboardOverlay {
|
|||
elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`);
|
||||
}
|
||||
lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth)));
|
||||
|
||||
// Progress score — traffic light indicator (#1221)
|
||||
if (this.dashData.active || this.dashData.paused) {
|
||||
const progressScore = computeProgressScore();
|
||||
const progressIcon = progressScore.level === "green" ? th.fg("success", "●")
|
||||
: progressScore.level === "yellow" ? th.fg("warning", "●")
|
||||
: th.fg("error", "●");
|
||||
lines.push(row(`${progressIcon} ${th.fg("text", progressScore.summary)}`));
|
||||
}
|
||||
lines.push(blank());
|
||||
|
||||
if (this.dashData.currentUnit) {
|
||||
|
|
@ -579,6 +590,23 @@ export class GSDDashboardOverlay {
|
|||
}
|
||||
}
|
||||
|
||||
// Environment health section (#1221) — only show issues
|
||||
const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
|
||||
const envIssues = envResults.filter(r => r.status !== "ok");
|
||||
if (envIssues.length > 0) {
|
||||
lines.push(blank());
|
||||
lines.push(hr());
|
||||
lines.push(row(th.fg("text", th.bold("Environment"))));
|
||||
lines.push(blank());
|
||||
for (const r of envIssues) {
|
||||
const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠");
|
||||
lines.push(row(` ${icon} ${th.fg("text", r.message)}`));
|
||||
if (r.detail) {
|
||||
lines.push(row(th.fg("dim", ` ${r.detail}`)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(blank());
|
||||
lines.push(hr());
|
||||
lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close")));
|
||||
|
|
|
|||
497
src/resources/extensions/gsd/doctor-environment.ts
Normal file
497
src/resources/extensions/gsd/doctor-environment.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
/**
|
||||
* GSD Doctor — Environment Health Checks (#1221)
|
||||
*
|
||||
* Deterministic checks for environment readiness that prevent the model
|
||||
* from spinning its wheels on missing tools, port conflicts, stale
|
||||
* dependencies, and other infrastructure issues.
|
||||
*
|
||||
* These checks complement the existing git/runtime health checks and
|
||||
* integrate into the doctor pipeline via checkEnvironmentHealth().
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EnvironmentCheckResult {
|
||||
name: string;
|
||||
status: "ok" | "warning" | "error";
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Default dev server ports to scan for conflicts. */
|
||||
const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888];
|
||||
|
||||
/** Minimum free disk space in bytes (500MB). */
|
||||
const MIN_DISK_BYTES = 500 * 1024 * 1024;
|
||||
|
||||
/** Timeout for external commands (ms). */
|
||||
const CMD_TIMEOUT = 5_000;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function tryExec(cmd: string, cwd: string): string | null {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd,
|
||||
timeout: CMD_TIMEOUT,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function commandExists(name: string, cwd: string): boolean {
|
||||
const whichCmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
|
||||
return tryExec(whichCmd, cwd) !== null;
|
||||
}
|
||||
|
||||
// ── Individual Checks ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check that Node.js version meets the project's engines requirement.
|
||||
*/
|
||||
function checkNodeVersion(basePath: string): EnvironmentCheckResult | null {
|
||||
const pkgPath = join(basePath, "package.json");
|
||||
if (!existsSync(pkgPath)) return null;
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const required = pkg.engines?.node;
|
||||
if (!required) return null;
|
||||
|
||||
const currentVersion = tryExec("node --version", basePath);
|
||||
if (!currentVersion) {
|
||||
return { name: "node_version", status: "error", message: "Node.js not found in PATH" };
|
||||
}
|
||||
|
||||
// Parse semver requirement (handles >=X.Y.Z format)
|
||||
const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/);
|
||||
if (!reqMatch) return null;
|
||||
|
||||
const reqMajor = parseInt(reqMatch[1], 10);
|
||||
const reqMinor = parseInt(reqMatch[2] ?? "0", 10);
|
||||
|
||||
const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/);
|
||||
if (!curMatch) return null;
|
||||
|
||||
const curMajor = parseInt(curMatch[1], 10);
|
||||
const curMinor = parseInt(curMatch[2], 10);
|
||||
|
||||
if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) {
|
||||
return {
|
||||
name: "node_version",
|
||||
status: "warning",
|
||||
message: `Node.js ${currentVersion} does not meet requirement "${required}"`,
|
||||
detail: `Current: ${currentVersion}, Required: ${required}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: "node_version", status: "ok", message: `Node.js ${currentVersion}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node_modules exists and is not stale vs the lockfile.
|
||||
*/
|
||||
function checkDependenciesInstalled(basePath: string): EnvironmentCheckResult | null {
|
||||
const pkgPath = join(basePath, "package.json");
|
||||
if (!existsSync(pkgPath)) return null;
|
||||
|
||||
const nodeModules = join(basePath, "node_modules");
|
||||
if (!existsSync(nodeModules)) {
|
||||
return {
|
||||
name: "dependencies",
|
||||
status: "error",
|
||||
message: "node_modules missing — run npm install",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if lockfile is newer than node_modules
|
||||
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
||||
for (const lockfile of lockfiles) {
|
||||
const lockPath = join(basePath, lockfile);
|
||||
if (!existsSync(lockPath)) continue;
|
||||
|
||||
try {
|
||||
const lockMtime = statSync(lockPath).mtimeMs;
|
||||
const nmMtime = statSync(nodeModules).mtimeMs;
|
||||
|
||||
if (lockMtime > nmMtime) {
|
||||
return {
|
||||
name: "dependencies",
|
||||
status: "warning",
|
||||
message: `${lockfile} is newer than node_modules — dependencies may be stale`,
|
||||
detail: `Run npm install / yarn / pnpm install to update`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// stat failed — skip
|
||||
}
|
||||
}
|
||||
|
||||
return { name: "dependencies", status: "ok", message: "Dependencies installed" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for .env.example files without corresponding .env files.
|
||||
*/
|
||||
function checkEnvFiles(basePath: string): EnvironmentCheckResult | null {
|
||||
const examplePath = join(basePath, ".env.example");
|
||||
if (!existsSync(examplePath)) return null;
|
||||
|
||||
const envPath = join(basePath, ".env");
|
||||
const envLocalPath = join(basePath, ".env.local");
|
||||
|
||||
if (!existsSync(envPath) && !existsSync(envLocalPath)) {
|
||||
return {
|
||||
name: "env_file",
|
||||
status: "warning",
|
||||
message: ".env.example exists but no .env or .env.local found",
|
||||
detail: "Copy .env.example to .env and fill in values",
|
||||
};
|
||||
}
|
||||
|
||||
return { name: "env_file", status: "ok", message: "Environment file present" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for port conflicts on common dev server ports.
|
||||
* Only checks ports that appear in package.json scripts.
|
||||
*/
|
||||
function checkPortConflicts(basePath: string): EnvironmentCheckResult[] {
|
||||
// Only run on macOS/Linux — lsof is not available on Windows
|
||||
if (process.platform === "win32") return [];
|
||||
|
||||
const results: EnvironmentCheckResult[] = [];
|
||||
|
||||
// Try to detect ports from package.json scripts
|
||||
const portsToCheck = new Set<number>();
|
||||
const pkgPath = join(basePath, "package.json");
|
||||
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const scripts = pkg.scripts ?? {};
|
||||
const scriptText = Object.values(scripts).join(" ");
|
||||
|
||||
// Look for --port NNNN, -p NNNN, PORT=NNNN, :NNNN patterns
|
||||
const portMatches = scriptText.matchAll(/(?:--port\s+|(?:^|[^a-z])PORT[=:]\s*|-p\s+|:)(\d{4,5})\b/gi);
|
||||
for (const m of portMatches) {
|
||||
const port = parseInt(m[1], 10);
|
||||
if (port >= 1024 && port <= 65535) portsToCheck.add(port);
|
||||
}
|
||||
} catch {
|
||||
// parse failed — use defaults
|
||||
}
|
||||
}
|
||||
|
||||
// If no ports found in scripts, check common defaults
|
||||
if (portsToCheck.size === 0) {
|
||||
for (const p of DEFAULT_DEV_PORTS) portsToCheck.add(p);
|
||||
}
|
||||
|
||||
for (const port of portsToCheck) {
|
||||
const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath);
|
||||
if (result && result.length > 0) {
|
||||
// Get process name
|
||||
const nameResult = tryExec(`lsof -i :${port} -sTCP:LISTEN -Fp | head -2`, basePath);
|
||||
const processName = nameResult?.match(/p(\d+)\n?c?(.+)?/)?.[2] ?? "unknown";
|
||||
|
||||
results.push({
|
||||
name: "port_conflict",
|
||||
status: "warning",
|
||||
message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`,
|
||||
detail: `Kill the process or use a different port`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check available disk space on the working directory partition.
|
||||
*/
|
||||
function checkDiskSpace(basePath: string): EnvironmentCheckResult | null {
|
||||
// Only run on macOS/Linux
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath);
|
||||
if (!dfOutput) return null;
|
||||
|
||||
try {
|
||||
// df output: filesystem blocks used avail capacity mount
|
||||
const parts = dfOutput.split(/\s+/);
|
||||
const availKB = parseInt(parts[3], 10);
|
||||
if (isNaN(availKB)) return null;
|
||||
|
||||
const availBytes = availKB * 1024;
|
||||
const availMB = Math.round(availBytes / (1024 * 1024));
|
||||
const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1);
|
||||
|
||||
if (availBytes < MIN_DISK_BYTES) {
|
||||
return {
|
||||
name: "disk_space",
|
||||
status: "error",
|
||||
message: `Low disk space: ${availMB}MB free`,
|
||||
detail: `Free up space — builds and git operations may fail`,
|
||||
};
|
||||
}
|
||||
|
||||
if (availBytes < MIN_DISK_BYTES * 4) {
|
||||
return {
|
||||
name: "disk_space",
|
||||
status: "warning",
|
||||
message: `Disk space getting low: ${availGB}GB free`,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: "disk_space", status: "ok", message: `${availGB}GB free` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker is available when project has a Dockerfile.
|
||||
*/
|
||||
function checkDocker(basePath: string): EnvironmentCheckResult | null {
|
||||
const hasDockerfile = existsSync(join(basePath, "Dockerfile")) ||
|
||||
existsSync(join(basePath, "docker-compose.yml")) ||
|
||||
existsSync(join(basePath, "docker-compose.yaml")) ||
|
||||
existsSync(join(basePath, "compose.yml")) ||
|
||||
existsSync(join(basePath, "compose.yaml"));
|
||||
|
||||
if (!hasDockerfile) return null;
|
||||
|
||||
if (!commandExists("docker", basePath)) {
|
||||
return {
|
||||
name: "docker",
|
||||
status: "warning",
|
||||
message: "Project has Docker files but docker is not installed",
|
||||
};
|
||||
}
|
||||
|
||||
const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath);
|
||||
if (!info) {
|
||||
return {
|
||||
name: "docker",
|
||||
status: "warning",
|
||||
message: "Docker is installed but daemon is not running",
|
||||
detail: "Start Docker Desktop or the docker daemon",
|
||||
};
|
||||
}
|
||||
|
||||
return { name: "docker", status: "ok", message: `Docker ${info}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for common project tools that should be available.
|
||||
*/
|
||||
function checkProjectTools(basePath: string): EnvironmentCheckResult[] {
|
||||
const results: EnvironmentCheckResult[] = [];
|
||||
const pkgPath = join(basePath, "package.json");
|
||||
|
||||
if (!existsSync(pkgPath)) return results;
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const allDeps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
|
||||
// Check for package manager
|
||||
const packageManager = pkg.packageManager;
|
||||
if (packageManager) {
|
||||
const managerName = packageManager.split("@")[0];
|
||||
if (managerName && managerName !== "npm" && !commandExists(managerName, basePath)) {
|
||||
results.push({
|
||||
name: "package_manager",
|
||||
status: "warning",
|
||||
message: `Project requires ${managerName} but it's not installed`,
|
||||
detail: `Install with: npm install -g ${managerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for TypeScript if it's a dependency
|
||||
if (allDeps["typescript"] && !existsSync(join(basePath, "node_modules", ".bin", "tsc"))) {
|
||||
results.push({
|
||||
name: "typescript",
|
||||
status: "warning",
|
||||
message: "TypeScript is a dependency but tsc is not available (run npm install)",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for Python if pyproject.toml or requirements.txt exists
|
||||
if (existsSync(join(basePath, "pyproject.toml")) || existsSync(join(basePath, "requirements.txt"))) {
|
||||
if (!commandExists("python3", basePath) && !commandExists("python", basePath)) {
|
||||
results.push({
|
||||
name: "python",
|
||||
status: "warning",
|
||||
message: "Project has Python config but python is not installed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Rust if Cargo.toml exists
|
||||
if (existsSync(join(basePath, "Cargo.toml"))) {
|
||||
if (!commandExists("cargo", basePath)) {
|
||||
results.push({
|
||||
name: "cargo",
|
||||
status: "warning",
|
||||
message: "Project has Cargo.toml but cargo is not installed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Go if go.mod exists
|
||||
if (existsSync(join(basePath, "go.mod"))) {
|
||||
if (!commandExists("go", basePath)) {
|
||||
results.push({
|
||||
name: "go",
|
||||
status: "warning",
|
||||
message: "Project has go.mod but go is not installed",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// parse failed — skip
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check git remote reachability.
|
||||
*/
|
||||
function checkGitRemote(basePath: string): EnvironmentCheckResult | null {
|
||||
// Only check if it's a git repo with a remote
|
||||
const remote = tryExec("git remote get-url origin", basePath);
|
||||
if (!remote) return null;
|
||||
|
||||
// Quick connectivity check with short timeout
|
||||
const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath);
|
||||
if (result === null) {
|
||||
return {
|
||||
name: "git_remote",
|
||||
status: "warning",
|
||||
message: "Git remote 'origin' is unreachable",
|
||||
detail: `Remote: ${remote}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: "git_remote", status: "ok", message: "Git remote reachable" };
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run all environment health checks. Returns structured results for
|
||||
* integration with the doctor pipeline.
|
||||
*/
|
||||
export function runEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
|
||||
const results: EnvironmentCheckResult[] = [];
|
||||
|
||||
const nodeCheck = checkNodeVersion(basePath);
|
||||
if (nodeCheck) results.push(nodeCheck);
|
||||
|
||||
const depsCheck = checkDependenciesInstalled(basePath);
|
||||
if (depsCheck) results.push(depsCheck);
|
||||
|
||||
const envCheck = checkEnvFiles(basePath);
|
||||
if (envCheck) results.push(envCheck);
|
||||
|
||||
results.push(...checkPortConflicts(basePath));
|
||||
|
||||
const diskCheck = checkDiskSpace(basePath);
|
||||
if (diskCheck) results.push(diskCheck);
|
||||
|
||||
const dockerCheck = checkDocker(basePath);
|
||||
if (dockerCheck) results.push(dockerCheck);
|
||||
|
||||
results.push(...checkProjectTools(basePath));
|
||||
|
||||
// Git remote check can be slow — only run on explicit doctor invocation
|
||||
// (not on pre-dispatch gate)
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run environment checks with git remote check included.
|
||||
* Use this for explicit /gsd doctor invocations, not pre-dispatch gates.
|
||||
*/
|
||||
export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
|
||||
const results = runEnvironmentChecks(basePath);
|
||||
|
||||
const remoteCheck = checkGitRemote(basePath);
|
||||
if (remoteCheck) results.push(remoteCheck);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert environment check results to DoctorIssue format for the doctor pipeline.
|
||||
*/
|
||||
export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult[]): DoctorIssue[] {
|
||||
return results
|
||||
.filter(r => r.status !== "ok")
|
||||
.map(r => ({
|
||||
severity: r.status === "error" ? "error" as const : "warning" as const,
|
||||
code: `env_${r.name}` as DoctorIssueCode,
|
||||
scope: "project" as const,
|
||||
unitId: "environment",
|
||||
message: r.detail ? `${r.message} — ${r.detail}` : r.message,
|
||||
fixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration point for the doctor pipeline. Runs environment checks
|
||||
* and appends issues to the provided array.
|
||||
*/
|
||||
export async function checkEnvironmentHealth(
|
||||
basePath: string,
|
||||
issues: DoctorIssue[],
|
||||
options?: { includeRemote?: boolean },
|
||||
): Promise<void> {
|
||||
const results = options?.includeRemote
|
||||
? runFullEnvironmentChecks(basePath)
|
||||
: runEnvironmentChecks(basePath);
|
||||
|
||||
issues.push(...environmentResultsToDoctorIssues(results));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format environment check results for display.
|
||||
*/
|
||||
export function formatEnvironmentReport(results: EnvironmentCheckResult[]): string {
|
||||
if (results.length === 0) return "No environment checks applicable.";
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("Environment Health:");
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.status === "ok" ? "\u2705" : r.status === "warning" ? "\u26A0\uFE0F" : "\uD83D\uDED1";
|
||||
lines.push(` ${icon} ${r.message}`);
|
||||
if (r.detail && r.status !== "ok") {
|
||||
lines.push(` ${r.detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
@ -32,7 +32,20 @@ export type DoctorIssueCode =
|
|||
| "gitignore_missing_patterns"
|
||||
| "unresolvable_dependency"
|
||||
| "failed_migration"
|
||||
| "broken_symlink";
|
||||
| "broken_symlink"
|
||||
// Environment health checks (#1221)
|
||||
| "env_node_version"
|
||||
| "env_dependencies"
|
||||
| "env_env_file"
|
||||
| "env_port_conflict"
|
||||
| "env_disk_space"
|
||||
| "env_docker"
|
||||
| "env_package_manager"
|
||||
| "env_typescript"
|
||||
| "env_python"
|
||||
| "env_cargo"
|
||||
| "env_go"
|
||||
| "env_git_remote";
|
||||
|
||||
/**
|
||||
* Issue codes that represent expected completion-transition states.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.
|
|||
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
||||
import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
|
||||
import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
|
||||
import { checkEnvironmentHealth } from "./doctor-environment.js";
|
||||
|
||||
// ── Re-exports ─────────────────────────────────────────────────────────────
|
||||
// All public types and functions from extracted modules are re-exported here
|
||||
// so that existing imports from "./doctor.js" continue to work unchanged.
|
||||
export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
|
||||
export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
|
||||
export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js";
|
||||
export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js";
|
||||
|
||||
/**
|
||||
* Characters that are used as delimiters in GSD state management documents
|
||||
|
|
@ -390,6 +393,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
// Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore)
|
||||
await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
|
||||
|
||||
// Environment health checks (#1221: missing tools, port conflicts, stale deps, disk space)
|
||||
await checkEnvironmentHealth(basePath, issues, { includeRemote: !options?.scope });
|
||||
|
||||
const milestonesPath = milestonesDir(basePath);
|
||||
if (!existsSync(milestonesPath)) {
|
||||
return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
|
||||
|
|
|
|||
273
src/resources/extensions/gsd/progress-score.ts
Normal file
273
src/resources/extensions/gsd/progress-score.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* GSD Progress Score — Traffic Light Status Indicator (#1221)
|
||||
*
|
||||
* Combines existing health signals into a single at-a-glance status:
|
||||
* - Green: progressing well
|
||||
* - Yellow: struggling (retries, warnings)
|
||||
* - Red: stuck (loops, persistent errors, no activity)
|
||||
*
|
||||
* Purely derived — no stored state. Reads from doctor-proactive health
|
||||
* tracking, stuck detection counters, and working-tree activity.
|
||||
*/
|
||||
|
||||
import {
|
||||
getHealthTrend,
|
||||
getConsecutiveErrorUnits,
|
||||
getHealthHistory,
|
||||
type HealthSnapshot,
|
||||
} from "./doctor-proactive.js";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProgressLevel = "green" | "yellow" | "red";
|
||||
|
||||
export interface ProgressScore {
|
||||
level: ProgressLevel;
|
||||
summary: string;
|
||||
signals: ProgressSignal[];
|
||||
}
|
||||
|
||||
export interface ProgressSignal {
|
||||
name: string;
|
||||
level: ProgressLevel;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
// ── Signal Evaluators ──────────────────────────────────────────────────────
|
||||
|
||||
function evaluateHealthTrend(): ProgressSignal {
|
||||
const trend = getHealthTrend();
|
||||
|
||||
switch (trend) {
|
||||
case "improving":
|
||||
return { name: "health_trend", level: "green", detail: "Health improving" };
|
||||
case "stable":
|
||||
return { name: "health_trend", level: "green", detail: "Health stable" };
|
||||
case "degrading":
|
||||
return { name: "health_trend", level: "red", detail: "Health degrading" };
|
||||
case "unknown":
|
||||
return { name: "health_trend", level: "green", detail: "Insufficient data" };
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateErrorStreak(): ProgressSignal {
|
||||
const streak = getConsecutiveErrorUnits();
|
||||
|
||||
if (streak === 0) {
|
||||
return { name: "error_streak", level: "green", detail: "No consecutive errors" };
|
||||
}
|
||||
if (streak <= 2) {
|
||||
return { name: "error_streak", level: "yellow", detail: `${streak} consecutive error unit(s)` };
|
||||
}
|
||||
return { name: "error_streak", level: "red", detail: `${streak} consecutive error units` };
|
||||
}
|
||||
|
||||
function evaluateRecentErrors(): ProgressSignal {
|
||||
const history = getHealthHistory();
|
||||
if (history.length === 0) {
|
||||
return { name: "recent_errors", level: "green", detail: "No health data yet" };
|
||||
}
|
||||
|
||||
const latest = history[history.length - 1]!;
|
||||
|
||||
if (latest.errors === 0 && latest.warnings <= 1) {
|
||||
return { name: "recent_errors", level: "green", detail: `${latest.errors}E/${latest.warnings}W` };
|
||||
}
|
||||
if (latest.errors === 0) {
|
||||
return { name: "recent_errors", level: "yellow", detail: `${latest.warnings} warning(s)` };
|
||||
}
|
||||
if (latest.errors <= 2) {
|
||||
return { name: "recent_errors", level: "yellow", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
|
||||
}
|
||||
return { name: "recent_errors", level: "red", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
|
||||
}
|
||||
|
||||
function evaluateArtifactProduction(): ProgressSignal {
|
||||
const history = getHealthHistory();
|
||||
if (history.length < 2) {
|
||||
return { name: "artifact_production", level: "green", detail: "Insufficient data" };
|
||||
}
|
||||
|
||||
const totalFixes = history.reduce((sum, s) => sum + s.fixesApplied, 0);
|
||||
const recent = history.slice(-3);
|
||||
const recentFixes = recent.reduce((sum, s) => sum + s.fixesApplied, 0);
|
||||
|
||||
// If recent units are all producing fixes but errors aren't decreasing,
|
||||
// doctor is fighting fires but not making headway
|
||||
if (recentFixes > 3 && recent.every(s => s.errors > 0)) {
|
||||
return { name: "artifact_production", level: "yellow", detail: "Doctor applying fixes but errors persist" };
|
||||
}
|
||||
|
||||
return { name: "artifact_production", level: "green", detail: `${totalFixes} total fixes applied` };
|
||||
}
|
||||
|
||||
function evaluateDispatchVelocity(): ProgressSignal {
|
||||
const history = getHealthHistory();
|
||||
if (history.length < 3) {
|
||||
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
|
||||
}
|
||||
|
||||
// Check time between recent snapshots — are units completing at a reasonable rate?
|
||||
const recent = history.slice(-5);
|
||||
if (recent.length < 2) {
|
||||
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
|
||||
}
|
||||
|
||||
const timeDiffs: number[] = [];
|
||||
for (let i = 1; i < recent.length; i++) {
|
||||
timeDiffs.push(recent[i]!.timestamp - recent[i - 1]!.timestamp);
|
||||
}
|
||||
|
||||
const avgTimeMs = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
|
||||
const avgTimeMins = Math.round(avgTimeMs / 60_000);
|
||||
|
||||
// If average unit time is > 15 minutes, something might be wrong
|
||||
if (avgTimeMins > 15) {
|
||||
return { name: "dispatch_velocity", level: "yellow", detail: `Units averaging ${avgTimeMins}min each` };
|
||||
}
|
||||
|
||||
return { name: "dispatch_velocity", level: "green", detail: `Units averaging ${avgTimeMins || "<1"}min each` };
|
||||
}
|
||||
|
||||
// ── Main API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the current progress score by evaluating all available signals.
|
||||
* Returns a composite score with individual signal details.
|
||||
*/
|
||||
export function computeProgressScore(): ProgressScore {
|
||||
const signals: ProgressSignal[] = [
|
||||
evaluateHealthTrend(),
|
||||
evaluateErrorStreak(),
|
||||
evaluateRecentErrors(),
|
||||
evaluateArtifactProduction(),
|
||||
evaluateDispatchVelocity(),
|
||||
];
|
||||
|
||||
// Overall level: worst of all signals
|
||||
const level = signals.some(s => s.level === "red")
|
||||
? "red"
|
||||
: signals.some(s => s.level === "yellow")
|
||||
? "yellow"
|
||||
: "green";
|
||||
|
||||
// Build summary from the most important signals
|
||||
const summary = buildSummary(level, signals);
|
||||
|
||||
return { level, summary, signals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute progress score with additional context from the current unit.
|
||||
*/
|
||||
export function computeProgressScoreWithContext(context: {
|
||||
currentUnitType?: string;
|
||||
currentUnitId?: string;
|
||||
completedUnits?: number;
|
||||
totalUnits?: number;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
}): ProgressScore {
|
||||
const base = computeProgressScore();
|
||||
|
||||
// Add retry signal if available
|
||||
if (context.retryCount !== undefined && context.maxRetries !== undefined) {
|
||||
const retrySignal: ProgressSignal = context.retryCount === 0
|
||||
? { name: "retry_count", level: "green", detail: "No retries" }
|
||||
: context.retryCount <= 2
|
||||
? { name: "retry_count", level: "yellow", detail: `Retry ${context.retryCount}/${context.maxRetries}` }
|
||||
: { name: "retry_count", level: "red", detail: `Retry ${context.retryCount}/${context.maxRetries} — looping` };
|
||||
|
||||
base.signals.push(retrySignal);
|
||||
|
||||
// Re-evaluate level
|
||||
if (retrySignal.level === "red") base.level = "red";
|
||||
else if (retrySignal.level === "yellow" && base.level === "green") base.level = "yellow";
|
||||
}
|
||||
|
||||
// Build richer summary with context
|
||||
base.summary = buildSummaryWithContext(base.level, base.signals, context);
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
// ── Formatting ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSummary(level: ProgressLevel, signals: ProgressSignal[]): string {
|
||||
switch (level) {
|
||||
case "green":
|
||||
return "Progressing well";
|
||||
case "yellow": {
|
||||
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
|
||||
return `Struggling — ${issues[0] ?? "minor issues detected"}`;
|
||||
}
|
||||
case "red": {
|
||||
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
|
||||
return `Stuck — ${issues[0] ?? "critical issues detected"}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummaryWithContext(
|
||||
level: ProgressLevel,
|
||||
signals: ProgressSignal[],
|
||||
context: {
|
||||
currentUnitType?: string;
|
||||
currentUnitId?: string;
|
||||
completedUnits?: number;
|
||||
totalUnits?: number;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
},
|
||||
): string {
|
||||
const unitLabel = context.currentUnitId
|
||||
? ` ${context.currentUnitId}`
|
||||
: "";
|
||||
const progressLabel = context.completedUnits !== undefined && context.totalUnits !== undefined
|
||||
? ` (${context.completedUnits} of ${context.totalUnits} done)`
|
||||
: "";
|
||||
|
||||
switch (level) {
|
||||
case "green":
|
||||
return `Progressing well —${unitLabel}${progressLabel}`;
|
||||
case "yellow": {
|
||||
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
|
||||
const retryInfo = context.retryCount ? `, attempt ${context.retryCount}/${context.maxRetries}` : "";
|
||||
return `Struggling —${unitLabel}${retryInfo}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "issues detected"}`;
|
||||
}
|
||||
case "red": {
|
||||
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
|
||||
return `Stuck —${unitLabel}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "critical issues"}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format progress score as a single-line traffic light for TUI display.
|
||||
*/
|
||||
export function formatProgressLine(score: ProgressScore): string {
|
||||
const icon = score.level === "green" ? "\uD83D\uDFE2"
|
||||
: score.level === "yellow" ? "\uD83D\uDFE1"
|
||||
: "\uD83D\uDD34";
|
||||
return `${icon} ${score.summary}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a detailed progress report showing all signals.
|
||||
*/
|
||||
export function formatProgressReport(score: ProgressScore): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(formatProgressLine(score));
|
||||
lines.push("");
|
||||
lines.push("Signals:");
|
||||
|
||||
for (const signal of score.signals) {
|
||||
const icon = signal.level === "green" ? "\u2705"
|
||||
: signal.level === "yellow" ? "\u26A0\uFE0F"
|
||||
: "\uD83D\uDED1";
|
||||
lines.push(` ${icon} ${signal.name}: ${signal.detail}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
314
src/resources/extensions/gsd/tests/doctor-environment.test.ts
Normal file
314
src/resources/extensions/gsd/tests/doctor-environment.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* doctor-environment.test.ts — Tests for environment health checks (#1221).
|
||||
*
|
||||
* Tests:
|
||||
* - Node version detection
|
||||
* - Dependencies installed check
|
||||
* - Env file detection
|
||||
* - Port conflict detection
|
||||
* - Disk space check
|
||||
* - Docker detection
|
||||
* - Project tool detection
|
||||
* - Doctor issue conversion
|
||||
* - Report formatting
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
runEnvironmentChecks,
|
||||
runFullEnvironmentChecks,
|
||||
environmentResultsToDoctorIssues,
|
||||
formatEnvironmentReport,
|
||||
checkEnvironmentHealth,
|
||||
type EnvironmentCheckResult,
|
||||
} from "../doctor-environment.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
function createProjectDir(files: Record<string, string> = {}): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "gsd-env-test-"));
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const filePath = join(dir, name);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cleanups: string[] = [];
|
||||
|
||||
try {
|
||||
// ── Node Version Check ─────────────────────────────────────────────
|
||||
console.log("\n=== env: no package.json returns empty ===");
|
||||
{
|
||||
const dir = createProjectDir();
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
// No package.json → no node checks
|
||||
const nodeCheck = results.find(r => r.name === "node_version");
|
||||
assertEq(nodeCheck, undefined, "no node version check without package.json");
|
||||
}
|
||||
|
||||
console.log("\n=== env: package.json without engines returns no node check ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
|
||||
});
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const nodeCheck = results.find(r => r.name === "node_version");
|
||||
assertEq(nodeCheck, undefined, "no node version check without engines field");
|
||||
}
|
||||
|
||||
console.log("\n=== env: package.json with engines returns node check ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
engines: { node: ">=18.0.0" },
|
||||
}),
|
||||
});
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const nodeCheck = results.find(r => r.name === "node_version");
|
||||
assertTrue(nodeCheck !== undefined, "node version check runs with engines field");
|
||||
// Current node should be >= 18 in CI
|
||||
assertEq(nodeCheck!.status, "ok", "node version meets requirement");
|
||||
}
|
||||
|
||||
// ── Dependencies Check ─────────────────────────────────────────────
|
||||
console.log("\n=== env: missing node_modules detected ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
});
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const depsCheck = results.find(r => r.name === "dependencies");
|
||||
assertTrue(depsCheck !== undefined, "dependencies check runs");
|
||||
assertEq(depsCheck!.status, "error", "missing node_modules is an error");
|
||||
assertTrue(depsCheck!.message.includes("node_modules missing"), "reports missing node_modules");
|
||||
}
|
||||
|
||||
console.log("\n=== env: existing node_modules detected ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
});
|
||||
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const depsCheck = results.find(r => r.name === "dependencies");
|
||||
assertTrue(depsCheck !== undefined, "dependencies check runs");
|
||||
assertEq(depsCheck!.status, "ok", "existing node_modules is ok");
|
||||
}
|
||||
|
||||
// ── Env File Check ─────────────────────────────────────────────────
|
||||
console.log("\n=== env: .env.example without .env detected ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
".env.example": "DB_URL=xxx\nAPI_KEY=xxx\n",
|
||||
});
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const envCheck = results.find(r => r.name === "env_file");
|
||||
assertTrue(envCheck !== undefined, "env file check runs");
|
||||
assertEq(envCheck!.status, "warning", "missing .env is a warning");
|
||||
}
|
||||
|
||||
console.log("\n=== env: .env.example with .env is ok ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
".env.example": "DB_URL=xxx\n",
|
||||
".env": "DB_URL=postgres://localhost/test\n",
|
||||
});
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const envCheck = results.find(r => r.name === "env_file");
|
||||
assertTrue(envCheck !== undefined, "env file check runs");
|
||||
assertEq(envCheck!.status, "ok", "present .env is ok");
|
||||
}
|
||||
|
||||
console.log("\n=== env: .env.example with .env.local is ok ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
".env.example": "DB_URL=xxx\n",
|
||||
".env.local": "DB_URL=postgres://localhost/test\n",
|
||||
});
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const envCheck = results.find(r => r.name === "env_file");
|
||||
assertTrue(envCheck !== undefined, "env file check runs");
|
||||
assertEq(envCheck!.status, "ok", ".env.local counts as present");
|
||||
}
|
||||
|
||||
// ── Disk Space Check ───────────────────────────────────────────────
|
||||
console.log("\n=== env: disk space check returns result ===");
|
||||
if (process.platform !== "win32") {
|
||||
const dir = createProjectDir();
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const diskCheck = results.find(r => r.name === "disk_space");
|
||||
assertTrue(diskCheck !== undefined, "disk space check runs on unix");
|
||||
// Should be ok on dev machines with reasonable disk
|
||||
assertTrue(diskCheck!.status === "ok" || diskCheck!.status === "warning", "disk check returns valid status");
|
||||
}
|
||||
|
||||
// ── Project Tools Check ────────────────────────────────────────────
|
||||
console.log("\n=== env: detects missing python when pyproject.toml exists ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
"pyproject.toml": "[build-system]\nrequires = ['setuptools']\n",
|
||||
});
|
||||
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const pythonCheck = results.find(r => r.name === "python");
|
||||
// Python is likely installed on CI/dev machines, so just verify the check runs
|
||||
// without error — the result depends on the system
|
||||
assertTrue(true, "python check runs without error");
|
||||
}
|
||||
|
||||
console.log("\n=== env: detects Cargo.toml ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
"Cargo.toml": "[package]\nname = 'test'\n",
|
||||
});
|
||||
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
// Just verify it runs without error
|
||||
assertTrue(true, "cargo check runs without error");
|
||||
}
|
||||
|
||||
// ── Docker Check ───────────────────────────────────────────────────
|
||||
console.log("\n=== env: no docker check without Dockerfile ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
});
|
||||
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const dockerCheck = results.find(r => r.name === "docker");
|
||||
assertEq(dockerCheck, undefined, "no docker check without Dockerfile");
|
||||
}
|
||||
|
||||
console.log("\n=== env: docker check with Dockerfile ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
"Dockerfile": "FROM node:22\n",
|
||||
});
|
||||
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
const dockerCheck = results.find(r => r.name === "docker");
|
||||
// Docker may or may not be installed on the test machine
|
||||
assertTrue(dockerCheck !== undefined, "docker check runs when Dockerfile present");
|
||||
}
|
||||
|
||||
// ── Doctor Issue Conversion ────────────────────────────────────────
|
||||
console.log("\n=== env: converts results to doctor issues ===");
|
||||
{
|
||||
const results: EnvironmentCheckResult[] = [
|
||||
{ name: "node_version", status: "ok", message: "Node.js v22.0.0" },
|
||||
{ name: "dependencies", status: "error", message: "node_modules missing" },
|
||||
{ name: "env_file", status: "warning", message: ".env missing", detail: "Copy .env.example" },
|
||||
];
|
||||
|
||||
const issues = environmentResultsToDoctorIssues(results);
|
||||
assertEq(issues.length, 2, "only non-ok results converted");
|
||||
assertEq(issues[0]!.severity, "error", "error severity preserved");
|
||||
assertEq(issues[0]!.code, "env_dependencies", "code prefixed with env_");
|
||||
assertEq(issues[1]!.severity, "warning", "warning severity preserved");
|
||||
assertTrue(issues[1]!.message.includes("Copy .env.example"), "detail included in message");
|
||||
}
|
||||
|
||||
// ── checkEnvironmentHealth integration ──────────────────────────────
|
||||
console.log("\n=== env: checkEnvironmentHealth adds issues to array ===");
|
||||
{
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({ name: "test" }),
|
||||
});
|
||||
cleanups.push(dir);
|
||||
|
||||
const issues: any[] = [];
|
||||
await checkEnvironmentHealth(dir, issues);
|
||||
// Should have at least the missing node_modules issue
|
||||
assertTrue(issues.some(i => i.code === "env_dependencies"), "environment issues added to array");
|
||||
}
|
||||
|
||||
// ── Report Formatting ──────────────────────────────────────────────
|
||||
console.log("\n=== env: formatEnvironmentReport ===");
|
||||
{
|
||||
const results: EnvironmentCheckResult[] = [
|
||||
{ name: "node_version", status: "ok", message: "Node.js v22.0.0" },
|
||||
{ name: "dependencies", status: "error", message: "node_modules missing", detail: "Run npm install" },
|
||||
{ name: "disk_space", status: "ok", message: "50.2GB free" },
|
||||
];
|
||||
|
||||
const report = formatEnvironmentReport(results);
|
||||
assertTrue(report.includes("Environment Health:"), "has header");
|
||||
assertTrue(report.includes("Node.js v22.0.0"), "includes ok result");
|
||||
assertTrue(report.includes("node_modules missing"), "includes error result");
|
||||
assertTrue(report.includes("Run npm install"), "includes detail for errors");
|
||||
}
|
||||
|
||||
console.log("\n=== env: formatEnvironmentReport empty ===");
|
||||
{
|
||||
const report = formatEnvironmentReport([]);
|
||||
assertEq(report, "No environment checks applicable.", "empty report message");
|
||||
}
|
||||
|
||||
// ── Full environment checks include git remote ─────────────────────
|
||||
console.log("\n=== env: runFullEnvironmentChecks includes git remote ===");
|
||||
{
|
||||
// runFullEnvironmentChecks adds git remote check
|
||||
// We can't easily test this without a real git repo, but verify it doesn't throw
|
||||
const dir = createProjectDir();
|
||||
cleanups.push(dir);
|
||||
const results = runFullEnvironmentChecks(dir);
|
||||
// No git repo → no remote check, but should not throw
|
||||
assertTrue(true, "runFullEnvironmentChecks does not throw on non-git dir");
|
||||
}
|
||||
|
||||
// ── Port Detection from package.json ───────────────────────────────
|
||||
console.log("\n=== env: port detection from scripts ===");
|
||||
if (process.platform !== "win32") {
|
||||
const dir = createProjectDir({
|
||||
"package.json": JSON.stringify({
|
||||
name: "test",
|
||||
scripts: {
|
||||
dev: "next dev --port 3456",
|
||||
start: "node server.js",
|
||||
},
|
||||
}),
|
||||
});
|
||||
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
||||
cleanups.push(dir);
|
||||
const results = runEnvironmentChecks(dir);
|
||||
// Port 3456 is unlikely to be in use, so no conflicts expected
|
||||
const portConflicts = results.filter(r => r.name === "port_conflict");
|
||||
// Just verify it ran without error
|
||||
assertTrue(true, "port check with script-detected ports runs without error");
|
||||
}
|
||||
|
||||
} finally {
|
||||
for (const dir of cleanups) {
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
|
|
@ -23,7 +23,11 @@ function createCtx(entries: unknown[]) {
|
|||
|
||||
test("clearActivityLogState resets dedup state so identical saves write again", () => {
|
||||
clearActivityLogState();
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "gsd-memleak-test-"));
|
||||
// Pre-resolve baseDir so gsdRoot() returns a stable key across calls.
|
||||
// On macOS, /tmp is a symlink to /private/tmp — without realpathSync, the
|
||||
// key changes between the first save (dir doesn't exist, realpathSync throws)
|
||||
// and subsequent saves (dir exists, realpathSync resolves to /private/tmp/...).
|
||||
const baseDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-memleak-test-")));
|
||||
try {
|
||||
const entries = [{ role: "assistant", content: "test entry" }];
|
||||
const ctx = createCtx(entries);
|
||||
|
|
@ -53,7 +57,7 @@ test("clearActivityLogState resets dedup state so identical saves write again",
|
|||
|
||||
test("saveActivityLog writes valid JSONL via streaming", () => {
|
||||
clearActivityLogState();
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "gsd-memleak-jsonl-"));
|
||||
const baseDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-memleak-jsonl-")));
|
||||
try {
|
||||
const entries = [
|
||||
{ type: "message", message: { role: "user", content: "hello" } },
|
||||
|
|
|
|||
206
src/resources/extensions/gsd/tests/progress-score.test.ts
Normal file
206
src/resources/extensions/gsd/tests/progress-score.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* progress-score.test.ts — Tests for progress score / traffic light (#1221).
|
||||
*
|
||||
* Tests:
|
||||
* - Score computation from health signals
|
||||
* - Signal evaluation (trend, error streak, recent errors)
|
||||
* - Context-aware scoring (retry counts, unit progress)
|
||||
* - Formatting (single-line, detailed report)
|
||||
*/
|
||||
|
||||
import {
|
||||
recordHealthSnapshot,
|
||||
resetProactiveHealing,
|
||||
} from "../doctor-proactive.ts";
|
||||
|
||||
import {
|
||||
computeProgressScore,
|
||||
computeProgressScoreWithContext,
|
||||
formatProgressLine,
|
||||
formatProgressReport,
|
||||
} from "../progress-score.ts";
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
// ── Base Score: No Data ─────────────────────────────────────────────
|
||||
console.log("\n=== progress: green with no data ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
const score = computeProgressScore();
|
||||
assertEq(score.level, "green", "green when no data available");
|
||||
assertTrue(score.summary.includes("Progressing well"), "summary says progressing");
|
||||
assertTrue(score.signals.length > 0, "has signals");
|
||||
}
|
||||
|
||||
// ── Green: Clean Health Data ────────────────────────────────────────
|
||||
console.log("\n=== progress: green with clean health ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
recordHealthSnapshot(0, 0, 0);
|
||||
}
|
||||
const score = computeProgressScore();
|
||||
assertEq(score.level, "green", "green with all clean snapshots");
|
||||
}
|
||||
|
||||
// ── Yellow: Some Warnings ──────────────────────────────────────────
|
||||
console.log("\n=== progress: yellow with error streak ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
recordHealthSnapshot(1, 2, 0);
|
||||
recordHealthSnapshot(1, 1, 0);
|
||||
const score = computeProgressScore();
|
||||
assertEq(score.level, "yellow", "yellow with consecutive errors");
|
||||
assertTrue(score.summary.includes("Struggling"), "summary says struggling");
|
||||
}
|
||||
|
||||
// ── Red: Degrading Health ──────────────────────────────────────────
|
||||
console.log("\n=== progress: red with degrading trend ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
// 5 older clean snapshots
|
||||
for (let i = 0; i < 5; i++) {
|
||||
recordHealthSnapshot(0, 0, 0);
|
||||
}
|
||||
// 5 recent error snapshots — triggers degrading trend
|
||||
for (let i = 0; i < 5; i++) {
|
||||
recordHealthSnapshot(3, 5, 0);
|
||||
}
|
||||
const score = computeProgressScore();
|
||||
assertEq(score.level, "red", "red with degrading trend and persistent errors");
|
||||
assertTrue(score.summary.includes("Stuck"), "summary says stuck");
|
||||
}
|
||||
|
||||
// ── Red: High Error Streak ─────────────────────────────────────────
|
||||
console.log("\n=== progress: red with high error streak ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
recordHealthSnapshot(2, 0, 0);
|
||||
}
|
||||
const score = computeProgressScore();
|
||||
assertEq(score.level, "red", "red with 4 consecutive error units");
|
||||
}
|
||||
|
||||
// ── Context-Aware Scoring ──────────────────────────────────────────
|
||||
console.log("\n=== progress: context with retries ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordHealthSnapshot(0, 0, 0);
|
||||
}
|
||||
const score = computeProgressScoreWithContext({
|
||||
currentUnitId: "M001/S01/T03",
|
||||
completedUnits: 2,
|
||||
totalUnits: 5,
|
||||
retryCount: 0,
|
||||
maxRetries: 5,
|
||||
});
|
||||
assertEq(score.level, "green", "green with no retries");
|
||||
assertTrue(score.summary.includes("M001/S01/T03"), "summary includes unit ID");
|
||||
assertTrue(score.summary.includes("2 of 5"), "summary includes progress");
|
||||
}
|
||||
|
||||
console.log("\n=== progress: context with high retry count ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordHealthSnapshot(0, 0, 0);
|
||||
}
|
||||
const score = computeProgressScoreWithContext({
|
||||
currentUnitId: "M001/S01/T03",
|
||||
retryCount: 4,
|
||||
maxRetries: 5,
|
||||
});
|
||||
assertEq(score.level, "red", "red with high retry count");
|
||||
assertTrue(score.summary.includes("looping"), "summary mentions looping");
|
||||
}
|
||||
|
||||
console.log("\n=== progress: context with moderate retries ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordHealthSnapshot(0, 0, 0);
|
||||
}
|
||||
const score = computeProgressScoreWithContext({
|
||||
currentUnitId: "M001/S01/T03",
|
||||
retryCount: 1,
|
||||
maxRetries: 5,
|
||||
});
|
||||
assertEq(score.level, "yellow", "yellow with 1 retry");
|
||||
}
|
||||
|
||||
// ── Formatting ─────────────────────────────────────────────────────
|
||||
console.log("\n=== progress: formatProgressLine ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
const score = computeProgressScore();
|
||||
const line = formatProgressLine(score);
|
||||
assertTrue(line.includes("Progressing well"), "line includes summary");
|
||||
// Should start with green circle emoji
|
||||
assertTrue(line.startsWith("\uD83D\uDFE2"), "starts with green circle");
|
||||
}
|
||||
|
||||
console.log("\n=== progress: formatProgressLine yellow ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
recordHealthSnapshot(1, 0, 0);
|
||||
recordHealthSnapshot(1, 0, 0);
|
||||
const score = computeProgressScore();
|
||||
const line = formatProgressLine(score);
|
||||
assertTrue(line.startsWith("\uD83D\uDFE1"), "starts with yellow circle");
|
||||
}
|
||||
|
||||
console.log("\n=== progress: formatProgressReport ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
recordHealthSnapshot(0, 1, 0);
|
||||
const score = computeProgressScore();
|
||||
const detailed = formatProgressReport(score);
|
||||
assertTrue(detailed.includes("Signals:"), "report has signals section");
|
||||
assertTrue(detailed.includes("health_trend"), "report includes trend signal");
|
||||
assertTrue(detailed.includes("error_streak"), "report includes streak signal");
|
||||
}
|
||||
|
||||
// ── Signal Details ─────────────────────────────────────────────────
|
||||
console.log("\n=== progress: signal names are consistent ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
recordHealthSnapshot(0, 0, 0);
|
||||
const score = computeProgressScore();
|
||||
const names = score.signals.map(s => s.name);
|
||||
assertTrue(names.includes("health_trend"), "has health_trend signal");
|
||||
assertTrue(names.includes("error_streak"), "has error_streak signal");
|
||||
assertTrue(names.includes("recent_errors"), "has recent_errors signal");
|
||||
assertTrue(names.includes("artifact_production"), "has artifact_production signal");
|
||||
assertTrue(names.includes("dispatch_velocity"), "has dispatch_velocity signal");
|
||||
}
|
||||
|
||||
console.log("\n=== progress: all signals have valid levels ===");
|
||||
{
|
||||
resetProactiveHealing();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
recordHealthSnapshot(1, 1, 1);
|
||||
}
|
||||
const score = computeProgressScore();
|
||||
for (const signal of score.signals) {
|
||||
assertTrue(
|
||||
signal.level === "green" || signal.level === "yellow" || signal.level === "red",
|
||||
`signal ${signal.name} has valid level: ${signal.level}`,
|
||||
);
|
||||
assertTrue(signal.detail.length > 0, `signal ${signal.name} has non-empty detail`);
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
resetProactiveHealing();
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Add table
Reference in a new issue