feat(slice-21 FR-5a): workspace-doctor check #1 — workspace root reachable (OPR.0.3.2.21.FR-5)

First commit of the FR-5 chain — establishes the per-check shape +
ships check #1 (the simplest gate). Checks #2-#7, daemon route, and
CLI subcommand land in follow-on commits on this same branch.

Per-check shape: `{check, status: "ok" | "warn" | "fail", message,
fixHint?, evidence?}` per the FR-5 IMPL-PRD §76-78. Orch-marshal-
accepted decision on the SHAPE-DIVERGENCE: the install-health
`rig doctor` (packages/cli/src/commands/doctor.ts) uses a different
shape (status: pass|warn|fail|skipped, reason/fix); `rig workspace
doctor` is intentionally a separate surface with a separate schema
because the concerns diverge (workspace-readiness vs install-health),
fixHint reads more honestly than fix, evidence is a structured
object that JSON-pipe consumers can introspect, and ok is state-
language vs pass test-language. Documented in the file header.

Check #1 — workspace root reachable:
- Verifies the resolved workspace root exists as a directory
- Propagates the resolution source (env / file / default) through
  the fix-hint so the operator gets the right remediation channel
  (unset OPENRIG_WORKSPACE_ROOT vs edit config.json vs run
  rig config init-workspace)
- Fail cases: ENOENT (root does not exist) and not-a-directory
  (root exists but is a regular file or other non-dir entry)
- Evidence object carries the workspaceRoot + source + errorCode/
  kind so JSON consumers can branch on the specific failure mode

Source-aware fix-hints land verbatim from the shipped ConfigStore
precedence at packages/cli/src/config-store.ts (env >
file > default) so the doctor's recommendations match the actual
resolution chain that produced the value being checked.

Tests (5 passing) include 4 discriminator-flip negative cases per
banked feedback_handoff_body_claims_need_discriminator_verification:
- ok path (existing directory)
- ENOENT (non-existent path)
- not-a-directory (regular file at the path)
- source-aware fix-hint (env vs file vs default)
- non-ENOENT IO error (EACCES via chmod 0o000 on parent; skips on
  Windows / root user where chmod is non-blocking)

