Doctor: add health-check contract and --lint validation (#80055)

* feat(doctor): add --lint mode + structured HealthFinding shape

Adds the core machinery for `openclaw doctor --lint` per the
doctor-lint-and-oc-rules upstream proposal. PR-1 of the proposal:
no new top-level verb, no public plugin SDK; everything internal.

Files:
- src/flows/checks.ts ? HealthFinding / HealthCheck / HealthCheckContext
   types. Findings carry severity per-finding; checks return
   readonly HealthFinding[]. Mode tag (doctor/lint/fix) lets a check
   distinguish the calling posture.
- src/flows/health-check-registry.ts ? module-level registry with
   duplicate-id rejection + test reset helper.
- src/flows/doctor-lint-flow.ts ? runner over registered checks.
   Catches throws into synthetic error findings (anchored at check id;
   message scrubbed of control chars, capped at 256 bytes). Sorts
   findings by severity desc, check id, path. Exports
   exitCodeFromFindings (1 if any warning/error, 0 otherwise).
- src/flows/doctor-core-checks.ts ? 4 modern HealthChecks rewriting
   logic from existing legacy run*Health functions:
     core/doctor/gateway-config            (warning)
     core/doctor/command-owner             (info)
     core/doctor/workspace-status          (info)
     core/doctor/final-config-validation   (error)
   Each was audited safe per the proposal's adapter constraints
   (no writes, no repair calls, no prompts, no probes incl. local-bind).
   Legacy run*Health contributions in doctor-health-contributions.ts
   are unchanged ? doctor mode (no --lint) still runs the existing 35.
- src/commands/doctor-lint.ts ? CLI dispatch for --lint. Reads config
   snapshot, builds HealthCheckContext (mode: "lint"), runs the registry,
   filters by --severity-min, emits human or JSON output, returns exit
   code from unfiltered set so --severity-min hides info findings
   without changing CI signal.
- src/cli/program/register.maintenance.ts ? adds --lint, --json,
   --severity-min, --skip, --only flags to existing doctor command.
   --lint branches to runDoctorLintCli; without --lint, doctor runs
   unchanged.

LoC: 382 src across 6 files. Tests + doc + oc-path-side rule packs
follow as separate commits on this branch.

* fix: avoid string spread in doctor errors

* chore: refresh plugin SDK API baseline

* docs: clarify doctor lint usage

* feat(doctor): prepare repairs for dry-run reporting
This commit is contained in:
Gio Della-Libera
2026-05-17 12:29:57 -07:00
committed by GitHub
parent 0dc04fb926
commit 9a5f2f61e7
33 changed files with 1771 additions and 35 deletions

View File

@@ -134,7 +134,7 @@ describe("registerPreActionHooks", () => {
.command("create")
.option("--json")
.action(() => {});
program.command("doctor").action(() => {});
program.command("doctor").option("--lint").action(() => {});
program.command("completion").action(() => {});
program.command("secrets").action(() => {});
program
@@ -334,6 +334,16 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("skips the config guard and plugin loading for doctor lint", async () => {
await runPreAction({
parseArgv: ["doctor"],
processArgv: ["node", "openclaw", "doctor", "--lint"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("only allows invalid config for explicit official recovery reinstall requests", async () => {
await runPreAction({
parseArgv: ["plugins", "install", "@openclaw/discord"],
@@ -529,6 +539,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for config validate when root option values are present", async () => {
@@ -538,6 +549,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for config schema", async () => {
@@ -547,6 +559,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for backup create", async () => {
@@ -556,6 +569,7 @@ describe("registerPreActionHooks", () => {
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
@@ -595,7 +609,7 @@ describe("registerPreActionHooks", () => {
preAction?: Array<(thisCommand: Command, actionCommand: Command) => Promise<void> | void>;
};
}
)._lifeCycleHooks?.preAction;
)["_lifeCycleHooks"]?.preAction;
preActionHook = hooks?.[0] ?? null;
});
});

View File

@@ -133,6 +133,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
commandPath,
startupPolicy,
allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath),
skipConfigGuard: shouldBypassConfigGuardForCommandPath(commandPath),
});
});
}

View File

@@ -12,9 +12,17 @@ const mocks = vi.hoisted(() => ({
error: vi.fn(),
exit: vi.fn(),
},
runDoctorLintCli: vi.fn(),
}));
const { doctorCommand, dashboardCommand, resetCommand, uninstallCommand, runtime } = mocks;
const {
doctorCommand,
dashboardCommand,
resetCommand,
uninstallCommand,
runtime,
runDoctorLintCli,
} = mocks;
vi.mock("../../commands/doctor.js", () => ({
doctorCommand: mocks.doctorCommand,
@@ -32,6 +40,10 @@ vi.mock("../../commands/uninstall.js", () => ({
uninstallCommand: mocks.uninstallCommand,
}));
vi.mock("../../commands/doctor-lint.js", () => ({
runDoctorLintCli: mocks.runDoctorLintCli,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
@@ -89,6 +101,51 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(options.repair).toBe(true);
});
it("runs doctor lint mode without invoking repair doctor", async () => {
runDoctorLintCli.mockResolvedValue(1);
await runMaintenanceCli([
"doctor",
"--lint",
"--json",
"--severity-min",
"error",
"--skip",
"a",
"--only",
"b",
]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, {
json: true,
severityMin: "error",
skipIds: ["a"],
onlyIds: ["b"],
});
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("exits with code 2 when doctor lint mode fails before findings are emitted", async () => {
runDoctorLintCli.mockRejectedValue(new Error("lint failed"));
await runMaintenanceCli(["doctor", "--lint"]);
expect(runtime.error).toHaveBeenCalledWith("Error: lint failed");
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("rejects lint-only selectors outside lint mode", async () => {
await runMaintenanceCli(["doctor", "--only", "core/example"]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
);
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("passes noOpen to dashboard command", async () => {
dashboardCommand.mockResolvedValue(undefined);

View File

@@ -25,7 +25,52 @@ export function registerMaintenanceCommands(program: Command) {
.option("--non-interactive", "Run without prompts (safe migrations only)", false)
.option("--generate-gateway-token", "Generate and configure a gateway token", false)
.option("--deep", "Scan system services for extra gateway installs", false)
.option("--lint", "Run read-only health checks and report findings", false)
.option("--json", "With --lint: emit JSON findings instead of human output", false)
.option(
"--severity-min <level>",
"With --lint: drop findings below this severity (info|warning|error)",
)
.option(
"--skip <id>",
"With --lint: skip a specific check id (repeatable)",
(v: string, prev: string[]) => [...prev, v],
[],
)
.option(
"--only <id>",
"With --lint: run only the specified check id (repeatable)",
(v: string, prev: string[]) => [...prev, v],
[],
)
.action(async (opts) => {
if (opts.lint === true) {
await runCommandWithRuntime(
defaultRuntime,
async () => {
const { runDoctorLintCli } = await import("../../commands/doctor-lint.js");
const exitCode = await runDoctorLintCli(defaultRuntime, {
json: Boolean(opts.json),
severityMin: typeof opts.severityMin === "string" ? opts.severityMin : undefined,
skipIds: Array.isArray(opts.skip) ? opts.skip : [],
onlyIds: Array.isArray(opts.only) ? opts.only : [],
});
defaultRuntime.exit(exitCode);
},
(err) => {
defaultRuntime.error(String(err));
defaultRuntime.exit(2);
},
);
return;
}
if (hasLintOnlyDoctorOptions(opts)) {
defaultRuntime.error(
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
);
defaultRuntime.exit(2);
return;
}
await runCommandWithRuntime(defaultRuntime, async () => {
await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions,
@@ -113,3 +158,17 @@ export function registerMaintenanceCommands(program: Command) {
});
});
}
function hasLintOnlyDoctorOptions(opts: {
readonly json?: boolean;
readonly severityMin?: unknown;
readonly skip?: unknown;
readonly only?: unknown;
}): boolean {
return (
opts.json === true ||
typeof opts.severityMin === "string" ||
(Array.isArray(opts.skip) && opts.skip.length > 0) ||
(Array.isArray(opts.only) && opts.only.length > 0)
);
}