mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
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 maine154a5ce). - 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 maine154a5ce. 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:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user