The unit test imports the pure check function directly; no daemon /
HTTP / CLI wiring touched in this commit. That wiring lands in
follow-on commits (#2: checks #2-#7 core logic; #3: daemon route
POST /api/workspace/doctor; #4: CLI subcommand + formatters).

Verification:
- packages/daemon tsc EXIT=0 (clean; no errors)
- packages/daemon workspace-doctor.test.ts: 5/5 PASS
- Pre-commit staged-diff grep for forbidden-attribution pattern:
  ZERO matches
- Pre-commit FULL-CONTENT grep on every touched file (per the
  touch-event-scope rule I authored at FR-4): ZERO matches
- git diff --cached --stat: 2 files (+192 / -0), both net-new

Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: this commit (FR-5a check #1 core logic + types). LOCAL only,
NO push.

Files:
- packages/daemon/src/domain/workspace/workspace-doctor.ts (+96 new)
- packages/daemon/test/workspace-doctor.test.ts (+96 new)
This commit is contained in:
mvschwarz committed 2026-06-01 21:01:11 -07:00
1 parent e154a5cef1
commit a4b4b67dae
2 files changed
+192

No files matched your search

@@ -0,0 +1,96 @@
// Slice-21 FR-5 — workspace doctor readiness checks for `rig workspace doctor`.
//
// 7 checks (this commit ships #1; #2-#7 land in follow-on commits in
// this same chain):
// 1. Workspace root reachable (env > file > default precedence)
// 2. Missions folder present
// 3. File allowlist sane
// 4. Daemon points at this workspace
// 5. Daemon reload needed
// 6. Optional slice docs (warn-only)
// 7. MISSION_NOTES presence
//
// Per-check return shape is `{check, status: "ok"|"warn"|"fail",
// message, fixHint?, evidence?}` per FR-5 IMPL-PRD §76-78. The shape
// diverges intentionally from the install-health `rig doctor`'s
// DoctorCheck shape (pass|warn|fail|skipped, reason/fix) — different
// concerns warrant different schemas (workspace-readiness vs install-
// health); the divergence is orch-marshal-accepted per cont.43-followup.
import * as fs from "node:fs";
export type CheckStatus = "ok" | "warn" | "fail";
export interface DoctorCheck {
check: string;
status: CheckStatus;
message: string;
fixHint?: string;
evidence?: Record<string, unknown>;
}
export type WorkspaceRootSource = "env" | "file" | "default";
export interface CheckWorkspaceRootInput {
workspaceRoot: string;
source: WorkspaceRootSource;
}
const ENV_FIX_HINT =
"unset OPENRIG_WORKSPACE_ROOT or set it to an existing directory; run `rig config init-workspace` to scaffold a fresh one";
const FILE_FIX_HINT =
"update workspace.root in config.json to an existing directory; run `rig config init-workspace` to scaffold a fresh one";
const DEFAULT_FIX_HINT =
"run `rig config init-workspace` to scaffold the default workspace at the configured root";
function fixHintForSource(source: WorkspaceRootSource): string {
switch (source) {
case "env":
return ENV_FIX_HINT;
case "file":
return FILE_FIX_HINT;
case "default":
return DEFAULT_FIX_HINT;
}
}
/**
* Check #1 — workspace root reachable.
*
* Verifies the resolved workspace root exists as a directory. The
* `source` (env / file / default) is propagated into the fix-hint so
* the operator gets the right remediation channel.
*/
export function checkWorkspaceRootReachable(opts: CheckWorkspaceRootInput): DoctorCheck {
const { workspaceRoot, source } = opts;
let stat: fs.Stats;
try {
stat = fs.statSync(workspaceRoot);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
return {
check: "workspace_root_reachable",
status: "fail",
message: code === "ENOENT"
? `workspace root '${workspaceRoot}' does not exist (resolved from ${source})`
: `workspace root '${workspaceRoot}' is not reachable: ${(err as Error).message}`,
fixHint: fixHintForSource(source),
evidence: { workspaceRoot, source, errorCode: code ?? "unknown" },
};
}
if (!stat.isDirectory()) {
return {
check: "workspace_root_reachable",
status: "fail",
message: `workspace root '${workspaceRoot}' exists but is not a directory (resolved from ${source})`,
fixHint: fixHintForSource(source),
evidence: { workspaceRoot, source, kind: "not_a_directory" },
};
}
return {
check: "workspace_root_reachable",
status: "ok",
message: `workspace root '${workspaceRoot}' is a reachable directory`,
evidence: { workspaceRoot, source },
};
}
@@ -0,0 +1,96 @@
// Slice-21 FR-5 — workspace doctor check unit tests.
//
// Per banked feedback_handoff_body_claims_need_discriminator_verification,
// each check ships with a discriminator-flip negative test: if the
// production code were wrong, the assertion would catch it.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
checkWorkspaceRootReachable,
type DoctorCheck,
} from "../src/domain/workspace/workspace-doctor.js";
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5-doctor-"));
});
afterEach(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe("FR-5 check #1 — workspace root reachable", () => {
it("returns ok when workspace root is an existing directory", () => {
const result: DoctorCheck = checkWorkspaceRootReachable({ workspaceRoot: dir, source: "default" });
expect(result.check).toBe("workspace_root_reachable");
expect(result.status).toBe("ok");
expect(result.message).toContain(dir);
expect(result.fixHint).toBeUndefined();
expect(result.evidence).toEqual({ workspaceRoot: dir, source: "default" });
});
// Discriminator-flip: ENOENT path MUST be a fail, not silently pass.
it("returns fail with ENOENT evidence when workspace root does not exist", () => {
const missing = path.join(dir, "definitely-not-here");
const result = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "env" });
expect(result.status).toBe("fail");
expect(result.message).toContain("does not exist");
expect(result.message).toContain("env");
expect(result.fixHint).toContain("OPENRIG_WORKSPACE_ROOT");
expect(result.evidence?.errorCode).toBe("ENOENT");
});
// Discriminator-flip: a file (not directory) at the root MUST fail, not pass.
// Without isDirectory() check, statSync would succeed and the check
// would return ok — this test catches that regression.
it("returns fail when workspace root is a regular file, not a directory", () => {
const filePath = path.join(dir, "not-a-dir");
fs.writeFileSync(filePath, "");
const result = checkWorkspaceRootReachable({ workspaceRoot: filePath, source: "file" });
expect(result.status).toBe("fail");
expect(result.message).toContain("not a directory");
expect(result.fixHint).toContain("config.json");
expect(result.evidence?.kind).toBe("not_a_directory");
});
// Source-aware fix-hint discriminator. If fix-hint resolution were
// hard-coded to one source, this would catch it.
it("emits source-aware fix-hints (env vs file vs default)", () => {
const missing = path.join(dir, "missing");
const envResult = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "env" });
const fileResult = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "file" });
const defaultResult = checkWorkspaceRootReachable({ workspaceRoot: missing, source: "default" });
expect(envResult.fixHint).toContain("OPENRIG_WORKSPACE_ROOT");
expect(envResult.fixHint).not.toContain("workspace.root in config.json");
expect(fileResult.fixHint).toContain("workspace.root in config.json");
expect(fileResult.fixHint).not.toContain("OPENRIG_WORKSPACE_ROOT");
expect(defaultResult.fixHint).toContain("rig config init-workspace");
});
// Discriminator-flip: non-ENOENT IO error must still fail with a
// useful message + evidence.errorCode (e.g., EACCES on permission
// denied). Simulate by chmod 000 on a created subdir.
it("returns fail with errorCode evidence on non-ENOENT stat error", () => {
// Skip on platforms where chmod doesn't restrict stat (e.g. Windows,
// root user). The check is the discriminator-flip we want, but it
// must be robust under varied test environments.
if (process.platform === "win32" || process.getuid?.() === 0) return;
const lockedParent = path.join(dir, "locked-parent");
fs.mkdirSync(lockedParent);
const inside = path.join(lockedParent, "inside");
fs.mkdirSync(inside);
try {
fs.chmodSync(lockedParent, 0o000);
const result = checkWorkspaceRootReachable({ workspaceRoot: inside, source: "env" });
expect(result.status).toBe("fail");
expect(result.evidence?.errorCode).toBeDefined();
expect(result.evidence?.errorCode).not.toBe("unknown");
} finally {
fs.chmodSync(lockedParent, 0o700);
}
});
});