mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-26 19:25:34 +00:00
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:
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
commandPath,
|
||||
startupPolicy,
|
||||
allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath),
|
||||
skipConfigGuard: shouldBypassConfigGuardForCommandPath(commandPath),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user