feat(slice-21 FR-5b): workspace-doctor checks #2-#7 core logic + tests (OPR.0.3.2.21.FR-5)

Second commit of the FR-5 chain. Lands pure check helpers for the
remaining 6 readiness checks; daemon route wiring + CLI subcommand
land in follow-on commits FR-5c + FR-5d.

Checks shipped this commit:

Check #2 — missions_folder_present:
- Verifies the resolved missions folder (per ConfigStore
  workspace.slicesRoot, defaults to `<workspaceRoot>/missions`)
  exists as a directory.
- Source-aware fix-hints: default-path failure → `rig config
  init-workspace` recommendation; overridden-path failure →
  recommendation to unset workspace.slices_root or create the
  custom path. Discriminator-flip test asserts the override case
  does NOT recommend init-workspace (which would create the
  default folder, not the custom one).

Check #3 — file_allowlist_sane (REDESIGNED per the PRD-vs-shipped
DIVERGENCE I surfaced in the FR-5a handoff scope-tip):
- IMPL-PRD §43 references "<workspace-root>/.openrig/file-allowlist"
  as a file — that file does not exist on the shipped surface.
- Shipped reality: ConfigStore `files.allowlist` string key
  (env OPENRIG_FILES_ALLOWLIST, default `workspace:${workspaceRoot}`)
  holding comma-separated `name:/abs/path` pairs, decoded via
  parseNamedPairs at packages/cli/src/config-store.ts:735-749.
- Redesigned check verifies the resolved value parses to at least
  one valid entry AND at least one entry's path covers the
  workspace root (ancestor or self).
- States: ok (at least one covering entry); warn (entries exist
  but none cover workspace root — operator misconfigured); fail
  (zero parseable entries — empty or unparseable).
- Path-coverage uses path.resolve normalization + path.relative
  ancestor check; same shape as workspace-resolver isInside helper.
- Discriminator tests cover all five branches + ancestor-of-root
  coverage + pre-decoded-entries injection path.
- The parseAllowlistPairs decoder is byte-equivalent to ConfigStore.
  parseNamedPairs but duplicated locally rather than imported
  cross-package; the daemon domain module deliberately has no cli
  package import edge.
- Landing the redesign under release-wrap autonomy grant (banked
  feedback_release_wrap_autonomy_grant + orch marshal-accept
  cont.43-followup which authorized "you make the call and
  proceed"). Surfaced to orch + guard in the FR-5a handoff scope-
  tip; commit-body documents the divergence and rationale so
  reviewers can read the actual shipped behavior the check covers.

Check #4 — daemon_points_at_this_workspace:
- Compares the workspace.root the daemon process resolved at start
  vs the workspace.root the doctor caller expects. The common
  cause of divergence is the daemon being started in a shell with
  a different OPENRIG_WORKSPACE_ROOT than the operator's current
  shell.
- Uses path.resolve normalization on both sides so a daemon path
  with redundant `./` or `..` segments compares equal to the
  expected path post-normalization.
- Fix-hint: restart the daemon with the right env, or unset the
  env override entirely.

Check #5 — daemon_reload_needed:
- Compares config-file mtime to daemon process start-time. A newer
  mtime means an operator wrote config after the daemon started
  and the daemon hasn't picked the change up yet.
- ENOENT on the config file is OK (defaults-only daemon, no
  reload needed). Non-ENOENT stat errors warn (operator may have
  permission misconfiguration affecting freshness detection).
- Evidence carries staleMs (mtimeMs - startMs) so JSON consumers
  can sort or escalate by age.

Check #6 — optional_slice_docs (warn-only):
- Walks each `<missionsRoot>/<mission>/slices/<slice>/` and warns
  on slices lacking README.md, IMPLEMENTATION-PRD.md, or
  IMPL-PRD.md. Empty slices are sometimes intentional staging
  (IMPL-PRD §57-59), so this is warn-only and never fail.
- Reports the specific bare slices so operators can act.
- Missing missions root is warn-only too (graceful handling of
  pre-init or partial workspaces).

Check #7 — mission_notes_presence:
- Walks each mission directory and warns on missions lacking
  MISSION_NOTES.md (per the FR-3 convention that just merged at
  LOCAL main e154a5ce).
- Fix-hint references both `rig scope mission create` (the FR-3
  auto-scaffold path) and the substrate-side template for
  manually retrofitting existing missions.
- Reports the specific bare missions so operators can act.

Shape consistency:
- All 6 new checks use the same `{check, status, message, fixHint?,
  evidence?}` shape established by FR-5a check #1.
- Each check returns one DoctorCheck; the orchestrator function
  that composes all 7 lands with the daemon route in FR-5c.
- evidence objects carry the inputs + the specific failure-mode
  details so JSON consumers can branch on errorCode / kind / etc.

Tests (28 total: 5 from FR-5a + 23 new):
- 23 new discriminator-flip cases across checks #2-#7
- Every check has at least one ok-path + one negative-path test
- Negative tests assert the SPECIFIC failure shape (not just
  status !== ok) so production regressions surface in the
  corresponding test, not adjacent ones
- Path-normalization, source-aware fix-hints, default-vs-override
  branches, and the pre-decoded-entries injection path are each
  covered by their own discriminator

Verification:
- packages/daemon tsc EXIT=0
- packages/daemon workspace-doctor.test.ts: 28/28 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
- Commit body verification grep clean per banked discipline
- git diff --cached --stat: 2 files (+698 / -3)

Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: a4b4b67d (FR-5a) → this commit (FR-5b checks #2-#7). LOCAL
only, NO push.

Files:
- packages/daemon/src/domain/workspace/workspace-doctor.ts (+410 / -3)
- packages/daemon/test/workspace-doctor.test.ts (+291)
This commit is contained in:
mvschwarz committed 2026-06-01 21:09:09 -07:00
1 parent a4b4b67dae
commit e5d61983ae
2 files changed
+698 -3

No files matched your search

@@ -1,10 +1,10 @@
// 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):
// 7 checks (this file ships pure check helpers; daemon route + CLI
// subcommand wiring land in follow-on commits):
// 1. Workspace root reachable (env > file > default precedence)
// 2. Missions folder present
// 3. File allowlist sane
// 3. File allowlist sane (named-pair ConfigStore key, NOT a file)
// 4. Daemon points at this workspace
// 5. Daemon reload needed
// 6. Optional slice docs (warn-only)
@@ -18,6 +18,7 @@
// health); the divergence is orch-marshal-accepted per cont.43-followup.
import * as fs from "node:fs";
import * as path from "node:path";
export type CheckStatus = "ok" | "warn" | "fail";
@@ -94,3 +95,406 @@ export function checkWorkspaceRootReachable(opts: CheckWorkspaceRootInput): Doct
evidence: { workspaceRoot, source },
};
}
export interface CheckMissionsFolderInput {
workspaceRoot: string;
/** Resolved `workspace.slicesRoot` per ConfigStore (defaults to
* `<workspaceRoot>/missions`). Operators who customized
* `workspace.slices_root` (env or config) point this elsewhere; the
* check honors the override so a deliberate custom layout doesn't
* spuriously fail. */
slicesRoot: string;
}
/**
* Check #2 — missions folder present.
*
* Verifies the resolved missions folder (per ConfigStore `workspace.
* slicesRoot` — defaults to `<workspaceRoot>/missions`) exists as a
* directory. Fail on absence or wrong-shape; this is the load-bearing
* folder for Project UI projection.
*/
export function checkMissionsFolder(opts: CheckMissionsFolderInput): DoctorCheck {
const { workspaceRoot, slicesRoot } = opts;
let stat: fs.Stats;
try {
stat = fs.statSync(slicesRoot);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
return {
check: "missions_folder_present",
status: "fail",
message: code === "ENOENT"
? `missions folder '${slicesRoot}' does not exist`
: `missions folder '${slicesRoot}' is not reachable: ${(err as Error).message}`,
fixHint:
slicesRoot === path.join(workspaceRoot, "missions")
? "run `rig config init-workspace` to scaffold the default missions/ folder"
: "create the configured missions folder or unset workspace.slices_root to use the default `<workspaceRoot>/missions/`",
evidence: { slicesRoot, workspaceRoot, errorCode: code ?? "unknown" },
};
}
if (!stat.isDirectory()) {
return {
check: "missions_folder_present",
status: "fail",
message: `missions folder '${slicesRoot}' exists but is not a directory`,
fixHint:
"remove or rename the conflicting file and run `rig config init-workspace` to scaffold the missions folder",
evidence: { slicesRoot, workspaceRoot, kind: "not_a_directory" },
};
}
return {
check: "missions_folder_present",
status: "ok",
message: `missions folder '${slicesRoot}' is present`,
evidence: { slicesRoot, workspaceRoot },
};
}
export interface AllowlistEntry {
name: string;
path: string;
}
export interface CheckFileAllowlistInput {
workspaceRoot: string;
/** Raw `files.allowlist` value resolved via ConfigStore
* (comma-separated `name:/abs/path` pairs, or empty string). */
allowlistValue: string;
allowlistSource: WorkspaceRootSource;
/** Pre-decoded entries; if omitted the check decodes
* allowlistValue locally with the same comma + colon parser
* ConfigStore.parseNamedPairs uses. */
parsedEntries?: AllowlistEntry[];
}
/**
* Check #3 — file allowlist sane.
*
* IMPL-PRD §43 references `<workspace-root>/.openrig/file-allowlist`
* as a file — the shipped surface (ConfigStore `files.allowlist` key,
* env OPENRIG_FILES_ALLOWLIST, default `workspace:${workspaceRoot}`)
* is a CONFIGSTORE STRING KEY holding comma-separated `name:/abs/path`
* pairs. There is no per-workspace file. Check #3 verifies the
* resolved value parses to at least one valid entry, with a warning
* when no allowlist entry covers the workspaceRoot (the operator's
* file surface won't include workspace files).
*/
export function checkFileAllowlist(opts: CheckFileAllowlistInput): DoctorCheck {
const { workspaceRoot, allowlistValue, allowlistSource } = opts;
const entries = opts.parsedEntries ?? parseAllowlistPairs(allowlistValue);
if (entries.length === 0) {
return {
check: "file_allowlist_sane",
status: "fail",
message: `files.allowlist resolved to no valid entries (raw='${allowlistValue}', source=${allowlistSource})`,
fixHint:
"set OPENRIG_FILES_ALLOWLIST or run `rig config set files.allowlist workspace:<workspaceRoot>` so the file surface has a readable root",
evidence: { allowlistValue, allowlistSource, entryCount: 0 },
};
}
const covers = entries.some((e) => allowlistPathCoversRoot(e.path, workspaceRoot));
if (!covers) {
return {
check: "file_allowlist_sane",
status: "warn",
message: `files.allowlist has ${entries.length} entr${entries.length === 1 ? "y" : "ies"} but none cover workspace root '${workspaceRoot}'`,
fixHint:
"add a `workspace:<workspaceRoot>` entry to files.allowlist (or set OPENRIG_FILES_ALLOWLIST) so workspace files are read-allowed",
evidence: {
allowlistValue,
allowlistSource,
entryCount: entries.length,
workspaceRoot,
entries,
},
};
}
return {
check: "file_allowlist_sane",
status: "ok",
message: `files.allowlist has ${entries.length} entr${entries.length === 1 ? "y" : "ies"} covering workspace root`,
evidence: { allowlistValue, allowlistSource, entryCount: entries.length, entries },
};
}
/** Local-only decoder, byte-identical to ConfigStore.parseNamedPairs.
* Duplicated (rather than imported from cli) to keep this domain
* module daemon-package-only with no cli cross-package import. */
function parseAllowlistPairs(raw: string): AllowlistEntry[] {
if (!raw || !raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
out.set(name, rawPath);
}
return Array.from(out.entries()).map(([name, p]) => ({ name, path: p }));
}
function allowlistPathCoversRoot(allowlistPath: string, workspaceRoot: string): boolean {
const normEntry = path.resolve(allowlistPath);
const normRoot = path.resolve(workspaceRoot);
if (normEntry === normRoot) return true;
const rel = path.relative(normEntry, normRoot);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
export interface CheckDaemonWorkspaceInput {
/** workspace.root the daemon process resolves (server-side). */
daemonResolvedRoot: string;
/** workspace.root the doctor caller expects (CLI-side resolved,
* passed in the request body or matched against the --workspace
* flag). */
expectedRoot: string;
}
/**
* Check #4 — daemon points at this workspace.
*
* Reports divergence between the workspace.root the daemon resolved at
* start-time vs the workspace.root the doctor caller expects. The
* common cause is the daemon being started in a shell with a different
* OPENRIG_WORKSPACE_ROOT than the operator's current shell.
*/
export function checkDaemonWorkspace(opts: CheckDaemonWorkspaceInput): DoctorCheck {
const { daemonResolvedRoot, expectedRoot } = opts;
const normDaemon = path.resolve(daemonResolvedRoot);
const normExpected = path.resolve(expectedRoot);
if (normDaemon === normExpected) {
return {
check: "daemon_points_at_this_workspace",
status: "ok",
message: `daemon and caller agree on workspace root '${normDaemon}'`,
evidence: { daemonResolvedRoot: normDaemon, expectedRoot: normExpected },
};
}
return {
check: "daemon_points_at_this_workspace",
status: "fail",
message: `daemon resolved workspace root '${normDaemon}' but caller expected '${normExpected}'`,
fixHint:
"restart the daemon (`rig daemon restart`) in a shell where OPENRIG_WORKSPACE_ROOT matches the expected workspace, or unset OPENRIG_WORKSPACE_ROOT to fall through to config + default",
evidence: { daemonResolvedRoot: normDaemon, expectedRoot: normExpected },
};
}
export interface CheckDaemonReloadInput {
/** Path to the ConfigStore config file on disk. */
configFilePath: string;
/** Daemon process start time (epoch-ms or Date). Compared to config
* file mtime; mtime > startTime → stale daemon. */
daemonStartTime: Date;
}
/**
* Check #5 — daemon reload needed.
*
* Compares config-file mtime to the daemon's start-time. A newer
* mtime means the operator edited config (via CLI / UI) after the
* daemon started, and the daemon hasn't picked the change up yet.
* Missing config file is not a fail — fresh installs without an
* operator-written config use defaults entirely and never need a
* reload.
*/
export function checkDaemonReload(opts: CheckDaemonReloadInput): DoctorCheck {
const { configFilePath, daemonStartTime } = opts;
let stat: fs.Stats;
try {
stat = fs.statSync(configFilePath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return {
check: "daemon_reload_needed",
status: "ok",
message: `no config file at '${configFilePath}'; daemon is running on defaults only`,
evidence: { configFilePath, configFileExists: false },
};
}
return {
check: "daemon_reload_needed",
status: "warn",
message: `cannot stat config file '${configFilePath}': ${(err as Error).message}`,
fixHint: "verify file permissions on the config file path so the daemon can detect freshness",
evidence: { configFilePath, errorCode: code ?? "unknown" },
};
}
const mtimeMs = stat.mtime.getTime();
const startMs = daemonStartTime.getTime();
if (mtimeMs > startMs) {
return {
check: "daemon_reload_needed",
status: "warn",
message: `config file mtime ${stat.mtime.toISOString()} is newer than daemon start ${daemonStartTime.toISOString()}`,
fixHint: "run `rig daemon restart` to pick up the latest config",
evidence: {
configFilePath,
configMtime: stat.mtime.toISOString(),
daemonStartTime: daemonStartTime.toISOString(),
staleMs: mtimeMs - startMs,
},
};
}
return {
check: "daemon_reload_needed",
status: "ok",
message: `config file mtime ${stat.mtime.toISOString()} is older than daemon start ${daemonStartTime.toISOString()}`,
evidence: {
configFilePath,
configMtime: stat.mtime.toISOString(),
daemonStartTime: daemonStartTime.toISOString(),
},
};
}
export interface CheckSliceDocsInput {
/** Resolved missions folder root. The check walks each
* `<missionsRoot>/<mission>/slices/<slice>/` and verifies it has
* README.md OR IMPLEMENTATION-PRD.md OR IMPL-PRD.md. */
missionsRoot: string;
}
const SLICE_DOC_FILES = ["README.md", "IMPLEMENTATION-PRD.md", "IMPL-PRD.md"] as const;
interface BareSlice {
mission: string;
slice: string;
path: string;
}
/**
* Check #6 — optional slice docs.
*
* Walks each mission's slices subdirectory and reports slices that
* have NEITHER a README.md, IMPLEMENTATION-PRD.md, nor IMPL-PRD.md.
* Warn-only (empty slices are sometimes intentional staging per
* IMPL-PRD §57-59). The walk is bounded to one mission + one slice
* level; we don't recurse into slice subdirs.
*/
export function checkOptionalSliceDocs(opts: CheckSliceDocsInput): DoctorCheck {
const { missionsRoot } = opts;
let missions: string[];
try {
missions = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
return {
check: "optional_slice_docs",
status: "warn",
message: code === "ENOENT"
? `missions root '${missionsRoot}' does not exist; no slice docs to check`
: `cannot read missions root '${missionsRoot}': ${(err as Error).message}`,
evidence: { missionsRoot, errorCode: code ?? "unknown" },
};
}
const bareSlices: BareSlice[] = [];
for (const mission of missions) {
const slicesDir = path.join(missionsRoot, mission, "slices");
let slices: string[];
try {
slices = fs.readdirSync(slicesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch {
continue; // no slices subdir is fine — mission may not be slice-organized
}
for (const slice of slices) {
const slicePath = path.join(slicesDir, slice);
const hasDoc = SLICE_DOC_FILES.some((f) => fs.existsSync(path.join(slicePath, f)));
if (!hasDoc) {
bareSlices.push({ mission, slice, path: slicePath });
}
}
}
if (bareSlices.length === 0) {
return {
check: "optional_slice_docs",
status: "ok",
message: `every slice under '${missionsRoot}' has a README, IMPLEMENTATION-PRD, or IMPL-PRD`,
evidence: { missionsRoot, bareSlices: [] },
};
}
return {
check: "optional_slice_docs",
status: "warn",
message: `${bareSlices.length} slice${bareSlices.length === 1 ? " has" : "s have"} no README / IMPLEMENTATION-PRD / IMPL-PRD`,
fixHint:
"author a README.md, IMPLEMENTATION-PRD.md, or IMPL-PRD.md in each bare slice directory; warn-only because empty slices are sometimes intentional staging",
evidence: { missionsRoot, bareSlices },
};
}
export interface CheckMissionNotesInput {
missionsRoot: string;
}
interface MissionWithoutNotes {
mission: string;
path: string;
}
/**
* Check #7 — MISSION_NOTES presence.
*
* Verifies each mission directory has a `MISSION_NOTES.md` (per the
* FR-3 convention that just merged at LOCAL main e154a5ce). Warn-
* only because legacy missions predate the convention; the
* operator can run `rig scope mission create` (which now auto-
* scaffolds the file per FR-3) for new missions.
*/
export function checkMissionNotesPresence(opts: CheckMissionNotesInput): DoctorCheck {
const { missionsRoot } = opts;
let missions: string[];
try {
missions = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
return {
check: "mission_notes_presence",
status: "warn",
message: code === "ENOENT"
? `missions root '${missionsRoot}' does not exist; no MISSION_NOTES to check`
: `cannot read missions root '${missionsRoot}': ${(err as Error).message}`,
evidence: { missionsRoot, errorCode: code ?? "unknown" },
};
}
const missing: MissionWithoutNotes[] = [];
for (const mission of missions) {
const missionDir = path.join(missionsRoot, mission);
const notesPath = path.join(missionDir, "MISSION_NOTES.md");
if (!fs.existsSync(notesPath)) {
missing.push({ mission, path: missionDir });
}
}
if (missing.length === 0) {
return {
check: "mission_notes_presence",
status: "ok",
message: `every mission under '${missionsRoot}' has MISSION_NOTES.md`,
evidence: { missionsRoot, missing: [] },
};
}
return {
check: "mission_notes_presence",
status: "warn",
message: `${missing.length} mission${missing.length === 1 ? " has" : "s have"} no MISSION_NOTES.md`,
fixHint:
"scaffold via `rig scope mission create <id>` (FR-3 auto-scaffold) for new missions, or copy from `<substrate>/openrig-work/conventions/mission-notes/TEMPLATE.md` for existing ones",
evidence: { missionsRoot, missing },
};
}
@@ -10,6 +10,12 @@ import * as os from "node:os";
import * as path from "node:path";
import {
checkWorkspaceRootReachable,
checkMissionsFolder,
checkFileAllowlist,
checkDaemonWorkspace,
checkDaemonReload,
checkOptionalSliceDocs,
checkMissionNotesPresence,
type DoctorCheck,
} from "../src/domain/workspace/workspace-doctor.js";
@@ -94,3 +100,288 @@ describe("FR-5 check #1 — workspace root reachable", () => {
}
});
});
describe("FR-5 check #2 — missions folder present", () => {
it("returns ok when default missions folder exists", () => {
const missionsDir = path.join(dir, "missions");
fs.mkdirSync(missionsDir);
const result = checkMissionsFolder({ workspaceRoot: dir, slicesRoot: missionsDir });
expect(result.check).toBe("missions_folder_present");
expect(result.status).toBe("ok");
expect(result.evidence?.slicesRoot).toBe(missionsDir);
});
// Discriminator-flip: missing folder must fail with default-fix-hint
// since slicesRoot equals workspaceRoot/missions.
it("returns fail with default fix-hint when missions folder is absent (default path)", () => {
const missing = path.join(dir, "missions");
const result = checkMissionsFolder({ workspaceRoot: dir, slicesRoot: missing });
expect(result.status).toBe("fail");
expect(result.fixHint).toContain("rig config init-workspace");
expect(result.evidence?.errorCode).toBe("ENOENT");
});
// Discriminator-flip: when operator overrode slicesRoot, fix-hint
// should NOT recommend running init-workspace (that creates the
// default missions/ in the workspace root, not the custom path).
it("returns fail with custom-path fix-hint when overridden slicesRoot is absent", () => {
const custom = path.join(dir, "custom-elsewhere");
const result = checkMissionsFolder({ workspaceRoot: dir, slicesRoot: custom });
expect(result.status).toBe("fail");
expect(result.fixHint).toContain("unset workspace.slices_root");
expect(result.fixHint).not.toContain("rig config init-workspace");
});
// Discriminator-flip: file-at-missions-path. Without isDirectory()
// gate the check would falsely return ok.
it("returns fail when missions path is a regular file", () => {
const filePath = path.join(dir, "missions");
fs.writeFileSync(filePath, "");
const result = checkMissionsFolder({ workspaceRoot: dir, slicesRoot: filePath });
expect(result.status).toBe("fail");
expect(result.message).toContain("not a directory");
expect(result.evidence?.kind).toBe("not_a_directory");
});
});
describe("FR-5 check #3 — file allowlist sane", () => {
it("returns ok when allowlist has at least one entry covering workspace root", () => {
const result = checkFileAllowlist({
workspaceRoot: dir,
allowlistValue: `workspace:${dir}`,
allowlistSource: "default",
});
expect(result.check).toBe("file_allowlist_sane");
expect(result.status).toBe("ok");
expect(result.evidence?.entryCount).toBe(1);
});
// Discriminator-flip: empty value must fail; without the entries.
// length === 0 check it would pass through to coverage logic.
it("returns fail with explicit fix-hint when allowlist is empty", () => {
const result = checkFileAllowlist({
workspaceRoot: dir,
allowlistValue: "",
allowlistSource: "default",
});
expect(result.status).toBe("fail");
expect(result.fixHint).toContain("OPENRIG_FILES_ALLOWLIST");
expect(result.fixHint).toContain("rig config set");
expect(result.evidence?.entryCount).toBe(0);
});
// Discriminator-flip: unparseable value (no colon) must fail. Without
// the parseAllowlistPairs colon-required check, garbage would pass.
it("returns fail when allowlist is malformed (no colon)", () => {
const result = checkFileAllowlist({
workspaceRoot: dir,
allowlistValue: "garbage-no-colon-here",
allowlistSource: "env",
});
expect(result.status).toBe("fail");
expect(result.evidence?.entryCount).toBe(0);
});
// Discriminator-flip: allowlist with valid entries but NONE covering
// workspace must warn (not ok, not fail). Without the covers check
// it would falsely return ok.
it("returns warn when allowlist entries are valid but none cover the workspace root", () => {
const elsewhere = path.join(dir, "elsewhere-1");
fs.mkdirSync(elsewhere);
const result = checkFileAllowlist({
workspaceRoot: dir,
allowlistValue: `other:${elsewhere}`,
allowlistSource: "file",
});
expect(result.status).toBe("warn");
expect(result.message).toContain("none cover workspace root");
expect(result.fixHint).toContain("workspace:");
expect(result.evidence?.entryCount).toBe(1);
});
// Sub-directory coverage discriminator: an allowlist entry that is
// an ANCESTOR of the workspace root SHOULD cover it.
it("returns ok when an allowlist entry is an ancestor of the workspace root", () => {
const sub = path.join(dir, "deep-sub");
fs.mkdirSync(sub);
const result = checkFileAllowlist({
workspaceRoot: sub,
allowlistValue: `parent:${dir}`,
allowlistSource: "env",
});
expect(result.status).toBe("ok");
});
// Pre-decoded entries path: pre-parsed entries should bypass the
// local parser and be honored verbatim. Discriminator: pass an
// entry shape that the parser couldn't have produced.
it("uses pre-decoded entries when provided", () => {
const result = checkFileAllowlist({
workspaceRoot: dir,
allowlistValue: "raw-value-different-from-entries",
allowlistSource: "default",
parsedEntries: [{ name: "ws", path: dir }],
});
expect(result.status).toBe("ok");
expect(result.evidence?.entryCount).toBe(1);
});
});
describe("FR-5 check #4 — daemon points at this workspace", () => {
it("returns ok when daemon and caller agree on workspace root", () => {
const result = checkDaemonWorkspace({ daemonResolvedRoot: dir, expectedRoot: dir });
expect(result.check).toBe("daemon_points_at_this_workspace");
expect(result.status).toBe("ok");
});
// Discriminator-flip: differing paths must fail. Without the
// string comparison the check would falsely return ok.
it("returns fail when daemon resolved a different workspace root", () => {
const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5-other-"));
try {
const result = checkDaemonWorkspace({
daemonResolvedRoot: otherDir,
expectedRoot: dir,
});
expect(result.status).toBe("fail");
expect(result.message).toContain(otherDir);
expect(result.message).toContain(dir);
expect(result.fixHint).toContain("rig daemon restart");
expect(result.fixHint).toContain("OPENRIG_WORKSPACE_ROOT");
} finally {
fs.rmSync(otherDir, { recursive: true, force: true });
}
});
// Discriminator-flip: path.resolve normalization. A daemon path
// with redundant `./` segments equal to expected should still
// compare equal post-normalization.
it("normalizes paths before comparing", () => {
const result = checkDaemonWorkspace({
daemonResolvedRoot: path.join(dir, ".", "sub", ".."),
expectedRoot: dir,
});
expect(result.status).toBe("ok");
});
});
describe("FR-5 check #5 — daemon reload needed", () => {
it("returns ok when config file is older than daemon start", () => {
const cfg = path.join(dir, "config.json");
fs.writeFileSync(cfg, "{}");
const oldMtime = new Date(Date.now() - 60_000);
fs.utimesSync(cfg, oldMtime, oldMtime);
const start = new Date(Date.now() - 5_000);
const result = checkDaemonReload({ configFilePath: cfg, daemonStartTime: start });
expect(result.check).toBe("daemon_reload_needed");
expect(result.status).toBe("ok");
});
// Discriminator-flip: config newer than daemon start MUST warn.
// Without the mtimeMs > startMs comparison, freshness would never trip.
it("returns warn when config file mtime is newer than daemon start", () => {
const cfg = path.join(dir, "config.json");
fs.writeFileSync(cfg, "{}");
const newMtime = new Date(Date.now());
fs.utimesSync(cfg, newMtime, newMtime);
const start = new Date(Date.now() - 60_000);
const result = checkDaemonReload({ configFilePath: cfg, daemonStartTime: start });
expect(result.status).toBe("warn");
expect(result.fixHint).toContain("rig daemon restart");
expect(result.evidence?.staleMs).toBeGreaterThan(0);
});
// Discriminator-flip: ENOENT must NOT fail or warn. A daemon
// running on pure defaults (no config file) is healthy state.
it("returns ok when config file does not exist (defaults-only daemon)", () => {
const missing = path.join(dir, "no-such-config.json");
const result = checkDaemonReload({
configFilePath: missing,
daemonStartTime: new Date(),
});
expect(result.status).toBe("ok");
expect(result.evidence?.configFileExists).toBe(false);
});
});
describe("FR-5 check #6 — optional slice docs", () => {
it("returns ok when every slice has README, IMPLEMENTATION-PRD, or IMPL-PRD", () => {
const missions = path.join(dir, "missions");
fs.mkdirSync(path.join(missions, "m1", "slices", "s1"), { recursive: true });
fs.mkdirSync(path.join(missions, "m1", "slices", "s2"), { recursive: true });
fs.writeFileSync(path.join(missions, "m1", "slices", "s1", "README.md"), "");
fs.writeFileSync(path.join(missions, "m1", "slices", "s2", "IMPL-PRD.md"), "");
const result = checkOptionalSliceDocs({ missionsRoot: missions });
expect(result.check).toBe("optional_slice_docs");
expect(result.status).toBe("ok");
expect(result.evidence?.bareSlices).toEqual([]);
});
// Discriminator-flip: a slice with NEITHER doc shape must warn.
// Without the SLICE_DOC_FILES.some() check, bare slices would
// silently pass.
it("returns warn naming bare slices that have no doc shape", () => {
const missions = path.join(dir, "missions");
fs.mkdirSync(path.join(missions, "m1", "slices", "bare-slice"), { recursive: true });
fs.mkdirSync(path.join(missions, "m1", "slices", "ok-slice"), { recursive: true });
fs.writeFileSync(path.join(missions, "m1", "slices", "ok-slice", "README.md"), "");
const result = checkOptionalSliceDocs({ missionsRoot: missions });
expect(result.status).toBe("warn");
const bare = result.evidence?.bareSlices as Array<{ mission: string; slice: string }>;
expect(bare).toHaveLength(1);
expect(bare[0]?.slice).toBe("bare-slice");
});
// Discriminator-flip: any of the 3 doc-file names should pass.
it("treats IMPLEMENTATION-PRD.md as equivalent to README.md and IMPL-PRD.md", () => {
const missions = path.join(dir, "missions");
fs.mkdirSync(path.join(missions, "m1", "slices", "s1"), { recursive: true });
fs.writeFileSync(path.join(missions, "m1", "slices", "s1", "IMPLEMENTATION-PRD.md"), "");
const result = checkOptionalSliceDocs({ missionsRoot: missions });
expect(result.status).toBe("ok");
});
// Discriminator-flip: missing missions root must warn (not fail).
// The check is warn-only per IMPL-PRD §57-59.
it("returns warn (not fail) when missions root is absent", () => {
const result = checkOptionalSliceDocs({ missionsRoot: path.join(dir, "no-missions") });
expect(result.status).toBe("warn");
expect(result.evidence?.errorCode).toBe("ENOENT");
});
});
describe("FR-5 check #7 — MISSION_NOTES presence", () => {
it("returns ok when every mission directory has MISSION_NOTES.md", () => {
const missions = path.join(dir, "missions");
fs.mkdirSync(path.join(missions, "m1"), { recursive: true });
fs.mkdirSync(path.join(missions, "m2"), { recursive: true });
fs.writeFileSync(path.join(missions, "m1", "MISSION_NOTES.md"), "");
fs.writeFileSync(path.join(missions, "m2", "MISSION_NOTES.md"), "");
const result = checkMissionNotesPresence({ missionsRoot: missions });
expect(result.check).toBe("mission_notes_presence");
expect(result.status).toBe("ok");
expect(result.evidence?.missing).toEqual([]);
});
// Discriminator-flip: missing MISSION_NOTES must warn and name the
// specific missions; without naming, operator can't act.
it("returns warn naming missions without MISSION_NOTES.md", () => {
const missions = path.join(dir, "missions");
fs.mkdirSync(path.join(missions, "with-notes"), { recursive: true });
fs.mkdirSync(path.join(missions, "no-notes-1"), { recursive: true });
fs.mkdirSync(path.join(missions, "no-notes-2"), { recursive: true });
fs.writeFileSync(path.join(missions, "with-notes", "MISSION_NOTES.md"), "");
const result = checkMissionNotesPresence({ missionsRoot: missions });
expect(result.status).toBe("warn");
const missing = result.evidence?.missing as Array<{ mission: string }>;
expect(missing.map((m) => m.mission).sort()).toEqual(["no-notes-1", "no-notes-2"]);
expect(result.fixHint).toContain("rig scope mission create");
});
// Discriminator-flip: missions root absent should warn-only.
it("returns warn (not fail) when missions root is absent", () => {
const result = checkMissionNotesPresence({ missionsRoot: path.join(dir, "no-missions") });
expect(result.status).toBe("warn");
expect(result.evidence?.errorCode).toBe("ENOENT");
});
});